diff --git a/Cargo.lock b/Cargo.lock index ceb618da5..3e183ff45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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/apps/obc-ios-host/src/lib.rs b/apps/obc-ios-host/src/lib.rs index 1d1f2617c..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. @@ -160,7 +163,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,9 +181,10 @@ 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); + 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 591c55136..ea4d0f436 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. @@ -147,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() { @@ -200,7 +225,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..fc9499815 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()); @@ -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_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 c53887237..fd2403cb3 100644 --- a/apps/obc-sim/src/main.rs +++ b/apps/obc-sim/src/main.rs @@ -39,9 +39,12 @@ 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 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 { - connected: bool, + link: obc_app::BleLink, paired: bool, passkey: Option, } @@ -72,6 +75,7 @@ enum Injection { TripUpload { id: obc_app::CatalogObjectId }, MapTransfer(obc_app::screen::MapTransfer), Warning(obc_app::Alerts), + BondClear(Result), } #[derive(Clone)] @@ -132,6 +136,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 +212,7 @@ impl Default for Args { no_sound: false, expect_screen: None, boot: false, + fresh: false, routes_dir: None, trip_progress: None, card: None, @@ -399,7 +406,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( @@ -409,7 +418,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) @@ -419,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 = @@ -475,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()), } } @@ -547,6 +566,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")?), @@ -707,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 @@ -715,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) } @@ -984,6 +1009,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); @@ -1021,6 +1052,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) @@ -1028,7 +1060,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: @@ -1036,7 +1068,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) @@ -1265,8 +1297,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; } @@ -1329,6 +1367,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_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. @@ -1365,11 +1404,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 @@ -1385,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()), @@ -1899,8 +1937,13 @@ 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", "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 }) @@ -1986,6 +2029,7 @@ mod cli_tests { "--script-after", "--expect-screen", "--boot", + "--fresh", "--routes-dir", "--tracks-dir", "--import", 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..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,6 +84,8 @@ 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}`); await expect.poll(() => page.locator(`#${anchor}`).evaluate(element => element.getBoundingClientRect().top - document.querySelector('.site-head').getBoundingClientRect().bottom diff --git a/docs/content/software/companion-link.md b/docs/content/software/companion-link.md index 00c26bba4..58165ddf1 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
- + @@ -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 @@ -311,7 +312,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 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 diff --git a/firmware/obc-app/Cargo.toml b/firmware/obc-app/Cargo.toml index 0bb104e4b..b62e37287 100644 --- a/firmware/obc-app/Cargo.toml +++ b/firmware/obc-app/Cargo.toml @@ -49,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/i18n/de.toml b/firmware/obc-app/i18n/de.toml index c459dfb5e..d0445bb42 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 --- @@ -311,6 +312,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] @@ -329,9 +332,10 @@ 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" -complete = "Zurückgesetzt" -restarting = "Neustart" factory = "Werksreset" erases = "Löscht alle Werte" saved_time = "& die Uhrzeit." @@ -382,6 +386,12 @@ title = "KOPPLUNG" enter_code = "Code eingeben" 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" + # --- Rides list screen --- [rides] title = "FAHRTEN" @@ -527,6 +537,42 @@ tomorrow = "MORGEN" est = "gesch." ok = "OK" +# --- Ersteinrichtung --- +[setup] +ok = "OK" +choose = "Wählen" +buttons_title = "TASTEN" +press_each = "Drücke jede Taste" +hold_back = "BACK halten" +menu_anywhere = "Menü, von überall" +continue = "Weiter" +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" +offline = "Funktioniert offline" +needs_app = "Braucht die App" +recording = "Aufzeichnung" +ride_sync = "Fahrten-Sync" +show_code = "Code zeigen" +paired_title = "GEKOPPELT" +phone_paired = "Handy gekoppelt" +all_set = "ALLES BEREIT" +no_phone = "Kein Handy" +sensor = "Sensor" +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] title = "FAHRT GERETTET" diff --git a/firmware/obc-app/i18n/en.toml b/firmware/obc-app/i18n/en.toml index 162af4716..a59f8ece2 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 --- @@ -338,6 +339,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] @@ -357,14 +361,15 @@ 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." 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 --- @@ -409,6 +414,12 @@ 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" +unplug = "Unplug cable to pair" +enable_bluetooth = "Turn Bluetooth on" + # --- Rides list screen --- [rides] title = "RIDES" @@ -561,6 +572,51 @@ tomorrow = "TOMORROW" est = "est." ok = "OK" +# --- First-use setup --- +[setup] +ok = "OK" +# The controls hint beside the Up and Down glyphs. +choose = "Choose" +# The button lesson. It names the buttons in English in every language, so `hold_back` keeps "BACK". +buttons_title = "BUTTONS" +press_each = "Press each button" +hold_back = "Hold BACK" +menu_anywhere = "Menu from anywhere" +continue = "Continue" +skip = "Skip" +# The titles of the units, theme and effort steps. +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?" +no_app_title = "WITHOUT APP" +offline = "Works offline" +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. +paired_title = "PAIRED" +phone_paired = "Phone paired" +# 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" +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] title = "RIDE RECOVERED" diff --git a/firmware/obc-app/i18n/es.toml b/firmware/obc-app/i18n/es.toml index 57ba054ca..847024b52 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 --- @@ -310,6 +311,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] @@ -328,9 +331,10 @@ 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" -complete = "Restablecido" -restarting = "Reiniciando" factory = "Restablecer" erases = "Borra los ajustes" saved_time = "y la hora." @@ -381,6 +385,12 @@ 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] +enable_bluetooth = "Activa Bluetooth" +scan = "Escanea con el móvil" +unplug = "Desconecta el cable" + # --- Rides list screen --- [rides] title = "SALIDAS" @@ -526,6 +536,42 @@ tomorrow = "MAÑANA" est = "est." ok = "OK" +# --- Configuración inicial --- +[setup] +ok = "OK" +choose = "Elegir" +buttons_title = "BOTONES" +press_each = "Pulsa cada botón" +hold_back = "Mantén BACK" +menu_anywhere = "El menú, siempre" +continue = "Continuar" +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" +offline = "Funciona sin app" +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" +all_set = "TODO LISTO" +no_phone = "Sin móvil" +sensor = "sensor" +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] title = "RECUPERACIÓN" diff --git a/firmware/obc-app/i18n/fr.toml b/firmware/obc-app/i18n/fr.toml index 4f99569eb..a2744166e 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 --- @@ -310,6 +311,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] @@ -328,9 +331,10 @@ 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" -complete = "Réinitialisé" -restarting = "Redémarrage" factory = "Réinit. usine" erases = "Efface les réglages" saved_time = "& l'heure." @@ -381,6 +385,12 @@ title = "APPAIRAGE" enter_code = "Entrez ce code" 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" + # --- Rides list screen --- [rides] title = "SORTIES" @@ -526,6 +536,42 @@ tomorrow = "DEMAIN" est = "est." ok = "OK" +# --- Première configuration --- +[setup] +ok = "OK" +choose = "Choisir" +buttons_title = "BOUTONS" +press_each = "Essayez chacun" +hold_back = "Maintenir BACK" +menu_anywhere = "Le menu, partout" +continue = "Continuer" +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" +offline = "Marche hors ligne" +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é" +all_set = "TOUT EST PRÊT" +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" +add_later = "Plus tard : Réglages" +lets_ride = "C'est parti" + # --- Sortie récupérée au démarrage --- [ride_recovery] title = "RÉCUPÉRATION" 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 55b4bb948..4030f540e 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, } @@ -450,6 +455,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. @@ -509,6 +517,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, @@ -553,6 +562,7 @@ impl App { storage, pass, fw_version, + factory_name, map_name, map_obcm_version, backlight_available, @@ -577,7 +587,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"); @@ -913,6 +926,12 @@ impl App { } } + /// 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 /// screen's `Map` row reads it as `name · vN`. pub fn set_map_info(&mut self, name: &str, obcm_version: u8) { @@ -1861,10 +1880,54 @@ 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 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; + }; + if matches!(self.ui.stack[i], Screen::SetupQr(_)) { + 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 { + self.ui.stack.truncate(i); + } + self.ui.cancel_holds(); + 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 { @@ -1876,7 +1939,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.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(); self.ui.find.resume_route = self @@ -1952,11 +2016,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. @@ -2142,9 +2207,16 @@ 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; } + // 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 // frame is a page of the quick drawer and a drawer must never declare `blocks_chords`. // A sheet over it would cancel a shutdown the rider already confirmed. @@ -2347,6 +2419,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 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)); + self.ui.map_dirty = true; + } } /// Merge the BLE-owned fields (units and device name) of a phone Config write into the live @@ -2365,6 +2444,15 @@ impl App { self.settings.theme = theme; } + /// 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 screen::base_screen(&self.ui.stack) { + Some(Screen::SetupTheme(step)) => step.0, + _ => self.settings.theme, + } + } + pub fn settings(&self) -> &Settings { &self.settings } @@ -2596,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 @@ -2989,7 +3077,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, }; @@ -3076,6 +3165,7 @@ impl App { recorder, ui, fw_version, + factory_name, map_name, map_obcm_version, storage, @@ -3155,6 +3245,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(), @@ -3175,7 +3266,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 }; @@ -3247,7 +3337,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); } @@ -3260,7 +3350,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, @@ -4441,7 +4531,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 @@ -4452,7 +4542,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 @@ -4492,13 +4582,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)); @@ -4534,6 +4622,22 @@ mod tests { /// cannot loop forever. const MAX_DEPTH_BACKOUT: usize = crate::screen::MAX_DEPTH; + /// An unpaired device needs no bond receipt: reset saves at once and opens setup. + #[test] + 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()); + 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, "there is no phone to forget"); + 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`. @@ -6073,6 +6177,363 @@ mod tests { assert_eq!(drain_persist(&mut app), None, "a seeded boot value is already persisted"); } + /// 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 commits it and opens the + /// button lesson. Once each button is pressed there, Select opens the units step. + #[test] + 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); + 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(_), 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); + let before = *app.settings(); + app.apply_gesture(Gesture::Step(1)); + 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(_), 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 opens the + /// pairing step. + #[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(_), Screen::SetupQr(_)])); + let s = app.settings(); + 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 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() { + 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::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::Press); + assert!( + matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupEffort(_)]), + "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"); + } + + /// 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 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_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(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::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(_)])); + 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, 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}; + 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); + 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"); + } + + /// 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 + /// 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() { + 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::Assistant)); + 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::SetupSensors(_)])); + assert_eq!((app.settings().setup, app.settings().max_hr), (SetupStep::Sensors, 181)); + + 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(_), Screen::SetupAllSet(_)]), "the last row"); + let s = app.settings(); + 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"); + } + + /// 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] + 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] + 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..1ce38c53c 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 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, } /// 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/device_core/pass.rs b/firmware/obc-app/src/device_core/pass.rs index 3662e3bed..cf3368ba7 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); @@ -342,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; } } @@ -776,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 5022c881d..70bea4f28 100644 --- a/firmware/obc-app/src/harness/copy_fit.rs +++ b/firmware/obc-app/src/harness/copy_fit.rs @@ -116,6 +116,19 @@ 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::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::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)), Screen::Settings(SettingsPage::hub()), @@ -187,6 +200,12 @@ 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 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])); @@ -261,6 +280,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)); } @@ -284,8 +305,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 { @@ -325,10 +346,67 @@ fn every_string_fits_the_panel_in_every_language() { offenders.extend(complaint(name, language, &drawn)); } } + // 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 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. + 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); } +/// 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/render_key.rs b/firmware/obc-app/src/render_key.rs index 544cc4f16..af693a1d8 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); @@ -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/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..74032e7e2 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; @@ -58,6 +59,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; @@ -89,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; @@ -116,6 +119,10 @@ pub use settings::{ AboutScreen, AddFieldScreen, LanguageScreen, ResetScreen, SensorScanScreen, SensorsScreen, SettingsPage, StatFieldsScreen, }; +pub use setup::{ + HelloScreen, SetupAllSetScreen, SetupButtonsScreen, SetupEffortScreen, SetupLanguageScreen, SetupNoAppScreen, + SetupPairedScreen, SetupQrScreen, SetupSensorScanScreen, SetupSensorsScreen, SetupThemeScreen, SetupUnitsScreen, +}; pub use start_away::StartAwayScreen; pub use statistics::StatisticsScreen; pub use trip_delete::TripDeleteScreen; @@ -463,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. @@ -903,6 +912,38 @@ 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 + /// 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().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().blocks_escape(), + /// Setup's units step: Metric or Imperial over a preview of the ride tiles. + 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().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().blocks_escape(), + /// Ride without the app: a row back to the code and Skip, which ends the pairing step. + 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().blocks_escape(), + /// Setup's sensors step: the three sensor slots with their live status, then Skip or Continue. + 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().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().blocks_escape(), + /// Setup's last step: what the rider set up. Select ends setup and opens Home. + 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(), @@ -962,6 +1003,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(), @@ -992,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(), @@ -1131,7 +1176,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/pair_code.rs b/firmware/obc-app/src/screen/pair_code.rs new file mode 100644 index 000000000..50ac9264e --- /dev/null +++ b/firmware/obc-app/src/screen/pair_code.rs @@ -0,0 +1,150 @@ +//! 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::{ + rect, + text::{Font, TextAlign}, + Surface, +}; + +use crate::input::Gesture; +use crate::{BleLink, Msg}; + +use super::vocab::chrome::{copy_w, title_frame, TITLE_BAR_H}; +use super::vocab::marquee::fit; +use super::{palette, Ctx, Render, Transition}; + +/// 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. +const CODE_PX: i32 = (SIZE + 2 * QUIET) * MODULE; + +fn dark(x: i32, y: i32) -> bool { + MODULES[y as usize] >> (SIZE - 1 - x) & 1 == 1 +} + +/// 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::*; + cv.round(rect(x, y, CODE_PX, CODE_PX), 4, ART_WHITE); + let (x0, y0) = (x + QUIET * MODULE, y + QUIET * MODULE); + // One fill for each run of dark modules in a row. + for my in 0..SIZE { + let mut mx = 0; + while mx < SIZE { + let start = mx; + while mx < SIZE && dark(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, the name the OBC advertises under it (BLE spec §9.3), and the +/// 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, 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 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); +} + +/// 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::*; + use qrcodegen_no_heap::{QrCode, QrCodeEcc, Version}; + + const LINK: &str = "https://openbikecomputer.com/app"; + + /// The bitmap is the symbol an encoder makes from the link as §9.2 specifies, module for module. + #[test] + 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-app/src/screen/quick_drawer.rs b/firmware/obc-app/src/screen/quick_drawer.rs index 43325ca89..61eb80647 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..] } } @@ -128,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 { @@ -168,7 +175,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 +300,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 +467,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/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, diff --git a/firmware/obc-app/src/screen/settings/language.rs b/firmware/obc-app/src/screen/settings/language.rs index b7bf2b14c..594937b15 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; @@ -26,6 +22,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 +34,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, @@ -42,31 +43,19 @@ 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); - 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/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/page.rs b/firmware/obc-app/src/screen/settings/page.rs index 203da4df1..27e3b847d 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)] @@ -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,11 +185,18 @@ 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. - fn shown(self, state: &AppState) -> bool { + /// 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.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, } } @@ -244,24 +254,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; } } @@ -270,17 +280,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 { @@ -313,9 +323,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); @@ -371,6 +381,7 @@ impl Door { } Door::About => Screen::About(super::AboutScreen::new()), Door::Reset => Screen::Reset(super::ResetScreen::new()), + Door::PairPhone => Screen::PairPhone(crate::screen::PairPhoneScreen), } } @@ -491,6 +502,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(); @@ -544,29 +578,42 @@ 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.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(2)); - assert_eq!(conn.selected, 3, "paired: the Forget row is on the page, past the info row"); - assert!(conn.selection_is_guarded(&st)); + 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(&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"); diff --git a/firmware/obc-app/src/screen/settings/reset.rs b/firmware/obc-app/src/screen/settings/reset.rs index 3ed5cdf4b..94b8e210a 100644 --- a/firmware/obc-app/src/screen/settings/reset.rs +++ b/firmware/obc-app/src/screen/settings/reset.rs @@ -1,6 +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, but 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::{ @@ -9,35 +11,45 @@ use obc_render::{ Surface, }; +use crate::ble::BondStatus; use crate::input::Gesture; -use crate::screen::vocab::chrome::{card_check, card_triangle, copy_w, title_frame, wrapped, TITLE_BAR_H}; -use crate::screen::{palette, Ctx, Render, Transition}; +use crate::screen::vocab::chrome::{card_triangle, copy_w, title_frame, wrapped, TITLE_BAR_H}; +use crate::screen::{palette, Ctx, QuickDrawerScreen, Render, Screen, 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, + removing: bool, } impl ResetScreen { pub fn new() -> Self { - ResetScreen { armed: false, done: 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.done + 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.done { - // Any key clears back to Home. The device would reboot here. - return match g { - Gesture::Press | Gesture::Back => Transition::Home, + 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, }; } @@ -46,12 +58,27 @@ impl ResetScreen { self.armed = true; Transition::None } - // `apply_gesture` sees the change and flags the host to persist the cleared settings. - Gesture::Hold if self.armed => { - *cx.settings = Settings::default(); - self.done = true; + 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, _ => Transition::None, } @@ -62,14 +89,23 @@ 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); + 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; } - - 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); if !self.armed { @@ -107,29 +143,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_defaults() { + 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); let mut scr = ResetScreen::new(); - run(&mut scr, &mut s, Gesture::Hold); - assert!(!scr.done, "an un-armed hold does nothing"); - assert_eq!(s, before, "and changes no settings"); + 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(&mut scr, &mut s, Gesture::Press); - 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!(scr.done); - assert!(matches!(run(&mut scr, &mut s, Gesture::Press), Transition::Home)); + run_in(&mut scr, &mut s, &mut st, Gesture::Press); + assert!(scr.armed); + 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, "there is no phone to forget"); } #[test] diff --git a/firmware/obc-app/src/screen/settings/sensors.rs b/firmware/obc-app/src/screen/settings/sensors.rs index fa14b2ece..e42166c7d 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; @@ -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}; @@ -19,7 +20,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 +28,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, @@ -48,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; @@ -85,7 +91,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; @@ -110,7 +116,7 @@ fn status_line(buf: &mut heapless::String<24>, present: bool, status: SensorStat #[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, } @@ -132,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 } @@ -145,16 +154,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, } } @@ -162,16 +167,24 @@ 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, h - 6); + } + /// 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 { - 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; } 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() { @@ -182,6 +195,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); } } @@ -216,11 +230,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) } @@ -230,14 +244,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") } @@ -265,46 +272,44 @@ 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"); + } + + /// 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); 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; @@ -338,4 +343,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 new file mode 100644 index 000000000..ea6cd106a --- /dev/null +++ b/firmware/obc-app/src/screen/setup.rs @@ -0,0 +1,896 @@ +//! 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`]. 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 +//! foot. The last step, All set, sums up the setup, and its Select opens Home. + +use core::fmt::Write; + +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, SENSOR_SLOTS}; +use crate::{AppState, 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::{ + 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::{ + 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. +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::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::new(s))), + SetupStep::Effort => Some(Screen::SetupEffort(SetupEffortScreen::default())), + SetupStep::AllSet => Some(Screen::SetupAllSet(SetupAllSetScreen)), + SetupStep::Done => None, + } +} + +/// 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) +} + +/// 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, state: &AppState, walk: fn(SetupStep) -> SetupStep) -> SetupStep { + let mut to = walk(step); + while !shows(to, state) { + to = walk(to); + } + to +} + +/// The first step after `step` that shows. +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); + 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, SetupStep::prev); + go_to(cx.settings) +} + +/// 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); + } +} + +/// 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); + +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) { + 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(Key::Ok(t(Msg::SetupOk, lang)))); + } +} + +/// The lesson's buttons, in the order of [`SetupButtonsScreen::pressed`]: the left flank top to +/// 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 +/// 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, 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(Key::Ok(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 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_bar(cv, w, h, SetupStep::Units, 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, true, rx.t(Msg::SetupChoose), Some(Key::Ok(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_bar(cv, w, h, SetupStep::Theme, 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, true, rx.t(Msg::SetupChoose), Some(Key::Ok(rx.t(Msg::SetupOk)))); + } +} + +/// 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 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; + +fn sensors_saved(s: &Settings) -> usize { + s.saved_sensors.iter().filter(|s| s.present).count() +} + +fn any_sensor_saved(s: &Settings) -> bool { + sensors_saved(s) > 0 +} + +fn any_limit_set(s: &Settings) -> bool { + s.max_hr != 0 || s.ftp_w != 0 +} + +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), + Gesture::Press if self.selected == SKIP => finish(SetupStep::Sensors, cx), + Gesture::Press => { + Transition::Push(Screen::SetupSensorScan(SetupSensorScanScreen::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 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(Key::Ok(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); + +/// 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)) + } + + 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(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)))); + } +} + +/// 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)))); + } +} + +/// 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; + +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)); + 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)))); + } +} + +/// 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::*; + 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 +/// 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 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, 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, +} + +/// 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) => on_step(&mut self.selected, n, LIMITS.len() + 1), + 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; + } + 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() { + 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(Key::Ok(rx.t(Msg::SetupOk)))); + } +} + +/// 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. 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 + 96, 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); + } + 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)))); + } +} + +/// 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 })), + ] +} + +/// 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) { + 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); + +/// 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 (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 = 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 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 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 +/// 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 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 `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; + 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 = if arrows { 4 * k + 3 + 8 } else { 0 }; + 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 key.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); + + 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)] +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 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 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 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"); + } + + /// 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 = [ + SetupStep::Language, + SetupStep::Buttons, + SetupStep::Units, + SetupStep::Theme, + SetupStep::Qr, + SetupStep::Sensors, + 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/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 83b1a9e1d..972fc66dc 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,62 @@ 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, + Language = 2, + Buttons = 3, + Units = 4, + Theme = 5, + Qr = 6, + Sensors = 7, + Effort = 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; 9] = [ + SetupStep::Hello, + SetupStep::Language, + SetupStep::Buttons, + SetupStep::Units, + SetupStep::Theme, + SetupStep::Qr, + SetupStep::Sensors, + SetupStep::Effort, + SetupStep::AllSet, + ]; + + fn index(self) -> Option { + Self::ORDER.iter().position(|&s| s == self) + } + + /// The step after this one. Setup ends at `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. + fn from_byte(b: u8) -> Self { + Self::ORDER.into_iter().find(|&s| s as u8 == b).unwrap_or(SetupStep::Done) + } +} + +crate::settings_table::setting_enum_codec!(SetupStep); + setting_enum! { /// The colour theme used by every device screen. pub enum Theme { @@ -482,6 +538,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 +558,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 +568,18 @@ 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 }; + + /// 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 { @@ -535,9 +601,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 +651,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 +680,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 +728,7 @@ mod tests { ftp_w: 250, sound: SoundLevel::Quiet, key_tones: true, + setup: SetupStep::Hello, brightness: 1, } @@ -683,6 +752,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 +767,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 +778,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-app/src/ui_runtime.rs b/firmware/obc-app/src/ui_runtime.rs index 2cd282d19..f27ef14d7 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 @@ -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; } @@ -511,12 +512,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/obc-fw-nrf54l/README.md b/firmware/obc-fw-nrf54l/README.md index 171d3a45a..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 ▸ Bluetooth 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/firmware/obc-fw-nrf54l/src/ble/mod.rs b/firmware/obc-fw-nrf54l/src/ble/mod.rs index a73101e32..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]; @@ -419,8 +421,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/obc-fw-nrf54l/src/link_control.rs b/firmware/obc-fw-nrf54l/src/link_control.rs index 546eab707..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 @@ -144,7 +152,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..5d06b374c 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_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. @@ -766,11 +767,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 @@ -2185,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 85008ce6f..099580999 100644 --- a/firmware/obc-fw-nrf54l/src/settings.rs +++ b/firmware/obc-fw-nrf54l/src/settings.rs @@ -394,24 +394,26 @@ 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 } } } 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/tools/resource_baseline.json b/firmware/tools/resource_baseline.json index e8238a5d7..1e1f8ed6a 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, @@ -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.", + "_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": "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", "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 3c6caecc4..2f76221e3 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}" @@ -1767,6 +1787,306 @@ 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"] + +# 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 page speaks German, and the tick stays on the committed language +# until Select. +[[frame]] +name = "setup-language-cursor" +map = "{grimsel}" +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"] + +# Setup's units step, after the button lesson: Metric and Imperial over the ride tiles. +[[frame]] +name = "setup-units" +map = "{grimsel}" +script = "p p u d b p p" +expect = "SetupUnits" +args = ["--fresh"] + +[[frame]] +name = "setup-units" +map = "{grimsel}" +script = "p p u d b 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 u d b 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 u d b p p p" +expect = "SetupTheme" +args = ["--fresh"] + +[[frame]] +name = "setup-theme" +map = "{grimsel}" +script = "p p u d b 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 u d b p p p d" +expect = "SetupTheme" +args = ["--fresh"] + +# 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}" +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"] + +# 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"] + +[[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]] +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"] + +# 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" +map = "{grimsel}" +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 b d p" +expect = "SetupSensors" +args = ["--fresh"] +langs = ["de", "fr", "es"] + +# Two sensors saved: the slots show their live status, and the cursor opens on Continue. +[[frame]] +name = "setup-sensors-added" +map = "{grimsel}" +script = "p p u d b p p p p b d p" +expect = "SetupSensors" +args = ["--fresh", "--sensors", "screen"] + +# 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}" +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 b d p d p" +expect = "SetupSensorScan" +args = ["--fresh"] +langs = ["de", "fr", "es"] + +# Setup's effort step after the sensors 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 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 b d p u 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 b d p u 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 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). @@ -2160,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 80a657296..e47f11524 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 +bbabff3aba47c4588f27189258be8a74f9d216ff9dce0ea2f6fadaff800a45db pair-phone.png f219b744a652adb1a877e58f4e004ed9893454571778d708a8ef2d347ed3c800 passkey-card.png 99b89756ca6d977555dd691b30f6ee55b546a9a217d16742841afed18a179084 peak-article-back.png 99b89756ca6d977555dd691b30f6ee55b546a9a217d16742841afed18a179084 peak-article-indicator.png @@ -229,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 @@ -299,13 +306,76 @@ 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 +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 +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 +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 +567ddcce9db29e7a93e768b2d040decb2c5ede8d4d5e93ae5db72c1a95799465 setup-no-app.png +69d39d7b3a1173f586d3beb82c7bfbeddf7e46f9e187a256421bf76cc19c3c55 setup-paired-de.png +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 +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 +1de423a245c6c7d1c89ecfdf17b2df2223f198e7db2580e42805a7b9ef37ed90 setup-sensors-fr.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 +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 diff --git a/specs/obc-ble-interface-spec.md b/specs/obc-ble-interface-spec.md index 302a50577..90780c092 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 @@ -414,9 +414,9 @@ 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 - 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 + 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. 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,9 +424,99 @@ 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. 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 + +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/app +``` + +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 | 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 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 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. **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. + +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/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 + +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": [{ "/": "/app" }] + } + ] + } +} +``` + +`` is the Apple Developer team id that signs the release app. The component matches only +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.