From 2779ca25e7053948547ca240014bc55850da902d Mon Sep 17 00:00:00 2001 From: PurpleSentinel <199351535+PurpleSentinel@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:03:04 +0100 Subject: [PATCH] feat(ui): add a Navigation mode Road speed low and large, the receiver's clock top left, and an arrow top right pointing where the car is going with north at twelve o'clock. Three readouts the device already computed in three separate modes, on one screen. Closes #228. The unit sits beside the speed rather than under it, which is the one place this screen departs from Speed Only and the reason its face is 208 px against Speed Only's 248. Three digits plus "MPH" and its gap have to fit 540 px, which leaves 454 px for the digits and so caps the face at 208. Speed Only keeps 248 and its label underneath - it answers a narrower question with the whole panel to answer it in, and is deliberately not changed to match. The arrow is absent whenever the heading is not trustworthy, with the reason left in the ring where it would have been. The board has no magnetometer, so the direction is course over ground and a parked car has no heading at all - not a stale one, not north. The gates are Compass mode's, reused rather than restated. The view model composes speed_only_view, clock_view and compass_view rather than formatting anything itself, so the absent-state rules stay in the one place that is already tested for them. One thing none of the three source modes needed: with no receiver all three readouts are absent for the same reason, and printing it beside the speed, under the clock and inside the ring reads as three faults. That case is collapsed to one line. Every other combination keeps its three statuses, because a parked car with a fix and a time and no course really is three different things to say. The mode is appended to OperatingMode, so this is one byte that already existed changing value: no settings format bump, no migration, and a device updating to this firmware keeps its settings. The upper bound in valid_settings moves with it, which test_settings now asserts. Two things found by building it: The digits first rendered mid-panel rather than low. A label's box is a line box and carries the face's descent, which figures never use, so hanging the box off the bottom margin left a quarter of the face's height as empty panel under the number. The baseline is placed now and the box hung from it. That descent is taken from this file's arithmetic rather than from lv_font_get_line_height, because the simulator's countdown_font is a stub that answers every size with one 52 px face. Measuring would have laid the screen out differently in the simulator than on the panel. The unit does measure, and can, because it is a built-in face that is the same font in both places. Verified: make check, make firmware-container-build, and all 78 simulator tests. The rendered snapshot confirms the composition and the absent states; it cannot confirm the numeral's size, because the host face is that stub - which is why the measured "MPH" width (70 px against a 72 px budget) and the resulting three-digit fit were checked against the real metrics separately. Not yet flashed. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 21 + Makefile | 16 +- firmware/components/settings/component.cpp | 2 +- .../include/track_timer/settings/settings.hpp | 4 + firmware/components/ui/CMakeLists.txt | 2 +- firmware/components/ui/device_mode.cpp | 17 + .../ui/include/track_timer/ui/device_mode.hpp | 7 +- .../track_timer/ui/navigation_mode.hpp | 57 +++ .../track_timer/ui/navigation_screen.hpp | 85 ++++ firmware/components/ui/navigation_mode.cpp | 32 ++ firmware/components/ui/navigation_screen.cpp | 430 ++++++++++++++++++ firmware/main/screen_router.cpp | 31 ++ simulator/CMakeLists.txt | 45 ++ simulator/src/application_screen.cpp | 21 +- simulator/src/application_screen.hpp | 4 + simulator/src/main.cpp | 21 +- tests/cpp/test_device_mode.cpp | 21 + tests/cpp/test_navigation_mode.cpp | 187 ++++++++ tests/cpp/test_settings.cpp | 32 +- 19 files changed, 1026 insertions(+), 9 deletions(-) create mode 100644 firmware/components/ui/include/track_timer/ui/navigation_mode.hpp create mode 100644 firmware/components/ui/include/track_timer/ui/navigation_screen.hpp create mode 100644 firmware/components/ui/navigation_mode.cpp create mode 100644 firmware/components/ui/navigation_screen.cpp create mode 100644 tests/cpp/test_navigation_mode.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index f06d6fb..d5b20c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,27 @@ The project follows Semantic Versioning once the first firmware release is tagge ### Added +- a **Navigation** mode: road speed low and large, the receiver's clock top left, and an + arrow top right pointing where the car is going with north at twelve o'clock. Three + readouts the device already computed in three separate modes, on one screen +- the **unit sits beside the speed rather than under it**, which is the one place this + screen departs from Speed Only and the reason its face is 208 px against Speed Only's + 248. Three digits plus `MPH` and its gap have to fit 540 px, which leaves 454 px for the + digits and so caps the face at 208. Speed Only keeps 248 and its label underneath - it + has the whole panel to answer a narrower question in, and is not changed to match +- the arrow is **absent whenever the heading is not trustworthy**, with the reason in the + ring where it would have been. The board has no magnetometer, so the direction is course + over ground and a parked car has no heading at all - not a stale one, not north. The + gates are Compass mode's, reused rather than restated: the receiver's own `headAcc` + under 15 degrees, with a half-metre-per-second backstop under it +- **one cause stated once**: with no receiver all three readouts are absent for the same + reason, and printing it beside the speed, under the clock and inside the ring would read + as three faults. Every other combination keeps its three separate statuses, because a + parked car with a fix and a time and no course really is three different things to say +- the mode is appended to `OperatingMode`, so this is **one byte that already existed + changing value**: no settings format bump, no migration, and a device updating to this + firmware keeps its settings + - **Race mode has its own running-session screen**: the lap in progress in `mm:ss.sss`, the fastest lap of the session, and the time left in it. Race previously borrowed Track Day's countdown, which - as that screen's own header says - has nowhere to put a lap diff --git a/Makefile b/Makefile index d341b24..3ea3a38 100644 --- a/Makefile +++ b/Makefile @@ -48,6 +48,7 @@ GPS_ONLY_TEST_BINARY := build/host/gps_only_test CLOCK_TEST_BINARY := build/host/clock_test SPEED_ONLY_TEST_BINARY := build/host/speed_only_test COMPASS_TEST_BINARY := build/host/compass_test +NAVIGATION_MODE_TEST_BINARY := build/host/navigation_mode_test ACCELERATION_TEST_BINARY := build/host/acceleration_test IMU_CALIBRATION_TEST_BINARY := build/host/imu_calibration_test SESSION_URGENCY_TEST_BINARY := build/host/session_urgency_test @@ -67,9 +68,9 @@ SIMULATOR_BUILD_DIR ?= build/simulator SIMULATOR_IMAGE ?= track-session-timer-simulator:lvgl-9.5.0 CMAKE ?= cmake -.PHONY: check venv python-env test track-validate uk-track-pack test-track-pack track-pack-test simulator-track-catalog-test simulator-fixture-validate repo-check host-test simulator-model-test session-state-test settings-test settings-editor-test projection-test intersection-test crossing-validation-test crossing-time-test lap-state-machine-test timing-engine-test gate-event-engine-test track-definition-test track-capture-test track-matching-test track-selection-test log-format-test async-logger-test session-review-test diagnostics-test active-session-test display-policy-test imu-meter-test rest-session-test track-catalog-test shell-navigation-test device-mode-test gps-only-test clock-test speed-only-test compass-test acceleration-test imu-calibration-test session-urgency-test time-roller-test session-trigger-test summary-store-test summary-file-test review-cards-test ubx-parser-test fix-validation-test gnss-pipeline-test gnss-configure-test i2c-transport-test gate-capture-test gate-session-automation-test ui-foundation-test navigation-test simulator-configure simulator-build simulator-test simulator-run simulator-container-image simulator-container-test simulator-clean firmware-build firmware-container-build firmware-container-flash firmware-container-monitor firmware-container-flash-monitor firmware-container-erase firmware-capture-boot firmware-device-info firmware-clean issue-preview label-preview +.PHONY: check venv python-env test track-validate uk-track-pack test-track-pack track-pack-test simulator-track-catalog-test simulator-fixture-validate repo-check host-test simulator-model-test session-state-test settings-test settings-editor-test projection-test intersection-test crossing-validation-test crossing-time-test lap-state-machine-test timing-engine-test gate-event-engine-test track-definition-test track-capture-test track-matching-test track-selection-test log-format-test async-logger-test session-review-test diagnostics-test active-session-test display-policy-test imu-meter-test rest-session-test track-catalog-test shell-navigation-test device-mode-test gps-only-test clock-test speed-only-test compass-test navigation-mode-test acceleration-test imu-calibration-test session-urgency-test time-roller-test session-trigger-test summary-store-test summary-file-test review-cards-test ubx-parser-test fix-validation-test gnss-pipeline-test gnss-configure-test i2c-transport-test gate-capture-test gate-session-automation-test ui-foundation-test navigation-test simulator-configure simulator-build simulator-test simulator-run simulator-container-image simulator-container-test simulator-clean firmware-build firmware-container-build firmware-container-flash firmware-container-monitor firmware-container-flash-monitor firmware-container-erase firmware-capture-boot firmware-device-info firmware-clean issue-preview label-preview -check: python-env test track-validate test-track-pack track-pack-test simulator-track-catalog-test simulator-fixture-validate repo-check host-test simulator-model-test session-state-test settings-test settings-editor-test projection-test intersection-test crossing-validation-test crossing-time-test lap-state-machine-test timing-engine-test gate-event-engine-test track-definition-test track-capture-test track-matching-test track-selection-test log-format-test async-logger-test session-review-test diagnostics-test active-session-test display-policy-test imu-meter-test rest-session-test track-catalog-test shell-navigation-test device-mode-test gps-only-test clock-test speed-only-test compass-test acceleration-test imu-calibration-test session-urgency-test time-roller-test session-trigger-test summary-store-test summary-file-test review-cards-test ubx-parser-test fix-validation-test gnss-pipeline-test gnss-configure-test i2c-transport-test gate-capture-test gate-session-automation-test ui-foundation-test navigation-test +check: python-env test track-validate test-track-pack track-pack-test simulator-track-catalog-test simulator-fixture-validate repo-check host-test simulator-model-test session-state-test settings-test settings-editor-test projection-test intersection-test crossing-validation-test crossing-time-test lap-state-machine-test timing-engine-test gate-event-engine-test track-definition-test track-capture-test track-matching-test track-selection-test log-format-test async-logger-test session-review-test diagnostics-test active-session-test display-policy-test imu-meter-test rest-session-test track-catalog-test shell-navigation-test device-mode-test gps-only-test clock-test speed-only-test compass-test navigation-mode-test acceleration-test imu-calibration-test session-urgency-test time-roller-test session-trigger-test summary-store-test summary-file-test review-cards-test ubx-parser-test fix-validation-test gnss-pipeline-test gnss-configure-test i2c-transport-test gate-capture-test gate-session-automation-test ui-foundation-test navigation-test venv: python3 -m venv .venv @@ -605,6 +606,17 @@ compass-test: tests/cpp/test_compass.cpp -o $(COMPASS_TEST_BINARY) $(COMPASS_TEST_BINARY) +navigation-mode-test: + mkdir -p build/host + $(CXX) -std=c++17 -Wall -Wextra -Werror -pedantic \ + -Ifirmware/components/domain/include \ + -Ifirmware/components/ui/include \ + firmware/components/ui/gps_only.cpp firmware/components/ui/speed_only.cpp \ + firmware/components/ui/compass.cpp firmware/components/ui/clock.cpp \ + firmware/components/ui/navigation_mode.cpp \ + tests/cpp/test_navigation_mode.cpp -o $(NAVIGATION_MODE_TEST_BINARY) + $(NAVIGATION_MODE_TEST_BINARY) + speed-only-test: mkdir -p build/host $(CXX) -std=c++17 -Wall -Wextra -Werror -pedantic \ diff --git a/firmware/components/settings/component.cpp b/firmware/components/settings/component.cpp index c72c3c9..0aa0882 100644 --- a/firmware/components/settings/component.cpp +++ b/firmware/components/settings/component.cpp @@ -179,7 +179,7 @@ bool valid_settings(const DeviceSettings& settings) noexcept settings.average_lap_seconds <= 59 * 60 + 59 && valid_brightness(settings.day_brightness_percent) && valid_brightness(settings.night_brightness_percent) && - settings.operating_mode <= OperatingMode::acceleration && + settings.operating_mode <= OperatingMode::navigation && valid_acceleration_target(settings.acceleration_target_mph) && settings.orientation <= OrientationMode::automatic && settings.lower_display <= LowerDisplayMode::laps_remaining && diff --git a/firmware/components/settings/include/track_timer/settings/settings.hpp b/firmware/components/settings/include/track_timer/settings/settings.hpp index 798ed05..d91d5a7 100644 --- a/firmware/components/settings/include/track_timer/settings/settings.hpp +++ b/firmware/components/settings/include/track_timer/settings/settings.hpp @@ -30,6 +30,10 @@ enum class OperatingMode : std::uint8_t { compass, // Standing-start acceleration timing, to a target speed chosen in Setup. acceleration, + // Road speed, the receiver's clock and its course over ground on one screen. Appended + // for the reason every mode before it was: this enum's order *is* the stored format, + // so a new mode goes on the end whatever order it is offered to the driver in. + navigation, }; enum class OrientationMode : std::uint8_t { diff --git a/firmware/components/ui/CMakeLists.txt b/firmware/components/ui/CMakeLists.txt index 9ea6e59..c0ea0b7 100644 --- a/firmware/components/ui/CMakeLists.txt +++ b/firmware/components/ui/CMakeLists.txt @@ -1,7 +1,7 @@ idf_component_register( SRCS "component.cpp" "active_session.cpp" "input.cpp" "shell_navigation.cpp" "device_mode.cpp" "value_picker.cpp" "gesture_input.cpp" "carousel_screen.cpp" "clock.cpp" "clock_screen.cpp" "time_roller.cpp" "time_roller_screen.cpp" "trackday_screen.cpp" "timer_font.cpp" "diagnostics.cpp" "diagnostics_screen.cpp" "display_policy.cpp" "foundation.cpp" "gate_capture.cpp" "gate_capture_screen.cpp" "gps_only.cpp" "gps_only_screen.cpp" "speed_only.cpp" "speed_only_screen.cpp" "compass.cpp" "compass_screen.cpp" "acceleration.cpp" "acceleration_screen.cpp" "g_meter_screen.cpp" "g_radar_screen.cpp" "race_screen.cpp" "imu_meter.cpp" - "lvgl_visual_system.cpp" "navigation.cpp" + "lvgl_visual_system.cpp" "navigation.cpp" "navigation_mode.cpp" "navigation_screen.cpp" "presenter.cpp" "ready_screen.cpp" "rest_session.cpp" "review_cards.cpp" "session_trigger.cpp" "session_review.cpp" "session_review_screen.cpp" "settings_editor.cpp" "settings_screen.cpp" "setup_menu_screen.cpp" "track_selection.cpp" "track_selection_screen.cpp" diff --git a/firmware/components/ui/device_mode.cpp b/firmware/components/ui/device_mode.cpp index 22933a1..f6e0e43 100644 --- a/firmware/components/ui/device_mode.cpp +++ b/firmware/components/ui/device_mode.cpp @@ -22,6 +22,10 @@ ModeVisibility visibility_for(const DeviceMode mode) noexcept // A compass card and nothing else. gnss_data again marks where the number comes // from rather than what is drawn, and again this is not a session view. return {false, false, false, false, true}; + case DeviceMode::navigation: + // Speed, time and heading together. gnss_data marks where all three come from; + // like the two above it this is not a session view and must not offer a timer. + return {false, false, false, false, true}; case DeviceMode::acceleration: // A measurement, and nothing that could be mistaken for timing a session. return {false, false, false, false, true}; @@ -51,6 +55,9 @@ DeviceMode mode_from_settings(const settings::DeviceSettings& settings) noexcept if (settings.operating_mode == settings::OperatingMode::compass) { return DeviceMode::compass; } + if (settings.operating_mode == settings::OperatingMode::navigation) { + return DeviceMode::navigation; + } if (settings.operating_mode == settings::OperatingMode::acceleration) { return DeviceMode::acceleration; } @@ -86,6 +93,10 @@ void apply_mode(const DeviceMode mode, settings::DeviceSettings& settings) noexc settings.operating_mode = settings::OperatingMode::compass; settings.trackday_mode_enabled = false; break; + case DeviceMode::navigation: + settings.operating_mode = settings::OperatingMode::navigation; + settings.trackday_mode_enabled = false; + break; case DeviceMode::acceleration: settings.operating_mode = settings::OperatingMode::acceleration; settings.trackday_mode_enabled = false; @@ -114,6 +125,8 @@ const char* device_mode_name(const DeviceMode mode) noexcept return "speed-only"; case DeviceMode::compass: return "compass"; + case DeviceMode::navigation: + return "navigation"; case DeviceMode::acceleration: return "acceleration"; case DeviceMode::gps_only: @@ -137,6 +150,8 @@ const char* device_mode_label(const DeviceMode mode) noexcept return "SPEED"; case DeviceMode::compass: return "COMPASS"; + case DeviceMode::navigation: + return "NAV"; case DeviceMode::acceleration: return "0-X"; case DeviceMode::gps_only: @@ -160,6 +175,8 @@ const char* device_mode_summary(const DeviceMode mode) noexcept return "Road speed in mph, as large as the panel allows."; case DeviceMode::compass: return "Course over ground. Needs movement: a parked car has no heading."; + case DeviceMode::navigation: + return "Speed, GMT and direction of travel. The arrow needs movement."; case DeviceMode::acceleration: return "Standing start to a target speed, set in Setup."; case DeviceMode::gps_only: diff --git a/firmware/components/ui/include/track_timer/ui/device_mode.hpp b/firmware/components/ui/include/track_timer/ui/device_mode.hpp index 15ed8e4..9a25e41 100644 --- a/firmware/components/ui/include/track_timer/ui/device_mode.hpp +++ b/firmware/components/ui/include/track_timer/ui/device_mode.hpp @@ -15,6 +15,7 @@ namespace track_timer::ui { // G-Only operating_mode = g_meter // Speed Only operating_mode = speed_only // Compass operating_mode = compass +// Navigation operating_mode = navigation // 0-X operating_mode = acceleration // GPS Only operating_mode = gps_only // Clock operating_mode = clock @@ -36,11 +37,15 @@ enum class DeviceMode : std::uint8_t { g_only, speed_only, compass, + // Beside Compass, because it is the same course-over-ground reading with road speed + // and the receiver's clock around it, and like Compass it is a driving display rather + // than something reached when a thing has gone wrong. + navigation, acceleration, gps_only, clock, }; -inline constexpr std::size_t kDeviceModeCount = 8; +inline constexpr std::size_t kDeviceModeCount = 9; // What each mode is permitted to show while a session is running. struct ModeVisibility { diff --git a/firmware/components/ui/include/track_timer/ui/navigation_mode.hpp b/firmware/components/ui/include/track_timer/ui/navigation_mode.hpp new file mode 100644 index 0000000..488c343 --- /dev/null +++ b/firmware/components/ui/include/track_timer/ui/navigation_mode.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include "track_timer/ui/clock.hpp" +#include "track_timer/ui/compass.hpp" +#include "track_timer/ui/speed_only.hpp" + +#include +#include +#include +#include + +namespace track_timer::ui { + +// The view model behind Navigation mode: road speed, the receiver's clock and the +// direction of travel on one screen. +// +// Not `navigation.hpp`, which is the shell's screen-to-screen navigator and has nothing to +// do with this. The two should not be merged on the strength of sharing a word. +// +// This mode composes the three view builders that already exist rather than formatting +// anything itself. Speed Only, Clock and Compass have each already settled what their +// readout does when it is not known, and those rules are tested; a fourth formatter would +// be a fourth place for them to drift. What this file owns is the one question none of the +// three can answer alone - what to do when they are all absent for the same reason. + +inline constexpr std::size_t kNavigationStatusCapacity = 24; + +struct NavigationSnapshot { + // One receiver feeds all three readouts, so there is one GNSS snapshot. The clock gets + // its own because what it shows depends on how long ago its reading arrived, which is + // not a property of the newest fix. + GpsOnlySnapshot gnss{}; + ClockSnapshot clock{}; +}; + +struct NavigationViewModel { + SpeedOnlyViewModel speed{}; + ClockViewModel time{}; + CompassViewModel heading{}; + // All three readouts absent, for one cause that is worth stating once. + // + // Without this the screen prints "NO RECEIVER" in three places - beside the speed, + // under the clock and inside the compass ring - which reads as three faults rather + // than one. The three per-readout statuses are still the right thing to show when the + // readouts disagree, which is the common case: a receiver that has a fix and a time + // but no course is a parked car, and that is three different things to say. + std::array shared_fault{}; + std::uint32_t shared_fault_rgb{0}; + bool has_shared_fault{false}; +}; + +[[nodiscard]] NavigationViewModel navigation_view(const NavigationSnapshot& snapshot) noexcept; + +static_assert(std::is_trivially_copyable_v); +static_assert(std::is_trivially_copyable_v); + +} // namespace track_timer::ui diff --git a/firmware/components/ui/include/track_timer/ui/navigation_screen.hpp b/firmware/components/ui/include/track_timer/ui/navigation_screen.hpp new file mode 100644 index 0000000..7f695ba --- /dev/null +++ b/firmware/components/ui/include/track_timer/ui/navigation_screen.hpp @@ -0,0 +1,85 @@ +#pragma once + +#include "track_timer/ui/navigation_mode.hpp" + +#include + +#include +#include + +namespace track_timer::ui { + +// Navigation mode's panel: road speed low and large, the receiver's clock top left, and an +// arrow top right pointing where the car is going, with north at twelve o'clock. +// +// Three departures from the screens it borrows from, each deliberate. +// +// The unit sits *beside* the speed rather than under it, because the digit line has moved +// to the bottom of the panel and there is nothing below it any more. Speed Only made the +// opposite call for a good reason - "beside would mean reserving width that the +// three-digit case needs, which is exactly the compromise that costs the face its size" - +// and that reason still holds: this face is 208 px against Speed Only's 248. Speed Only is +// not changed to match. It answers a different question, with the whole panel to answer it +// in. +// +// There is no title. The top left is the clock and the top right is the arrow, and a mode +// label would have to displace one of them to say what three readouts already say. +// +// The arrow is dots, not artwork. Rotating a bitmap or walking a vector path every tick at +// 20 Hz is the per-frame work that starved the UI task in #133 and #141, so this follows +// CompassScreen: a ring built once and never touched, and five dots that move only when +// the bearing has changed by a whole degree. +class NavigationScreen { + public: + explicit NavigationScreen(lv_obj_t* root) noexcept; + + void update(const NavigationViewModel& model) noexcept; + + NavigationScreen(const NavigationScreen&) = delete; + NavigationScreen& operator=(const NavigationScreen&) = delete; + + private: + // Three digits, or the three dashes that stand in for them. + static constexpr std::size_t kSpeedDigits = 3; + // "HH:MM:SS" - six digits and the two separators, each in its own fixed cell so a + // minute rolling over cannot shuffle the hours sideways. + static constexpr std::size_t kTimeCells = 8; + // A tail, a pivot and three dots growing towards the tip. Fewer than three and it + // reads as a dot rather than as pointing somewhere; the tail is what makes the + // difference between an arrow and a comet. + static constexpr std::size_t kArrowDots = 5; + // Four cardinals and the four between them, which is as much as a 128 px ring can + // carry without the ticks closing into a line. + static constexpr std::size_t kTickCount = 8; + + lv_obj_t* root_{nullptr}; + std::array time_cells_{}; + std::array time_shown_{}; + lv_obj_t* time_status_{nullptr}; + std::array time_status_shown_{}; + std::array digits_{}; + std::array digits_shown_{}; + lv_obj_t* unit_{nullptr}; + lv_obj_t* speed_status_{nullptr}; + std::array speed_status_shown_{}; + std::array ticks_{}; + std::array arrow_{}; + lv_obj_t* heading_status_{nullptr}; + std::array heading_status_shown_{}; + + // Measured, not assumed. The face size is chosen against a compile-time budget for + // this width, but where the label actually lands is decided by what the font renders, + // so a metrics change moves the unit instead of overlapping the digits. + std::int32_t unit_width_{0}; + // Where the digit block is centred: the middle of what is left after the unit has + // taken its width from the right. The unit itself never moves, which is the point - + // only the digits recentre as the reading crosses 9 to 10 and 99 to 100. + std::int32_t digits_centre_{0}; + std::size_t shown_count_{0}; + bool speed_valid_{false}; + bool time_valid_{false}; + int arrow_bearing_{-1}; + bool arrow_visible_{false}; +}; + +} // namespace track_timer::ui diff --git a/firmware/components/ui/navigation_mode.cpp b/firmware/components/ui/navigation_mode.cpp new file mode 100644 index 0000000..e66cb67 --- /dev/null +++ b/firmware/components/ui/navigation_mode.cpp @@ -0,0 +1,32 @@ +#include "track_timer/ui/navigation_mode.hpp" + +#include + +namespace track_timer::ui { + +NavigationViewModel navigation_view(const NavigationSnapshot& snapshot) noexcept +{ + NavigationViewModel model{}; + model.speed = speed_only_view(snapshot.gnss); + model.time = clock_view(snapshot.clock); + model.heading = compass_view(snapshot.gnss); + + // Only when no bytes have ever arrived. Every other combination has the readouts + // disagreeing about what they know, and then each has to speak for itself: a parked + // car with a good fix has a speed and a time and no heading, and collapsing that into + // one line would lose the only part of it the driver cannot guess. + // + // The three valid flags are tested as well as the receiver flag, rather than trusted + // to follow from it. They come from three independent view builders, and a readout + // that somehow had a value here would be hidden by the single line. + if (!snapshot.gnss.receiver_present && !model.speed.speed_valid && + !model.time.digits_valid && !model.heading.heading_valid) { + model.has_shared_fault = true; + model.shared_fault_rgb = model.speed.status_rgb; + std::snprintf(model.shared_fault.data(), model.shared_fault.size(), "%s", + model.speed.status.data()); + } + return model; +} + +} // namespace track_timer::ui diff --git a/firmware/components/ui/navigation_screen.cpp b/firmware/components/ui/navigation_screen.cpp new file mode 100644 index 0000000..67b0f42 --- /dev/null +++ b/firmware/components/ui/navigation_screen.cpp @@ -0,0 +1,430 @@ +#include "track_timer/ui/navigation_screen.hpp" + +#include "track_timer/ui/foundation.hpp" +#include "track_timer/ui/lvgl_visual_system.hpp" +#include "track_timer/ui/timer_font.hpp" + +#include +#include +#include + +namespace track_timer::ui { +namespace { + +constexpr std::int32_t kScreenWidth = 600; +constexpr std::int32_t kScreenHeight = 450; +constexpr std::int32_t kSideMargin = 30; +constexpr std::int32_t kBottomMargin = 30; +constexpr std::int32_t kUsableWidth = kScreenWidth - 2 * kSideMargin; + +// The same metrics every other large numeral on this device takes from the face itself. +constexpr std::int32_t cell_width_for(const std::int32_t font_px) noexcept +{ + return font_px * 7235 / 10000; +} +constexpr std::int32_t cell_height_for(const std::int32_t font_px) noexcept +{ + return font_px * 1218 / 1000; +} +// A colon is far narrower than a digit, and giving it a digit's cell would open a gap +// either side of it that reads as a missing character. +constexpr std::int32_t separator_width_for(const std::int32_t font_px) noexcept +{ + return font_px * 288 / 1000; +} + +// What the inline unit is allowed to take, and what the face is sized against. +// +// "MPH" at montserrat_28 measures around 66 px; 72 is that with room for a metrics change. +// The gap is what stops the unit reading as a fourth digit. +constexpr std::int32_t kUnitWidthBudget = 72; +constexpr std::int32_t kUnitGap = 14; + +// 208 px, and the arithmetic is the whole reason it is not Speed Only's 248. +// +// Three digits plus the unit and its gap have to fit 540 px. That leaves 454 px for the +// digits, so a cell may be 151 px, so the face may be 151 / 0.7235 = 208 px. The inline +// unit costs 40 px of face and 49 px of glyph height against Speed Only, which is what +// buys the clock and the arrow a band to live in. +constexpr std::int32_t kSpeedPx = 208; +constexpr std::int32_t kDigitCellWidth = cell_width_for(kSpeedPx); +constexpr std::int32_t kDigitCellHeight = cell_height_for(kSpeedPx); +static_assert(kDigitCellWidth * 3 + kUnitGap + kUnitWidthBudget <= kUsableWidth, + "a three-digit speed and the inline unit must both fit across the panel"); + +// Printed low, which is what Navigation asks for and Speed Only does not do - and low +// means the *ink* is low, not the cell. +// +// A label's box is a line box: it carries the face's descent under the baseline, and +// figures use none of it. Hanging the box off the bottom margin therefore leaves a quarter +// of the face's height as empty panel under the number. So the baseline is placed first +// and the box is hung from it. +// +// The descent comes from the same arithmetic as the rest of the face rather than from +// lv_font_get_line_height, and that is deliberate. The simulator's countdown_font is a +// stub that answers every size with one 52 px face, so on the host those metrics describe +// a font that is not the one being laid out. Every other large numeral on this device is +// positioned from this arithmetic for that reason, and a screen that measured instead +// would lay itself out differently in the simulator than on the panel. +constexpr std::int32_t descent_for(const std::int32_t font_px) noexcept +{ + return font_px * 251 / 1000; +} +constexpr std::int32_t kSpeedBaseline = kScreenHeight - kBottomMargin; +constexpr std::int32_t kDigitsTop = + kSpeedBaseline - (kDigitCellHeight - descent_for(kSpeedPx)); +// Where the ink starts. Montserrat's figures are about 0.70 em tall; this guards a gap of +// a hundred-odd pixels, so the estimate is ample for what it is asked to do. +constexpr std::int32_t kSpeedInkTop = kSpeedBaseline - kSpeedPx * 70 / 100; + +// The clock. Large enough to read at a glance, small enough to leave the arrow its corner. +constexpr std::int32_t kTimePx = 48; +constexpr std::int32_t kTimeDigitWidth = cell_width_for(kTimePx); +constexpr std::int32_t kTimeSeparatorWidth = separator_width_for(kTimePx); +constexpr std::int32_t kTimeCellHeight = cell_height_for(kTimePx); +constexpr std::int32_t kTimeTop = 22; +constexpr std::int32_t kTimeStatusTop = kTimeTop + kTimeCellHeight + 6; +constexpr std::int32_t kSpeedStatusTop = kTimeStatusTop + 28; +constexpr std::int32_t kStatusWidth = 300; + +constexpr bool time_cell_is_separator(const std::size_t index) noexcept +{ + return index == 2 || index == 5; +} +constexpr std::int32_t time_cell_width(const std::size_t index) noexcept +{ + return time_cell_is_separator(index) ? kTimeSeparatorWidth : kTimeDigitWidth; +} +constexpr std::int32_t time_cell_left(const std::size_t index) noexcept +{ + std::int32_t left = kSideMargin; + for (std::size_t before = 0; before < index; ++before) { + left += time_cell_width(before); + } + return left; +} +constexpr std::int32_t kTimeWidth = time_cell_left(8) - kSideMargin; + +// The arrow's ring, in the top right corner and as large as the corner allows. +constexpr std::int32_t kRingRadius = 64; +constexpr std::int32_t kRingCentreX = kScreenWidth - kSideMargin - kRingRadius - 2; +constexpr std::int32_t kRingCentreY = 92; +static_assert(kRingCentreY + kRingRadius < kSpeedInkTop, + "the ring must clear the speed it sits above"); +static_assert(kRingCentreX - kRingRadius > kSideMargin + kTimeWidth, + "the ring must clear the clock beside it"); + +constexpr std::int32_t kTickRadius = kRingRadius - 3; +constexpr std::int32_t kCardinalTickSize = 7; +constexpr std::int32_t kTickSize = 4; +// Outside the ring rather than inside it. Inside, the arrow sweeps through the letter +// twice a turn; outside, the one thing that establishes which way the card is oriented is +// never crossed by the thing being oriented. +constexpr std::int32_t kNorthTop = kRingCentreY - kRingRadius - 24; +static_assert(kNorthTop >= 0, "the north label must fit on the panel"); + +// A needle: thin at the tail, thickening to the tip, spaced so the dots very nearly touch. +// That near-continuity is what makes five circles read as one thing pointing somewhere +// rather than as five circles. A negative radius puts a dot on the far side of the centre, +// which is all a tail is. +constexpr std::int32_t kArrowRadii[5] = {-30, -14, 4, 24, 44}; +constexpr std::int32_t kArrowSizes[5] = {7, 8, 10, 14, 20}; +static_assert(kArrowRadii[4] + kArrowSizes[4] / 2 < kTickRadius - 2, + "the tip must stay inside the ticks"); +static_assert(-kArrowRadii[0] + kArrowSizes[0] / 2 < kTickRadius - 2, + "the tail must stay inside the ticks too"); + +constexpr std::uint32_t kCard = 0x3A4556; +constexpr std::uint32_t kCardBright = 0x6C7E96; +constexpr std::uint32_t kArrow = 0xFF5252; + +constexpr float kPi = 3.14159265F; + +// Screen position of a bearing on the ring. North is up and bearings run clockwise, which +// is why sine drives x and cosine is subtracted from y rather than the other way about. +void place(lv_obj_t* const object, const float bearing_deg, const std::int32_t radius, + const std::int32_t size) noexcept +{ + const auto radians = bearing_deg * kPi / 180.0F; + const auto x = static_cast(std::lround( + static_cast(kRingCentreX) + static_cast(radius) * std::sin(radians))); + const auto y = static_cast(std::lround( + static_cast(kRingCentreY) - static_cast(radius) * std::cos(radians))); + lv_obj_set_pos(object, x - size / 2, y - size / 2); +} + +lv_obj_t* make_dot(lv_obj_t* const parent, const std::int32_t size, + const std::uint32_t rgb) noexcept +{ + auto* dot = lv_obj_create(parent); + lv_obj_remove_style_all(dot); + lv_obj_set_size(dot, size, size); + lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(dot, lv_color_hex(rgb), 0); + lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); + lv_obj_remove_flag(dot, LV_OBJ_FLAG_CLICKABLE); + return dot; +} + +lv_obj_t* make_cell(lv_obj_t* const parent, const lv_font_t* const font, + const std::int32_t width, const std::int32_t height, + const std::int32_t left, const std::int32_t top) noexcept +{ + auto* cell = lv_label_create(parent); + if (font != nullptr) { + lv_obj_set_style_text_font(cell, font, 0); + } + lv_obj_set_style_text_color(cell, lv_color_hex(color::text_primary), 0); + lv_obj_set_size(cell, width, height); + lv_obj_set_pos(cell, left, top); + lv_obj_set_style_text_align(cell, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_text(cell, ""); + return cell; +} + +// How far a font's baseline sits below the top of its line box. +// +// Used for the unit alone, and only because the unit is a built-in face that is the same +// font on the host as on the panel - so unlike the numeral above it, measuring it tells +// the truth in both places. It is what sits the unit on the digits' baseline instead of at +// an offset picked by eye, which would not survive either face being resized. +[[nodiscard]] std::int32_t baseline_offset(const lv_font_t* const font, + const std::int32_t fallback) noexcept +{ + if (font == nullptr) { + return fallback; + } + return static_cast(lv_font_get_line_height(font)) - + static_cast(font->base_line); +} + +[[nodiscard]] std::int32_t measure(const lv_font_t* const font, + const char* const text) noexcept +{ + if (font == nullptr) { + return 0; + } + lv_point_t size{}; + lv_text_get_size(&size, text, font, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); + return size.x; +} + +} // namespace + +NavigationScreen::NavigationScreen(lv_obj_t* const root) noexcept : root_(root) +{ + style_screen(root_); + + // The clock, top left. Fixed cells, so the seconds ticking cannot shift the hours. + const auto* time_font = countdown_font(kTimePx); + for (std::size_t index = 0; index < time_cells_.size(); ++index) { + time_cells_[index] = + make_cell(root_, time_font, time_cell_width(index), kTimeCellHeight, + time_cell_left(index), kTimeTop); + } + time_shown_.fill('\0'); + + time_status_ = create_label(root_, Typography::body, color::text_secondary, + LV_TEXT_ALIGN_LEFT); + lv_obj_set_width(time_status_, kStatusWidth); + lv_obj_set_pos(time_status_, kSideMargin, kTimeStatusTop); + lv_label_set_text(time_status_, ""); + + speed_status_ = create_label(root_, Typography::body, color::text_secondary, + LV_TEXT_ALIGN_LEFT); + lv_obj_set_width(speed_status_, kStatusWidth); + lv_obj_set_pos(speed_status_, kSideMargin, kSpeedStatusTop); + lv_label_set_text(speed_status_, ""); + + // The ring: built once, never moved again, exactly as CompassScreen's card is. + for (std::size_t index = 0; index < ticks_.size(); ++index) { + const auto cardinal = index % 2 == 0; + const auto size = cardinal ? kCardinalTickSize : kTickSize; + ticks_[index] = make_dot(root_, size, cardinal ? kCardBright : kCard); + place(ticks_[index], + static_cast(index) * 360.0F / static_cast(ticks_.size()), + kTickRadius, size); + } + + auto* north = create_label(root_, Typography::body, color::text_secondary); + lv_obj_set_width(north, 60); + lv_obj_set_pos(north, kRingCentreX - 30, kNorthTop); + lv_label_set_text(north, "N"); + + // Created before the arrow so the arrow draws over it, and sized to the ring so a + // reason for the arrow's absence has somewhere to go that is not the ring's edge. + heading_status_ = create_label(root_, Typography::caption, color::text_secondary); + lv_obj_set_width(heading_status_, kRingRadius * 2); + lv_obj_set_pos(heading_status_, kRingCentreX - kRingRadius, kRingCentreY - 8); + lv_label_set_text(heading_status_, ""); + + // Hidden until there is a heading. An unhidden arrow at a default bearing is the exact + // failure this screen inherits from Compass mode and must not repeat: a device + // pointing somewhere when it knows nothing. + for (std::size_t index = 0; index < arrow_.size(); ++index) { + arrow_[index] = make_dot(root_, kArrowSizes[index], kArrow); + lv_obj_add_flag(arrow_[index], LV_OBJ_FLAG_HIDDEN); + } + + // The speed, low and large. The unit is measured first, because where the digits are + // centred depends on how much width is left once it has taken its own. + const auto* unit_font = font_for(Typography::timer_secondary); + const auto measured = measure(unit_font, "MPH"); + unit_width_ = measured > 0 ? measured : kUnitWidthBudget; + const auto unit_left = kScreenWidth - kSideMargin - unit_width_; + digits_centre_ = (kSideMargin + unit_left - kUnitGap) / 2; + + const auto* speed_font = countdown_font(kSpeedPx); + for (std::size_t index = 0; index < digits_.size(); ++index) { + digits_[index] = make_cell(root_, speed_font, kDigitCellWidth, kDigitCellHeight, + digits_centre_ - + static_cast(digits_.size()) * + kDigitCellWidth / 2 + + static_cast(index) * kDigitCellWidth, + kDigitsTop); + digits_shown_[index] = '\0'; + } + shown_count_ = digits_.size(); + + unit_ = create_label(root_, Typography::timer_secondary, color::text_secondary, + LV_TEXT_ALIGN_LEFT); + lv_obj_set_width(unit_, unit_width_ + 4); + // Sat on the digits' own baseline rather than at a measured-by-eye offset. + lv_obj_set_pos(unit_, unit_left, kSpeedBaseline - baseline_offset(unit_font, 25)); + lv_label_set_text(unit_, ""); + + time_status_shown_[0] = '\0'; + speed_status_shown_[0] = '\0'; + heading_status_shown_[0] = '\0'; +} + +void NavigationScreen::update(const NavigationViewModel& model) noexcept +{ + // The clock. A time that is not known shows dashes in the same cells, so the block + // neither moves nor disappears between a known time and an unknown one. + char time_text[kTimeCells + 1] = {}; + std::snprintf(time_text, sizeof(time_text), "%s:%s", model.time.time.data(), + model.time.seconds.data()); + for (std::size_t index = 0; index < time_cells_.size(); ++index) { + const char character = time_text[index]; + if (character == time_shown_[index]) { + continue; + } + time_shown_[index] = character; + const char text[2] = {character, '\0'}; + lv_label_set_text(time_cells_[index], text); + } + if (model.time.digits_valid != time_valid_) { + time_valid_ = model.time.digits_valid; + for (auto* cell : time_cells_) { + lv_obj_set_style_text_color( + cell, + lv_color_hex(time_valid_ ? color::text_primary : color::text_secondary), 0); + } + } + + // One fault stated once. With no receiver all three readouts are absent for the same + // reason, and saying it three times reads as three faults. + const auto* const time_status = + model.has_shared_fault ? model.shared_fault.data() : model.time.status.data(); + if (std::strcmp(time_status_shown_.data(), time_status) != 0) { + lv_label_set_text(time_status_, time_status); + lv_obj_set_style_text_color( + time_status_, + lv_color_hex(model.has_shared_fault ? model.shared_fault_rgb + : model.time.status_rgb), + 0); + std::snprintf(time_status_shown_.data(), time_status_shown_.size(), "%s", + time_status); + } + + // The unit carries "MPH" and the status line carries the fault, and the two are never + // on screen together: "MPH" beside a row of dashes would read as a speed of nothing + // rather than as no speed at all. + const auto* const speed_status = (model.speed.speed_valid || model.has_shared_fault) + ? "" + : model.speed.status.data(); + if (std::strcmp(speed_status_shown_.data(), speed_status) != 0) { + lv_label_set_text(speed_status_, speed_status); + lv_obj_set_style_text_color(speed_status_, lv_color_hex(model.speed.status_rgb), 0); + std::snprintf(speed_status_shown_.data(), speed_status_shown_.size(), "%s", + speed_status); + } + if (model.speed.speed_valid != speed_valid_) { + speed_valid_ = model.speed.speed_valid; + lv_label_set_text(unit_, speed_valid_ ? model.speed.status.data() : ""); + for (auto* cell : digits_) { + lv_obj_set_style_text_color( + cell, + lv_color_hex(speed_valid_ ? color::text_primary : color::text_secondary), 0); + } + } + + // The block is centred on the digits actually shown, so every reading is centred + // rather than only the two-digit ones. The unit does not move with it. + const auto count = std::strlen(model.speed.speed.data()); + const auto shown = count == 0 || count > digits_.size() ? digits_.size() : count; + if (shown != shown_count_) { + shown_count_ = shown; + const auto left = + digits_centre_ - static_cast(shown) * kDigitCellWidth / 2; + for (std::size_t index = 0; index < digits_.size(); ++index) { + lv_obj_set_pos(digits_[index], + left + static_cast(index) * kDigitCellWidth, + kDigitsTop); + } + } + for (std::size_t index = 0; index < digits_.size(); ++index) { + const char character = index < shown ? model.speed.speed[index] : '\0'; + if (character == digits_shown_[index]) { + continue; + } + digits_shown_[index] = character; + const char text[2] = {character, '\0'}; + lv_label_set_text(digits_[index], text); + } + + // The arrow. Absent whenever the heading is not trustworthy, which includes a parked + // car with sixteen satellites: there is no magnetometer on this board, so a car that + // is not moving has no heading at all rather than a stale one. + if (model.heading.heading_valid != arrow_visible_) { + arrow_visible_ = model.heading.heading_valid; + for (auto* dot : arrow_) { + if (arrow_visible_) { + lv_obj_remove_flag(dot, LV_OBJ_FLAG_HIDDEN); + } + else { + lv_obj_add_flag(dot, LV_OBJ_FLAG_HIDDEN); + } + } + // Forget where it was, so the next heading always repositions rather than + // reappearing at the bearing it held before the car stopped. + arrow_bearing_ = -1; + } + if (arrow_visible_) { + // Moved only when the bearing has changed by a whole degree. This is not + // smoothing: the bearing shown is still exactly what the receiver said. + const auto bearing = static_cast(std::lround(model.heading.heading_deg)) % 360; + if (bearing != arrow_bearing_) { + arrow_bearing_ = bearing; + for (std::size_t index = 0; index < arrow_.size(); ++index) { + place(arrow_[index], static_cast(bearing), kArrowRadii[index], + kArrowSizes[index]); + } + } + } + + const auto* const heading_status = + (model.heading.heading_valid || model.has_shared_fault) + ? "" + : model.heading.status.data(); + if (std::strcmp(heading_status_shown_.data(), heading_status) != 0) { + lv_label_set_text(heading_status_, heading_status); + lv_obj_set_style_text_color(heading_status_, + lv_color_hex(model.heading.status_rgb), 0); + std::snprintf(heading_status_shown_.data(), heading_status_shown_.size(), "%s", + heading_status); + } +} + +} // namespace track_timer::ui diff --git a/firmware/main/screen_router.cpp b/firmware/main/screen_router.cpp index 9aa7a2d..8941636 100644 --- a/firmware/main/screen_router.cpp +++ b/firmware/main/screen_router.cpp @@ -29,6 +29,7 @@ #include "track_timer/ui/gps_only_screen.hpp" #include "track_timer/ui/acceleration_screen.hpp" #include "track_timer/ui/compass_screen.hpp" +#include "track_timer/ui/navigation_screen.hpp" #include "track_timer/ui/gate_capture_screen.hpp" #include "track_timer/ui/speed_only_screen.hpp" #include "track_timer/ui/gesture_input.hpp" @@ -418,6 +419,10 @@ constexpr ui::CarouselEntry kModeEntries[] = { // Beside Speed, because it is the same kind of readout from the same fix - and like // it, a driving display rather than something reached when a thing has gone wrong. {LV_SYMBOL_REFRESH, "COMPASS", kAzure, "course over ground"}, + // After Compass, because it is that reading with the speed and the clock around it. + // The caption names the thing the driver cannot see from the label: the arrow is the + // same course over ground, so it needs the car to be moving. + {LV_SYMBOL_UP, "NAV", kAzure, "speed, GMT and heading"}, {LV_SYMBOL_CHARGE, "0-X", kRuby, "standing start to a target"}, {LV_SYMBOL_GPS, "GPS ONLY", kAzure}, // Last, after the diagnostic. It is the mode least likely to be wanted on a track day, @@ -456,6 +461,7 @@ static_assert(mode_entry_is(ui::DeviceMode::race, "RACE")); static_assert(mode_entry_is(ui::DeviceMode::g_only, "G-ONLY")); static_assert(mode_entry_is(ui::DeviceMode::speed_only, "SPEED")); static_assert(mode_entry_is(ui::DeviceMode::compass, "COMPASS")); +static_assert(mode_entry_is(ui::DeviceMode::navigation, "NAV")); static_assert(mode_entry_is(ui::DeviceMode::acceleration, "0-X")); static_assert(mode_entry_is(ui::DeviceMode::gps_only, "GPS ONLY")); static_assert(mode_entry_is(ui::DeviceMode::clock, "CLOCK")); @@ -983,6 +989,14 @@ class ScreenRouter { lv_screen_active() == compass_root_) { compass_->update(ui::compass_view(gps_only_snapshot_)); } + // Fourth reading of the same snapshot, so "no fix" still has one definition. The + // clock half is rebuilt here rather than reused, for Clock mode's reason: what it + // shows depends on how long ago the reading arrived, and that changes between + // polls as well as between fixes. + if (nav_mode_ != nullptr && nav_mode_root_ != nullptr && + lv_screen_active() == nav_mode_root_) { + nav_mode_->update(ui::navigation_view({gps_only_snapshot_, clock_snapshot()})); + } if (acceleration_ != nullptr && acceleration_root_ != nullptr && lv_screen_active() == acceleration_root_) { acceleration_->update(acceleration_run_.view_model(acceleration_snapshot())); @@ -1071,6 +1085,14 @@ class ScreenRouter { compass_ = new (compass_storage_) ui::CompassScreen(compass_root_); ui::attach_gesture_input(compass_root_, on_input, this); + // Navigation owns the whole panel as well, with nothing on it to press. + nav_mode_root_ = lv_obj_create(nullptr); + if (nav_mode_root_ == nullptr) { + return false; + } + nav_mode_ = new (nav_mode_storage_) ui::NavigationScreen(nav_mode_root_); + ui::attach_gesture_input(nav_mode_root_, on_input, this); + // 0-X owns the whole panel. It does take a tap - that is its reset - which the // shell handler routes before reaching the menu. acceleration_root_ = lv_obj_create(nullptr); @@ -1623,6 +1645,9 @@ class ScreenRouter { case ui::DeviceMode::compass: trackday_->set_mode_note("COMPASS - course over ground, needs movement"); break; + case ui::DeviceMode::navigation: + trackday_->set_mode_note("NAV - speed, GMT and heading"); + break; case ui::DeviceMode::acceleration: trackday_->set_mode_note("0-X - standing start to a target speed"); break; @@ -1662,6 +1687,8 @@ class ScreenRouter { return speed_only_root_; case ui::DeviceMode::compass: return compass_root_; + case ui::DeviceMode::navigation: + return nav_mode_root_; case ui::DeviceMode::acceleration: return acceleration_root_; case ui::DeviceMode::gps_only: @@ -2562,6 +2589,8 @@ class ScreenRouter { lv_obj_t* speed_only_root_{nullptr}; ui::CompassScreen* compass_{nullptr}; lv_obj_t* compass_root_{nullptr}; + ui::NavigationScreen* nav_mode_{nullptr}; + lv_obj_t* nav_mode_root_{nullptr}; ui::AccelerationScreen* acceleration_{nullptr}; lv_obj_t* acceleration_root_{nullptr}; // Holds the run across fixes: the start instant has to be remembered, and the finish @@ -2666,6 +2695,8 @@ class ScreenRouter { alignas(ui::SpeedOnlyScreen) std::byte speed_only_storage_[sizeof(ui::SpeedOnlyScreen)]{}; alignas(ui::CompassScreen) std::byte compass_storage_[sizeof(ui::CompassScreen)]{}; + alignas(ui::NavigationScreen) + std::byte nav_mode_storage_[sizeof(ui::NavigationScreen)]{}; alignas(ui::AccelerationScreen) std::byte acceleration_storage_[sizeof(ui::AccelerationScreen)]{}; alignas(ui::GateCaptureScreen) std::byte diff --git a/simulator/CMakeLists.txt b/simulator/CMakeLists.txt index 40b4296..d2ba47e 100644 --- a/simulator/CMakeLists.txt +++ b/simulator/CMakeLists.txt @@ -60,6 +60,7 @@ add_library( ../firmware/components/ui/gps_only.cpp ../firmware/components/ui/speed_only.cpp ../firmware/components/ui/compass.cpp + ../firmware/components/ui/navigation_mode.cpp ../firmware/components/ui/diagnostics.cpp ../firmware/components/ui/display_policy.cpp ../firmware/components/ui/imu_meter.cpp @@ -126,6 +127,7 @@ add_library( ../firmware/components/ui/gps_only_screen.cpp ../firmware/components/ui/speed_only_screen.cpp ../firmware/components/ui/compass_screen.cpp + ../firmware/components/ui/navigation_screen.cpp ../firmware/components/ui/lvgl_visual_system.cpp src/host_timer_font.cpp ../firmware/components/ui/ready_screen.cpp @@ -1372,6 +1374,49 @@ if(BUILD_TESTING) TIMEOUT 20 ) + # The layout this mode exists for, rendered: speed low and large with the unit beside + # it, the clock top left, and the ring top right with no arrow in it. A parked device + # is the arrow's absent case, so the snapshot that proves the absence is the cheap one. + add_test( + NAME simulator-navigation-screen + COMMAND track_timer_simulator + --headless + --scenario ready + --screen navigation + --frames 20 + --snapshot "${CMAKE_CURRENT_BINARY_DIR}/navigation.ppm" + ) + set_tests_properties( + simulator-navigation-screen + PROPERTIES + ENVIRONMENT "SDL_VIDEODRIVER=dummy" + PASS_REGULAR_EXPRESSION + "screen=setup.*setup-page=navigation.*nav-heading=---.*nav-shared-fault=no" + TIMEOUT 20 + ) + + # The live case cannot be rendered, for the reason the GPS Only, Speed Only and Compass + # pairs above give: every scenario that replays fixes also runs a session, which hides + # the page. This asserts the composed view model against the real replayed stream - a + # moving car has a speed and a three-digit heading at the same time, which is the one + # thing none of the three source modes can show on its own - and takes no snapshot. + add_test( + NAME simulator-navigation-live-view + COMMAND track_timer_simulator + --headless + --scenario active + --screen navigation + --frames 30 + ) + set_tests_properties( + simulator-navigation-live-view + PROPERTIES + ENVIRONMENT "SDL_VIDEODRIVER=dummy" + PASS_REGULAR_EXPRESSION + "nav-speed=[0-9]+ nav-heading=[0-9][0-9][0-9] nav-shared-fault=no" + TIMEOUT 20 + ) + add_test( NAME simulator-storage-failure-smoke COMMAND track_timer_simulator diff --git a/simulator/src/application_screen.cpp b/simulator/src/application_screen.cpp index 337329f..24562f3 100644 --- a/simulator/src/application_screen.cpp +++ b/simulator/src/application_screen.cpp @@ -37,6 +37,7 @@ ApplicationScreen::ApplicationScreen(lv_obj_t* root, gps_only_root_(make_screen_root(root)), speed_only_root_(make_screen_root(root)), compass_root_(make_screen_root(root)), + navigation_root_(make_screen_root(root)), clock_root_(make_screen_root(root)), rest_root_(make_screen_root(root)), ready_screen_(ready_root_, ready_navigation, this), @@ -51,6 +52,7 @@ ApplicationScreen::ApplicationScreen(lv_obj_t* root, gps_only_screen_(gps_only_root_), speed_only_screen_(speed_only_root_), compass_screen_(compass_root_), + navigation_screen_(navigation_root_), clock_screen_(clock_root_), rest_screen_(rest_root_, rest_action, this) { @@ -105,6 +107,15 @@ void ApplicationScreen::update(const ui::ReadyViewModel& ready, setup_page_ == SetupPage::clock) { clock_screen_.update(ui::clock_view(clock_snapshot_)); } + // Here rather than with the other pages below, because Navigation reads the clock too + // and this is where the clock snapshot has just been rebuilt. It is deliberately not + // also in the per-fix path: a fix changes only the GNSS half, which the next tick + // redraws regardless. + if (navigation_.destination() == ui::Destination::setup && + setup_page_ == SetupPage::navigation) { + navigation_screen_.update( + ui::navigation_view({gps_only_snapshot_, clock_snapshot_})); + } ready_screen_.update(ready); const auto& active_settings = active_settings_override == nullptr @@ -791,6 +802,7 @@ void ApplicationScreen::show_destination() noexcept lv_obj_add_flag(gps_only_root_, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(speed_only_root_, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(compass_root_, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(navigation_root_, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(clock_root_, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(rest_root_, LV_OBJ_FLAG_HIDDEN); @@ -826,6 +838,11 @@ void ApplicationScreen::show_destination() noexcept lv_obj_remove_flag(compass_root_, LV_OBJ_FLAG_HIDDEN); compass_screen_.update(ui::compass_view(gps_only_snapshot_)); } + else if (setup_page_ == SetupPage::navigation) { + lv_obj_remove_flag(navigation_root_, LV_OBJ_FLAG_HIDDEN); + navigation_screen_.update( + ui::navigation_view({gps_only_snapshot_, clock_snapshot_})); + } else if (setup_page_ == SetupPage::clock) { lv_obj_remove_flag(clock_root_, LV_OBJ_FLAG_HIDDEN); clock_screen_.update(ui::clock_view(clock_snapshot_)); @@ -880,7 +897,7 @@ void ApplicationScreen::apply_display_policy(const board::DisplayCommand& comman for (auto* root : {ready_root_, active_root_, setup_menu_root_, settings_root_, track_selection_root_, gate_capture_root_, session_review_root_, diagnostics_root_, g_meter_root_, gps_only_root_, speed_only_root_, compass_root_, - clock_root_, rest_root_}) { + navigation_root_, clock_root_, rest_root_}) { lv_obj_set_pos(root, command.layout_shift_x, command.layout_shift_y); lv_obj_set_style_transform_pivot_x(root, 300, 0); lv_obj_set_style_transform_pivot_y(root, 225, 0); @@ -919,6 +936,8 @@ const char* setup_page_name(const SetupPage page) noexcept return "speed-only"; case SetupPage::compass: return "compass"; + case SetupPage::navigation: + return "navigation"; case SetupPage::clock: return "clock"; } diff --git a/simulator/src/application_screen.hpp b/simulator/src/application_screen.hpp index 268bb9c..4d2643e 100644 --- a/simulator/src/application_screen.hpp +++ b/simulator/src/application_screen.hpp @@ -25,6 +25,7 @@ #include "track_timer/ui/clock_screen.hpp" #include "track_timer/ui/gps_only_screen.hpp" #include "track_timer/ui/compass_screen.hpp" +#include "track_timer/ui/navigation_screen.hpp" #include "track_timer/ui/speed_only_screen.hpp" #include "track_timer/ui/track_selection.hpp" #include "track_timer/ui/track_selection_screen.hpp" @@ -42,6 +43,7 @@ enum class SetupPage : std::uint8_t { gps_only, speed_only, compass, + navigation, clock, }; @@ -157,6 +159,7 @@ class ApplicationScreen { lv_obj_t* gps_only_root_{nullptr}; lv_obj_t* speed_only_root_{nullptr}; lv_obj_t* compass_root_{nullptr}; + lv_obj_t* navigation_root_{nullptr}; lv_obj_t* clock_root_{nullptr}; lv_obj_t* rest_root_{nullptr}; lv_obj_t* brightness_overlay_{nullptr}; @@ -181,6 +184,7 @@ class ApplicationScreen { ui::GpsOnlySnapshot gps_only_snapshot_{}; ui::SpeedOnlyScreen speed_only_screen_; ui::CompassScreen compass_screen_; + ui::NavigationScreen navigation_screen_; ui::ClockScreen clock_screen_; ui::ClockSnapshot clock_snapshot_{}; ui::RestSessionController rest_session_{}; diff --git a/simulator/src/main.cpp b/simulator/src/main.cpp index 4701e4e..b247f74 100644 --- a/simulator/src/main.cpp +++ b/simulator/src/main.cpp @@ -14,6 +14,7 @@ #include "track_timer/track/definition.hpp" #include "track_timer/track/matching.hpp" #include "track_timer/ui/foundation.hpp" +#include "track_timer/ui/navigation_mode.hpp" #include "track_timer/ui/presenter.hpp" #include @@ -192,12 +193,14 @@ Options parse_options(const int argc, char** argv) options.initial_screen != "gps-only" && options.initial_screen != "speed-only" && options.initial_screen != "compass" && + options.initial_screen != "navigation" && options.initial_screen != "clock" && options.initial_screen != "review" && options.initial_screen != "diagnostics") { throw std::invalid_argument( "--screen must be ready, setup, settings, tracks, gate-capture, g-meter, " - "gps-only, speed-only, compass, clock, review, or diagnostics"); + "gps-only, speed-only, compass, navigation, clock, review, or " + "diagnostics"); } } else if (argument == "--imu-state") { @@ -256,7 +259,7 @@ Options parse_options(const int argc, char** argv) else if (argument == "--help") { std::cout << "Usage: track_timer_simulator [--scenario NAME] [--headless] " "[--frames COUNT] [--frame-ms MS] [--gnss-rate 20|25] " - "[--gnss-fixture FILE] [--screen ready|setup|settings|tracks|gate-capture|g-meter|gps-only|speed-only|compass|clock|review|diagnostics] " + "[--gnss-fixture FILE] [--screen ready|setup|settings|tracks|gate-capture|g-meter|gps-only|speed-only|compass|navigation|clock|review|diagnostics] " "[--track-state STATE] [--review-state STATE] " "[--diagnostics-state STATE] [--display-state STATE] [--imu-state STATE] [--workflow-state STATE] [--trackday-mode saved|enabled|disabled] " "[--snapshot FILE]\n" @@ -668,6 +671,9 @@ int run(const Options& options) else if (options.initial_screen == "compass") { screen.open_setup_page(track_timer::simulator::SetupPage::compass); } + else if (options.initial_screen == "navigation") { + screen.open_setup_page(track_timer::simulator::SetupPage::navigation); + } else if (options.initial_screen == "clock") { screen.open_setup_page(track_timer::simulator::SetupPage::clock); } @@ -799,6 +805,17 @@ int run(const Options& options) track_timer::ui::compass_state(screen.gps_only())) << " compass-deg=" << track_timer::ui::compass_view(screen.gps_only()).degrees.data() + << " nav-speed=" + << track_timer::ui::navigation_view({screen.gps_only(), screen.clock()}) + .speed.speed.data() + << " nav-heading=" + << track_timer::ui::navigation_view({screen.gps_only(), screen.clock()}) + .heading.degrees.data() + << " nav-shared-fault=" + << (track_timer::ui::navigation_view({screen.gps_only(), screen.clock()}) + .has_shared_fault + ? "yes" + : "no") << " clock-state=" << track_timer::ui::clock_state_name( track_timer::ui::clock_state(screen.clock())) diff --git a/tests/cpp/test_device_mode.cpp b/tests/cpp/test_device_mode.cpp index 3a1371b..cc7d45c 100644 --- a/tests/cpp/test_device_mode.cpp +++ b/tests/cpp/test_device_mode.cpp @@ -25,6 +25,7 @@ void every_mode_round_trips_through_settings() for (const auto mode : {ui::DeviceMode::track_day, ui::DeviceMode::race, ui::DeviceMode::g_only, ui::DeviceMode::gps_only, ui::DeviceMode::speed_only, ui::DeviceMode::compass, + ui::DeviceMode::navigation, ui::DeviceMode::acceleration, ui::DeviceMode::clock}) { auto settings = base(); ui::apply_mode(mode, settings); @@ -59,6 +60,10 @@ void modes_map_onto_the_existing_settings_fields() assert(settings.operating_mode == settings::OperatingMode::speed_only); assert(!settings.trackday_mode_enabled); + ui::apply_mode(ui::DeviceMode::navigation, settings); + assert(settings.operating_mode == settings::OperatingMode::navigation); + assert(!settings.trackday_mode_enabled); + ui::apply_mode(ui::DeviceMode::compass, settings); assert(settings.operating_mode == settings::OperatingMode::compass); assert(!settings.trackday_mode_enabled); @@ -83,6 +88,9 @@ void the_operating_mode_wins_over_a_stale_trackday_flag() settings.operating_mode = settings::OperatingMode::compass; assert(ui::mode_from_settings(settings) == ui::DeviceMode::compass); + + settings.operating_mode = settings::OperatingMode::navigation; + assert(ui::mode_from_settings(settings) == ui::DeviceMode::navigation); } // A debug view of the receiver and nothing else. A session timer here would invite using @@ -135,6 +143,18 @@ void compass_shows_heading_and_nothing_else() assert(!visibility.g_meter); } +// Three readouts on one screen, and still not a session view: a device showing speed, the +// time and a heading is not a device being used to time something. +void navigation_shows_three_readouts_and_no_timer() +{ + const auto visibility = ui::visibility_for(ui::DeviceMode::navigation); + assert(visibility.gnss_data); + assert(!visibility.session_timer); + assert(!visibility.lap_times); + assert(!visibility.lap_delta); + assert(!visibility.g_meter); +} + void every_mode_has_a_name_a_label_and_a_summary() { for (std::size_t index = 0; index < ui::kDeviceModeCount; ++index) { @@ -267,6 +287,7 @@ int main() clock_shows_the_time_and_nothing_else(); speed_only_shows_speed_and_nothing_else(); compass_shows_heading_and_nothing_else(); + navigation_shows_three_readouts_and_no_timer(); every_mode_has_a_name_a_label_and_a_summary(); track_day_shows_the_timer_only(); race_shows_laps_delta_and_remaining(); diff --git a/tests/cpp/test_navigation_mode.cpp b/tests/cpp/test_navigation_mode.cpp new file mode 100644 index 0000000..29d4c24 --- /dev/null +++ b/tests/cpp/test_navigation_mode.cpp @@ -0,0 +1,187 @@ +#include "track_timer/ui/navigation_mode.hpp" + +#include +#include +#include + +namespace { + +using namespace track_timer; + +constexpr std::int64_t kSecond = 1'000'000; + +ui::NavigationSnapshot nothing() +{ + return ui::NavigationSnapshot{}; +} + +// A receiver that is present, fixed, and moving at the given speed and course. +ui::NavigationSnapshot driving(const float speed_mps, const float heading_deg) +{ + ui::NavigationSnapshot snapshot{}; + snapshot.gnss.receiver_present = true; + snapshot.gnss.fix_valid = true; + snapshot.gnss.fix.fix_type = domain::FixType::fix_3d; + snapshot.gnss.fix.speed_mps = speed_mps; + snapshot.gnss.fix.heading_deg = heading_deg; + snapshot.gnss.fix.heading_accuracy_deg = 2.0F; + snapshot.gnss.fix.accepted_for_timing = true; + + snapshot.clock.receiver_present = true; + snapshot.clock.utc.valid = true; + snapshot.clock.utc.year = 2026; + snapshot.clock.utc.month = 10; + snapshot.clock.utc.day = 20; + snapshot.clock.utc.hour = 14; + snapshot.clock.utc.minute = 32; + snapshot.clock.utc.second = 7; + snapshot.clock.utc_monotonic_us = 10 * kSecond; + snapshot.clock.now_monotonic_us = 10 * kSecond; + return snapshot; +} + +// The mode composes three view builders rather than formatting anything itself. That is +// the whole design, so it is the first thing asserted: each readout must arrive with the +// value its own mode would have shown for the same snapshot. +void each_readout_matches_the_mode_it_came_from() +{ + const auto snapshot = driving(31.3F, 90.0F); // about 70 mph, due east + const auto model = ui::navigation_view(snapshot); + + assert(std::strcmp(model.speed.speed.data(), + ui::speed_only_view(snapshot.gnss).speed.data()) == 0); + assert(std::strcmp(model.time.time.data(), + ui::clock_view(snapshot.clock).time.data()) == 0); + assert(std::strcmp(model.heading.degrees.data(), + ui::compass_view(snapshot.gnss).degrees.data()) == 0); + + // And the values themselves, so a composition that silently returned defaults would + // not pass on the strength of agreeing with itself. + assert(model.speed.speed_valid); + assert(std::strcmp(model.speed.speed.data(), "70") == 0); + assert(std::strcmp(model.speed.status.data(), "MPH") == 0); + assert(model.time.digits_valid); + assert(std::strcmp(model.time.time.data(), "14:32") == 0); + assert(std::strcmp(model.time.seconds.data(), "07") == 0); + assert(model.heading.heading_valid); + assert(std::strcmp(model.heading.degrees.data(), "090") == 0); + assert(std::strcmp(model.heading.cardinal.data(), "E") == 0); + assert(!model.has_shared_fault); +} + +// The case the mode exists to get right, and the one a combined screen is most likely to +// get wrong. Parked with a good fix, the device knows the speed (zero, truthfully) and the +// time, and knows nothing whatever about which way it is facing: there is no magnetometer +// on this board. The arrow must go, and the other two must stay. +void a_parked_car_keeps_its_speed_and_time_and_loses_the_arrow() +{ + auto snapshot = driving(0.0F, 0.0F); + // What a receiver standing still actually reports: a course it cannot vouch for. + snapshot.gnss.fix.heading_accuracy_deg = 80.0F; + const auto model = ui::navigation_view(snapshot); + + assert(model.speed.speed_valid); + assert(std::strcmp(model.speed.speed.data(), "0") == 0); + assert(model.time.digits_valid); + assert(!model.heading.heading_valid); + assert(model.heading.state == ui::CompassState::no_heading); + assert(std::strlen(model.heading.status.data()) > 0); // says why + // Not collapsed into one line: three readouts that disagree have three things to say, + // and the heading is the only one of them the driver cannot work out for themselves. + assert(!model.has_shared_fault); +} + +// An optimistic headAcc while stationary must not get an arrow through on a technicality. +void a_believable_heading_accuracy_does_not_beat_standing_still() +{ + auto snapshot = driving(0.1F, 45.0F); + snapshot.gnss.fix.heading_accuracy_deg = 1.0F; // the receiver's own optimism + const auto model = ui::navigation_view(snapshot); + assert(!model.heading.heading_valid); + assert(model.speed.speed_valid); // 0 mph is a true reading and stays +} + +// With no bytes ever received, all three readouts are absent for one reason. Saying it in +// three places reads as three faults, so the mode says it once. +void one_cause_is_stated_once() +{ + const auto model = ui::navigation_view(nothing()); + + assert(!model.speed.speed_valid); + assert(!model.time.digits_valid); + assert(!model.heading.heading_valid); + assert(model.has_shared_fault); + assert(std::strlen(model.shared_fault.data()) > 0); + // The shared line is one of the readouts' own words, not a fourth wording of the same + // fault invented here. + assert(std::strcmp(model.shared_fault.data(), model.speed.status.data()) == 0); + // And the readouts still carry their dashes, so the screen has something to draw in + // the cells rather than emptying them. + assert(std::strcmp(model.speed.speed.data(), "---") == 0); + assert(std::strcmp(model.time.time.data(), "--:--") == 0); + assert(std::strcmp(model.heading.degrees.data(), "---") == 0); +} + +// A receiver that is talking but has not resolved anything yet is not the shared-fault +// case: it is a different diagnosis from a receiver that is not there, and the two must +// not be shown the same way. +void a_present_receiver_with_nothing_resolved_is_not_the_shared_fault() +{ + ui::NavigationSnapshot snapshot{}; + snapshot.gnss.receiver_present = true; + snapshot.clock.receiver_present = true; + const auto model = ui::navigation_view(snapshot); + + assert(!model.speed.speed_valid); + assert(!model.time.digits_valid); + assert(!model.heading.heading_valid); + assert(!model.has_shared_fault); + // Each says its own thing, and none of them says the receiver is missing. + assert(std::strlen(model.speed.status.data()) > 0); + assert(std::strlen(model.time.status.data()) > 0); + assert(std::strlen(model.heading.status.data()) > 0); +} + +// A three-digit speed is the case the screen's layout is sized against, so it is worth +// knowing the view model really does produce one. +void a_three_digit_speed_is_three_characters() +{ + const auto model = ui::navigation_view(driving(53.6F, 180.0F)); // about 120 mph + assert(model.speed.speed_valid); + assert(std::strlen(model.speed.speed.data()) == 3); + assert(std::strcmp(model.heading.cardinal.data(), "S") == 0); +} + +// The clock outliving the fix that carried it is why the snapshot carries two receiver +// flags rather than one. A receiver that has gone quiet holds the time and loses the rest. +void a_quiet_receiver_holds_the_clock_and_drops_the_arrow() +{ + auto snapshot = driving(31.3F, 90.0F); + snapshot.gnss.fix_valid = false; + snapshot.gnss.fix = {}; + snapshot.clock.now_monotonic_us = snapshot.clock.utc_monotonic_us + 30 * kSecond; + const auto model = ui::navigation_view(snapshot); + + assert(model.time.digits_valid); + assert(model.time.state == ui::ClockState::holding); + assert(!model.speed.speed_valid); + assert(!model.heading.heading_valid); + assert(!model.has_shared_fault); +} + +} // namespace + +int main() +{ + each_readout_matches_the_mode_it_came_from(); + a_parked_car_keeps_its_speed_and_time_and_loses_the_arrow(); + a_believable_heading_accuracy_does_not_beat_standing_still(); + one_cause_is_stated_once(); + a_present_receiver_with_nothing_resolved_is_not_the_shared_fault(); + a_three_digit_speed_is_three_characters(); + a_quiet_receiver_holds_the_clock_and_drops_the_arrow(); + + std::cout << "Navigation mode: composed readouts, a parked car losing only its arrow, " + "and one cause stated once passed\n"; + return 0; +} diff --git a/tests/cpp/test_settings.cpp b/tests/cpp/test_settings.cpp index cdb119f..08cfbcb 100644 --- a/tests/cpp/test_settings.cpp +++ b/tests/cpp/test_settings.cpp @@ -201,7 +201,9 @@ void test_acceleration_mode_and_target_round_trip() auto beyond = settings; beyond.operating_mode = static_cast( static_cast(OperatingMode::acceleration) + 1); - assert(!valid_settings(beyond)); + // Valid now: navigation follows it. The bound moves with the enum every time, which is + // the point of asserting it here rather than trusting it. + assert(valid_settings(beyond)); // A target the picker cannot reach is a record this firmware did not write, whatever // else in it looks reasonable. @@ -212,6 +214,33 @@ void test_acceleration_mode_and_target_round_trip() assert(!valid_settings(odd)); } +// Navigation, appended after Acceleration, and currently the last enumerator - so this is +// the test that holds the upper bound. Adding a mode after it means moving the assertion +// below, which is deliberate: the bound and the enum must be changed together or a stored +// byte this firmware cannot render would decode as valid. +void test_navigation_operating_mode_round_trips() +{ + auto settings = customized_settings(); + settings.operating_mode = OperatingMode::navigation; + assert(valid_settings(settings)); + + const auto blob = encode_settings(settings); + DeviceSettings decoded{}; + assert(decode_settings(blob, decoded) == DecodeResult::current); + assert(decoded.operating_mode == OperatingMode::navigation); + assert(settings_equal(settings, decoded)); + + // Appending the mode must not have changed the record, because the mode is one byte + // that already existed and nothing else moved. A format bump here would mean every + // device losing its settings for the sake of a new screen. + assert(decode_settings(blob, decoded) == DecodeResult::current); + + auto beyond = settings; + beyond.operating_mode = static_cast( + static_cast(OperatingMode::navigation) + 1); + assert(!valid_settings(beyond)); +} + // A device upgrading from v7 has never had an opinion about the target, so it gets the // default rather than a zero - and everything else it did choose has to survive intact. void test_v7_records_migrate_and_keep_their_settings() @@ -483,6 +512,7 @@ int main() test_speed_only_operating_mode_round_trips(); test_compass_operating_mode_round_trips(); test_acceleration_mode_and_target_round_trip(); + test_navigation_operating_mode_round_trips(); test_v7_records_migrate_and_keep_their_settings(); test_launch_sensitivity_snapping(); test_defaults_restart_and_deferred_apply();