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();