Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
07f53d6
docs: name the Forget phone path Settings ▸ Connections
claude Sep 25, 2026
2ef2b46
onboarding: boot a new device into setup with the Hello screen
claude Sep 25, 2026
e5ae72f
docs: first-use setup refuses the global escape
claude Sep 25, 2026
462db4b
onboarding: add the language step after Hello
claude Sep 25, 2026
a4e471c
onboarding: add the units and theme steps after Language
claude Sep 25, 2026
4b367fe
onboarding: add the button lesson and count the setup steps
claude Sep 25, 2026
1883b5f
Merge branch 'f4-units-theme'
claude Sep 25, 2026
a9df1bf
onboarding: name the lesson's buttons in English in every language
claude Sep 25, 2026
8391d87
spec: define the QR pairing link (BLE spec §9)
claude Sep 25, 2026
19930f7
onboarding: add the sensors step after Theme
claude Sep 25, 2026
f8e5d78
onboarding: add the max heart rate and FTP step after Theme
claude Sep 25, 2026
e7dca66
Merge branch 's1-qr-link': QR pairing link spec (BLE spec §9)
claude Sep 25, 2026
19320b3
Merge branch 'f7-sensors': the sensors setup step
claude Sep 25, 2026
1557539
Merge branch 'f8-effort': the effort setup step after Sensors
claude Sep 25, 2026
f54582d
setup: scan list in the setup chrome, sensors cursor on Continue
claude Sep 25, 2026
02afb39
onboarding: add the pairing code step and the skip-app page
claude Sep 25, 2026
feafd58
docs: a factory reset also forgets the paired phone
claude Sep 25, 2026
8ae047e
Merge branch 'f5-qr': the pairing code step before Sensors
claude Sep 25, 2026
2719683
resources: re-pin the BLE settings cache for the setup step
claude Sep 25, 2026
eb56e77
onboarding: the pairing code is one fixed app link
claude Sep 27, 2026
5b6c640
onboarding: the pairing code says to unplug the cable
claude Sep 27, 2026
cc0c1e9
onboarding: the skip-app page is titled WITHOUT APP
claude Sep 27, 2026
fc00848
sensors: the scan list scrolls
claude Sep 27, 2026
28c1aa4
resources: re-pin the pairing-code note on the branch head
claude Sep 27, 2026
4f89985
onboarding: a bond on the pairing step opens a Paired page
claude Sep 27, 2026
96a66d0
onboarding: end setup on an All set page, then Home
claude Sep 27, 2026
fa0c962
Merge branch 'f9-allset': setup ends on an All set page
claude Sep 27, 2026
1714746
setup: trim the Paired page, All set ends on "Let's ride"
claude Sep 27, 2026
1c34f69
test: wait for the landing anchor scroll to land
claude Sep 27, 2026
b3043a6
setup: route and trip cards wait for setup, a scan runs while its lis…
claude Sep 27, 2026
12fbfb0
setup: a factory reset saves at once and opens setup
claude Sep 27, 2026
2eb0996
settings: hide the Reset door while a ride records
claude Sep 27, 2026
1ce5e55
setup: the quick drawer opens over setup, without its Settings row
claude Sep 27, 2026
44b08a4
setup: skip the pairing step on a platform that does not bond
claude Sep 27, 2026
7ec8652
sensors: Select picks the drawn row after the scan list shrinks
claude Sep 27, 2026
533f8e9
setup: short capital titles for the scan lists
claude Sep 27, 2026
f4d919b
setup: open setup once a recovered ride is answered
claude Sep 27, 2026
99724c6
docs: a factory reset also clears the bond
claude Sep 27, 2026
15c4f94
board: the settings load logs that it boots factory settings
claude Sep 27, 2026
ba2f5b0
setup: step bytes follow the step order, one net resource note
claude Sep 27, 2026
44903a6
fix: complete phone removal before factory setup
timohueser Sep 28, 2026
091329e
test: use isolated landing anchor clearance check
timohueser Sep 28, 2026
b471dff
docs: show factory reset in phone pairing diagram
timohueser Sep 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions apps/obc-ios-host/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<dyn ElevationSource> = match obc_host_core::terrain::FlatElevation::open(&map) {
Ok(Some(terrain)) => terrain,
Expand All @@ -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 {
Expand Down
29 changes: 27 additions & 2 deletions apps/obc-ios-host/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
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.
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 4 additions & 3 deletions apps/obc-sim/src/gui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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);
Expand Down
72 changes: 58 additions & 14 deletions apps/obc-sim/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>,
}
Expand Down Expand Up @@ -72,6 +75,7 @@ enum Injection {
TripUpload { id: obc_app::CatalogObjectId },
MapTransfer(obc_app::screen::MapTransfer),
Warning(obc_app::Alerts),
BondClear(Result<obc_app::ble::ControllerClearance, obc_app::ble::BondError>),
}

#[derive(Clone)]
Expand Down Expand Up @@ -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<String>,
/// A progress record for the first imported trip: `(day, metres, last finished day)`.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -399,7 +406,9 @@ fn parse_ble(s: &str) -> Result<BleSeed, String> {
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(
Expand All @@ -409,7 +418,11 @@ fn parse_ble(s: &str) -> Result<BleSeed, String> {
.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)
Expand All @@ -419,7 +432,7 @@ fn parse_ble(s: &str) -> Result<BleSeed, String> {
/// 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 =
Expand Down Expand Up @@ -475,6 +488,12 @@ fn parse_injection(s: &str) -> Result<Injection, String> {
}
"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()),
}
}
Expand Down Expand Up @@ -547,6 +566,7 @@ fn parse_args_from(args: impl IntoIterator<Item = String>) -> Result<Args, Strin
"--script-after" => 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")?),
Expand Down Expand Up @@ -707,6 +727,7 @@ struct Stores<'a> {
/// settle instead of parking.
#[derive(Default)]
struct HeadlessPlatform {
bond_clear: Option<Result<obc_app::ble::ControllerClearance, obc_app::ble::BondError>>,
/// The `--dfu` scan answer, taken by the first scan the flow asks for.
scan: Option<Result<obc_app::dfu::DfuScanReport, obc_app::dfu::DfuScanError>>,
/// The `--dfu` install answer. `None` leaves the arm in flight, which is the progress
Expand All @@ -715,6 +736,10 @@ struct HeadlessPlatform {
}

impl HostPlatform for HeadlessPlatform {
fn forget_bond(&mut self) -> Result<obc_app::ble::ControllerClearance, obc_app::ble::BondError> {
self.bond_clear.unwrap_or(Err(obc_app::ble::BondError::Unsupported))
}

fn measure_free_space(&mut self) -> Result<u64, obc_app::device_core::StorageInfoError> {
Ok(SIM_CARD_FREE)
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1021,22 +1052,23 @@ 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)
--utc-offset-min N Local offset for explicit clocks, minutes (-720..840; default 0)
--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:
--script-after TOKENS Apply button input after GPX replay, before rendering
--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)
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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()),
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -1986,6 +2029,7 @@ mod cli_tests {
"--script-after",
"--expect-screen",
"--boot",
"--fresh",
"--routes-dir",
"--tracks-dir",
"--import",
Expand Down
2 changes: 2 additions & 0 deletions apps/obc-web-demo/tests/browser/reading-pages.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading