From c0d73fe11bcace7e7d81281932543350291fcb0c Mon Sep 17 00:00:00 2001 From: mrsqr Date: Thu, 20 Aug 2026 18:15:12 +0100 Subject: [PATCH] feat(ui): add a TRIGGER menu and start the session on a launch START was the only way to begin a session. A driver leaving the pits had to press it at the right moment, which is a poor thing to ask of someone joining a circuit, and the LAUNCH setting that should have automated it was stored, editable, and read by nothing. TRIGGER sits between TRACK and SETUP, because both are set at the circuit before going out rather than buried among the rarely-touched options. MANUAL behaves as before. IMU and GPS arm the timer instead, showing the session screen with the clock held at the configured duration and PENDING where the lap estimate goes, so the driver can see the device is ready and waiting rather than wondering whether the press registered. The IMU trigger reads forward acceleration only. A launch is acceleration down the road, so braking, cornering and kerb strikes cannot start a session; a test asserts that -3 g does not fire. GravityCalibration already returns exactly this, gravity removed and resolved into vehicle axes, with the attitude tracking keeping it honest under body roll. TRIGGER owns pit_exit_auto_start_enabled rather than sitting beside it. That setting already meant "start when the car leaves the pits", with tested automation behind it, so GPS derives it and the boolean leaves the device settings list. Offering one behaviour in two places invites the two disagreeing, which is the rule Mode established. pit_entry_auto_stop_enabled is untouched: ending a session is a different question. A trigger that cannot fire is worse than no trigger, because the device simply sits there. LAUNCH at zero means off, so IMU refuses to arm and says so; GPS refuses because no receiver exists. Both report rather than waiting silently. Arming lives in the router. A pending session is one that has not been started yet, so SessionController needs no new state and stays as tested. A double tap disarms, the same gesture that ends a session and ends a rest period. Also fixes two things found on the way. Section menus opened on their first item rather than the one in force, so returning to TRIGGER showed MANUAL however IMU was set - and the same was true of Mode and Track, which the carousel title partly hid for Mode by showing the live value beside the wrong highlight. The value level already resolved its current entry and called select_value; the section level had no equivalent to call. Tracks are matched by identifier rather than a remembered position, since the catalog is rebuilt from the card at boot. Separately, the menu carousel was passed a count of four against five entries, so Diagnostics was selectable by the shell and never drawn; a static_assert now ties the array to the shell's count. Settings reach v6 for the trigger. Everything before it predates the trigger, so migrating defaults to MANUAL, which is what those devices were doing. Closes #155 Closes #157 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 7 + Makefile | 17 +- firmware/components/settings/component.cpp | 37 ++- .../include/track_timer/settings/settings.hpp | 17 +- firmware/components/ui/CMakeLists.txt | 2 +- firmware/components/ui/active_session.cpp | 28 ++- .../include/track_timer/ui/active_session.hpp | 11 + .../track_timer/ui/session_trigger.hpp | 47 ++++ .../track_timer/ui/shell_navigation.hpp | 11 +- .../include/track_timer/ui/value_picker.hpp | 10 +- firmware/components/ui/session_trigger.cpp | 94 ++++++++ firmware/components/ui/shell_navigation.cpp | 17 +- firmware/components/ui/trackday_screen.cpp | 10 +- firmware/main/screen_router.cpp | 222 +++++++++++++++--- tests/cpp/test_active_session.cpp | 17 ++ tests/cpp/test_session_trigger.cpp | 138 +++++++++++ tests/cpp/test_settings.cpp | 17 ++ tests/cpp/test_shell_navigation.cpp | 81 +++++-- 18 files changed, 719 insertions(+), 64 deletions(-) create mode 100644 firmware/components/ui/include/track_timer/ui/session_trigger.hpp create mode 100644 firmware/components/ui/session_trigger.cpp create mode 100644 tests/cpp/test_session_trigger.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index f3f2698..c81efd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ The project follows Semantic Versioning once the first firmware release is tagge ### Fixed +- section menus opened on their first item rather than the one in force, so Mode, Track and + Trigger could not tell the driver what was set, only let them change it +- the menu carousel was told there were four items while the shell offered five, leaving + Diagnostics selectable but never drawn + - the start page kept the durations it read at boot, so a session or rest period changed in the menu was obeyed by the timer but still shown at its old value until a restart - durations were written two ways at once, so a one minute session read "1 MIN SESSION" @@ -58,6 +63,8 @@ The project follows Semantic Versioning once the first firmware release is tagge - vertical G alongside the lateral and longitudinal pair, for kerbs and compressions - gyroscope zero-rate offset measured at rest and removed, 4.4 dps on this board - device settings persisted in NVS, so Mode survives a reboot +- top-level TRIGGER selection: MANUAL starts on the button, IMU on a launch, GPS at the line +- pending phase on the running screen for a session armed and waiting for its trigger - overrun timer counting up in deep purple once a session reaches 00:00, with the lap estimate replaced by OVER RUN - rest period shown on the running screen, counting down on the same ramp as a session diff --git a/Makefile b/Makefile index f104aa8..6de17a1 100644 --- a/Makefile +++ b/Makefile @@ -42,15 +42,16 @@ DEVICE_MODE_TEST_BINARY := build/host/device_mode_test IMU_CALIBRATION_TEST_BINARY := build/host/imu_calibration_test SESSION_URGENCY_TEST_BINARY := build/host/session_urgency_test TIME_ROLLER_TEST_BINARY := build/host/time_roller_test +SESSION_TRIGGER_TEST_BINARY := build/host/session_trigger_test GATE_CAPTURE_TEST_BINARY := build/host/gate_capture_test GATE_SESSION_AUTOMATION_TEST_BINARY := build/host/gate_session_automation_test SIMULATOR_BUILD_DIR ?= build/simulator SIMULATOR_IMAGE ?= track-session-timer-simulator:lvgl-9.5.0 CMAKE ?= cmake -.PHONY: check test track-validate uk-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 imu-calibration-test session-urgency-test time-roller-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-device-info firmware-clean issue-preview label-preview +.PHONY: check test track-validate uk-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 imu-calibration-test session-urgency-test time-roller-test session-trigger-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-device-info firmware-clean issue-preview label-preview -check: test track-validate 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 imu-calibration-test session-urgency-test time-roller-test gate-capture-test gate-session-automation-test ui-foundation-test navigation-test +check: test track-validate 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 imu-calibration-test session-urgency-test time-roller-test session-trigger-test gate-capture-test gate-session-automation-test ui-foundation-test navigation-test test: $(PYTHON) -B -m unittest discover -s tests -p 'test_*.py' @@ -423,6 +424,18 @@ gate-session-automation-test: -o $(GATE_SESSION_AUTOMATION_TEST_BINARY) $(GATE_SESSION_AUTOMATION_TEST_BINARY) +session-trigger-test: + mkdir -p build/host + $(CXX) -std=c++17 -Wall -Wextra -Werror -pedantic \ + -Ifirmware/components/settings/include \ + -Ifirmware/components/ui/include \ + firmware/components/settings/component.cpp \ + firmware/components/ui/session_trigger.cpp \ + firmware/components/ui/value_picker.cpp \ + firmware/components/ui/time_roller.cpp \ + tests/cpp/test_session_trigger.cpp -o $(SESSION_TRIGGER_TEST_BINARY) + $(SESSION_TRIGGER_TEST_BINARY) + time-roller-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 3f96e66..333931a 100644 --- a/firmware/components/settings/component.cpp +++ b/firmware/components/settings/component.cpp @@ -16,7 +16,9 @@ constexpr std::size_t kLegacyV4PayloadSize = 66; // v5 appends the durations as seconds. The minutes still written at offsets 0-3 are // vestigial: they keep the shared prefix that every earlier version's decoder reads, and // v5 ignores them in favour of the appended values. -constexpr std::size_t kCurrentPayloadSize = 74; +constexpr std::size_t kLegacyV5PayloadSize = 74; +// v6 appends the session trigger, which nothing before it had. +constexpr std::size_t kCurrentPayloadSize = 75; inline constexpr std::uint32_t kMaximumDurationSeconds = 24U * 60U * 60U; inline constexpr std::uint32_t kMinimumSessionSeconds = 60U; @@ -149,6 +151,7 @@ bool valid_settings(const DeviceSettings& settings) noexcept (settings.average_lap_seconds > 0 || settings.lower_display == LowerDisplayMode::elapsed) && settings.lap_boundary <= LapBoundaryMode::finish && + settings.session_trigger <= SessionTrigger::gps && valid_track_identifier(settings.selected_track_id); } @@ -222,16 +225,28 @@ SettingsBlob encode_legacy_settings_v4(const DeviceSettings& settings) noexcept return make_blob(4, payload.data(), payload.size()); } -SettingsBlob encode_settings(const DeviceSettings& settings) noexcept +SettingsBlob encode_legacy_settings_v5(const DeviceSettings& settings) noexcept { const auto legacy = encode_legacy_settings_v4(settings); if (legacy.size == 0) { return {}; } - std::array payload{}; + std::array payload{}; std::copy_n(legacy.bytes.data() + kHeaderSize, kLegacyV4PayloadSize, payload.data()); put_u32(payload.data() + 66, settings.session_duration_seconds); put_u32(payload.data() + 70, settings.rest_duration_seconds); + return make_blob(5, payload.data(), payload.size()); +} + +SettingsBlob encode_settings(const DeviceSettings& settings) noexcept +{ + const auto legacy = encode_legacy_settings_v5(settings); + if (legacy.size == 0) { + return {}; + } + std::array payload{}; + std::copy_n(legacy.bytes.data() + kHeaderSize, kLegacyV5PayloadSize, payload.data()); + payload[74] = static_cast(settings.session_trigger); return make_blob(kCurrentSettingsVersion, payload.data(), payload.size()); } @@ -274,7 +289,7 @@ DecodeResult decode_settings(const SettingsBlob& blob, DeviceSettings& settings) settings = candidate; return DecodeResult::migrated_v1; } - if (version != 2 && version != 3 && version != 4 && + if (version != 2 && version != 3 && version != 4 && version != 5 && version != kCurrentSettingsVersion) { return DecodeResult::unsupported_version; } @@ -282,6 +297,7 @@ DecodeResult decode_settings(const SettingsBlob& blob, DeviceSettings& settings) version == 2 ? kLegacyV2PayloadSize : version == 3 ? kLegacyV3PayloadSize : version == 4 ? kLegacyV4PayloadSize + : version == 5 ? kLegacyV5PayloadSize : kCurrentPayloadSize; if (payload_size != expected_payload_size) { return DecodeResult::corrupt; @@ -308,16 +324,23 @@ DecodeResult decode_settings(const SettingsBlob& blob, DeviceSettings& settings) candidate.pit_exit_auto_start_enabled = payload[64] != 0; candidate.pit_entry_auto_stop_enabled = payload[65] != 0; } - if (version == kCurrentSettingsVersion) { + if (version >= 5) { // The authoritative durations, which the vestigial minutes above cannot express. candidate.session_duration_seconds = get_u32(payload + 66); candidate.rest_duration_seconds = get_u32(payload + 70); } + if (version == kCurrentSettingsVersion) { + candidate.session_trigger = static_cast(payload[74]); + } + // Everything before v6 predates the trigger, so it defaults to manual, which is what + // those devices were doing. if (payload[12] > 1 || (version >= 3 && payload[62] > 1) || (version >= 4 && (payload[63] > static_cast(LapBoundaryMode::finish) || payload[64] > 1 || payload[65] > 1)) || + (version == kCurrentSettingsVersion && + payload[74] > static_cast(SessionTrigger::gps)) || !valid_settings(candidate)) { return DecodeResult::corrupt; } @@ -325,6 +348,7 @@ DecodeResult decode_settings(const SettingsBlob& blob, DeviceSettings& settings) return version == 2 ? DecodeResult::migrated_v2 : version == 3 ? DecodeResult::migrated_v3 : version == 4 ? DecodeResult::migrated_v4 + : version == 5 ? DecodeResult::migrated_v5 : DecodeResult::current; } @@ -375,6 +399,9 @@ SettingsLoadReport SettingsManager::load() noexcept case DecodeResult::migrated_v4: current_ = decoded; return {SettingsSource::migrated_v4, persist(current_)}; + case DecodeResult::migrated_v5: + current_ = decoded; + return {SettingsSource::migrated_v5, persist(current_)}; case DecodeResult::corrupt: current_ = {}; return {SettingsSource::defaults_corrupt, persist(current_)}; diff --git a/firmware/components/settings/include/track_timer/settings/settings.hpp b/firmware/components/settings/include/track_timer/settings/settings.hpp index 6600d30..22cf7ca 100644 --- a/firmware/components/settings/include/track_timer/settings/settings.hpp +++ b/firmware/components/settings/include/track_timer/settings/settings.hpp @@ -7,7 +7,7 @@ namespace track_timer::settings { -inline constexpr std::uint16_t kCurrentSettingsVersion = 5; +inline constexpr std::uint16_t kCurrentSettingsVersion = 6; inline constexpr std::size_t kSettingsBlobCapacity = 128; inline constexpr std::size_t kTrackIdentifierCapacity = 48; @@ -29,6 +29,13 @@ enum class LowerDisplayMode : std::uint8_t { laps_remaining, }; +// What starts a session. Manual is the button; the other two arm the timer and wait. +enum class SessionTrigger : std::uint8_t { + manual, + imu, + gps, +}; + enum class LapBoundaryMode : std::uint8_t { start, finish, @@ -52,6 +59,10 @@ struct DeviceSettings { LapBoundaryMode lap_boundary{LapBoundaryMode::finish}; bool pit_exit_auto_start_enabled{false}; bool pit_entry_auto_stop_enabled{false}; + // Appended, like every field before it: this struct is aggregate-initialised at call + // sites. Owns pit_exit_auto_start_enabled, which is derived from it rather than + // edited separately, so the two cannot disagree. + SessionTrigger session_trigger{SessionTrigger::manual}; }; struct LegacySettingsV1 { @@ -86,6 +97,7 @@ enum class DecodeResult : std::uint8_t { migrated_v2, migrated_v3, migrated_v4, + migrated_v5, corrupt, unsupported_version, }; @@ -96,6 +108,7 @@ enum class SettingsSource : std::uint8_t { migrated_v2, migrated_v3, migrated_v4, + migrated_v5, defaults_missing, defaults_corrupt, defaults_unsupported, @@ -146,6 +159,8 @@ struct FeatureAvailability { const LegacySettingsV1& settings) noexcept; [[nodiscard]] SettingsBlob encode_legacy_settings_v2( const DeviceSettings& settings) noexcept; +[[nodiscard]] SettingsBlob encode_legacy_settings_v5( + const DeviceSettings& settings) noexcept; [[nodiscard]] SettingsBlob encode_legacy_settings_v4( const DeviceSettings& settings) noexcept; [[nodiscard]] SettingsBlob encode_legacy_settings_v3( diff --git a/firmware/components/ui/CMakeLists.txt b/firmware/components/ui/CMakeLists.txt index 3632a64..b9902a5 100644 --- a/firmware/components/ui/CMakeLists.txt +++ b/firmware/components/ui/CMakeLists.txt @@ -2,7 +2,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" "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" "g_meter_screen.cpp" "g_radar_screen.cpp" "imu_meter.cpp" "lvgl_visual_system.cpp" "navigation.cpp" - "presenter.cpp" "ready_screen.cpp" "rest_session.cpp" "session_review.cpp" "session_review_screen.cpp" + "presenter.cpp" "ready_screen.cpp" "rest_session.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" INCLUDE_DIRS "include" diff --git a/firmware/components/ui/active_session.cpp b/firmware/components/ui/active_session.cpp index c355439..9e46db3 100644 --- a/firmware/components/ui/active_session.cpp +++ b/firmware/components/ui/active_session.cpp @@ -156,6 +156,8 @@ std::uint32_t urgency_rgb(const SessionUrgency urgency) noexcept const char* running_phase_caption(const RunningPhase phase) noexcept { switch (phase) { + case RunningPhase::pending: + return "PENDING"; case RunningPhase::overrun: return "OVER RUN"; case RunningPhase::rest: @@ -168,7 +170,16 @@ const char* running_phase_caption(const RunningPhase phase) noexcept std::uint32_t trackday_rgb(const RunningPhase phase, const SessionUrgency urgency) noexcept { - return phase == RunningPhase::overrun ? kOverrunRgb : urgency_rgb(urgency); + switch (phase) { + case RunningPhase::pending: + return kPendingRgb; + case RunningPhase::overrun: + return kOverrunRgb; + case RunningPhase::session: + case RunningPhase::rest: + break; + } + return urgency_rgb(urgency); } const char* session_urgency_name(const SessionUrgency urgency) noexcept @@ -215,7 +226,22 @@ void ActiveSessionController::update(const domain::UiSnapshot& snapshot, view_.feedback = {}; view_.trackday.visible = true; view_.trackday.phase = display.phase; + view_.trackday.detail[0] = '\0'; switch (display.phase) { + case RunningPhase::pending: { + // The whole session is still ahead, so the bar is full and the clock holds at + // the configured duration rather than counting anything. + format_running_clock( + view_.trackday.countdown, + static_cast(display.session_duration_seconds) * 1'000); + format_estimated_laps(view_.trackday, domain::kUnavailableTime, + display.average_lap_seconds); + view_.trackday.remaining_ratio = 1.0F; + view_.trackday.urgency = SessionUrgency::ample; + std::snprintf(view_.trackday.detail.data(), view_.trackday.detail.size(), "%s", + display.pending_detail == nullptr ? "" : display.pending_detail); + break; + } case RunningPhase::overrun: { // Counting up, so there is no proportion left to show and the bar is spent. format_running_clock(view_.trackday.countdown, display.overrun_ms); diff --git a/firmware/components/ui/include/track_timer/ui/active_session.hpp b/firmware/components/ui/include/track_timer/ui/active_session.hpp index cba0543..f18b5dc 100644 --- a/firmware/components/ui/include/track_timer/ui/active_session.hpp +++ b/firmware/components/ui/include/track_timer/ui/active_session.hpp @@ -48,6 +48,9 @@ struct StopControlViewModel { // Which clock the running screen is showing. The session counts down, the overrun counts // up past zero, and rest counts down again through the configured break. enum class RunningPhase : std::uint8_t { + // Armed and waiting for a trigger. The clock shows the configured duration and does + // not move, because nothing has started yet. + pending, session, overrun, rest, @@ -68,6 +71,9 @@ struct ActiveSessionDisplayConfig { std::int64_t overrun_ms{0}; std::int64_t rest_remaining_ms{0}; std::uint32_t rest_duration_seconds{0}; + // What a pending session is waiting for. Owned by the caller, which is the only thing + // that knows whether the IMU has calibrated or a receiver exists. + const char* pending_detail{""}; }; // Urgency bands for the countdown. Named rather than raw colours so the thresholds are @@ -84,6 +90,8 @@ enum class SessionUrgency : std::uint8_t { struct TrackdayModeViewModel { std::array countdown{}; RunningPhase phase{RunningPhase::session}; + // What a pending session is waiting for, or why it cannot arm. + std::array detail{}; std::array estimated_laps{}; // 1.0 at the start of a session falling to 0.0 at its end, for the decaying bar. float remaining_ratio{0.0F}; @@ -112,6 +120,9 @@ struct TrackdayModeViewModel { // literal "deep purple" #6A0DAD manages 2.27:1, where every other state here sits between // 5.9 and 10.5, and a 10 mm numeral at 2.27:1 is hard work in daylight. inline constexpr std::uint32_t kOverrunRgb = 0x9A4DFF; +// Pending is not a countdown, so it does not take a point on the ramp: green would read as +// "plenty of time left" on a clock that is not running. +inline constexpr std::uint32_t kPendingRgb = 0x39B6FF; [[nodiscard]] std::uint32_t trackday_rgb(RunningPhase phase, SessionUrgency urgency) noexcept; [[nodiscard]] const char* session_urgency_name(SessionUrgency urgency) noexcept; diff --git a/firmware/components/ui/include/track_timer/ui/session_trigger.hpp b/firmware/components/ui/include/track_timer/ui/session_trigger.hpp new file mode 100644 index 0000000..230f4f7 --- /dev/null +++ b/firmware/components/ui/include/track_timer/ui/session_trigger.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include "track_timer/settings/settings.hpp" + +#include +#include + +namespace track_timer::ui { + +inline constexpr std::size_t kSessionTriggerCount = 3; + +// Why a trigger owns pit-exit auto-start rather than sitting beside it: +// +// `pit_exit_auto_start_enabled` already meant "start the session when the car leaves the +// pits", with tested automation behind it. GPS is the same intent, so the trigger writes +// that setting and it leaves the device settings list. Offering one behaviour in two +// places invites the two disagreeing, which is the rule Mode established. +void apply_trigger(settings::SessionTrigger trigger, + settings::DeviceSettings& settings) noexcept; +[[nodiscard]] settings::SessionTrigger trigger_from_settings( + const settings::DeviceSettings& settings) noexcept; + +[[nodiscard]] const char* session_trigger_name(settings::SessionTrigger trigger) noexcept; +[[nodiscard]] const char* session_trigger_label(settings::SessionTrigger trigger) noexcept; +[[nodiscard]] const char* session_trigger_summary(settings::SessionTrigger trigger) noexcept; + +// Whether a trigger can arm at all, and what to say when it cannot. A trigger the driver +// has selected but that cannot fire is worse than no trigger: the device simply sits +// there. Both cases are reportable rather than silent. +struct TriggerReadiness { + bool can_arm{true}; + const char* detail{""}; +}; + +// `launch_milli_g` is the LAUNCH setting; zero means off, which with the IMU trigger is a +// session that can never start. `gnss_available` is false until a receiver exists. +[[nodiscard]] TriggerReadiness trigger_readiness(settings::SessionTrigger trigger, + std::uint16_t launch_milli_g, + bool imu_calibrated, + bool gnss_available) noexcept; + +// True when forward acceleration has reached the configured launch threshold. Forward +// only: a launch is acceleration down the road, so braking, cornering and kerbs cannot +// start a session. Never true at a zero threshold, which means off. +[[nodiscard]] bool launch_detected(float longitudinal_g, std::uint16_t launch_milli_g) noexcept; + +} // namespace track_timer::ui diff --git a/firmware/components/ui/include/track_timer/ui/shell_navigation.hpp b/firmware/components/ui/include/track_timer/ui/shell_navigation.hpp index 4075170..0377f58 100644 --- a/firmware/components/ui/include/track_timer/ui/shell_navigation.hpp +++ b/firmware/components/ui/include/track_timer/ui/shell_navigation.hpp @@ -28,11 +28,14 @@ enum class ShellLevel : std::uint8_t { enum class MenuItem : std::uint8_t { mode, track, + // What starts a session. Sits with the track because both are set at the circuit, + // before going out, rather than buried under Setup with the rarely-touched options. + trigger, setup, review, diagnostics, }; -inline constexpr std::size_t kMenuItemCount = 5; +inline constexpr std::size_t kMenuItemCount = 6; // Mode and Setup each offer three children; the track list is however many are on the // card, so the section count is set by the caller. @@ -53,7 +56,8 @@ enum class ShellOutcome : std::uint8_t { refused_session_active, mode_selected, // a Mode was chosen; the caller persists it track_selected, // a track was chosen; the caller loads and applies it - value_selected, // a setting value was chosen; the caller applies it + value_selected, // a setting value was chosen; the caller applies it + trigger_selected, // a session trigger was chosen; the caller persists it }; struct ShellState { @@ -88,6 +92,9 @@ class ShellNavigation { // Places the cursor on the value the setting already holds, so opening a field shows // the current choice rather than always starting at the first one. + // Opens a carousel on the item currently in force rather than the first one. Without + // it a menu cannot tell the driver what is set, only let them change it. + void select_section(std::size_t index) noexcept; void select_value(std::size_t index) noexcept; // A live session owns the screen, matching the existing rule that Setup is diff --git a/firmware/components/ui/include/track_timer/ui/value_picker.hpp b/firmware/components/ui/include/track_timer/ui/value_picker.hpp index 39ac3da..99761c4 100644 --- a/firmware/components/ui/include/track_timer/ui/value_picker.hpp +++ b/firmware/components/ui/include/track_timer/ui/value_picker.hpp @@ -28,13 +28,13 @@ struct ValueChoiceList { // The fields the gesture UI offers. operating_mode and trackday_mode are deliberately // absent: they are owned by the top-level Mode selection, and offering the same state in // two places invites the two disagreeing. -inline constexpr std::array kPickerFields{ +inline constexpr std::array kPickerFields{ SettingsField::session_duration, SettingsField::rest_duration, SettingsField::average_lap, SettingsField::lower_display, - SettingsField::lap_boundary, SettingsField::pit_exit_auto_start, - SettingsField::pit_entry_auto_stop, SettingsField::launch_sensitivity, - SettingsField::day_brightness, SettingsField::night_brightness, - SettingsField::auto_dim, SettingsField::orientation, + SettingsField::lap_boundary, SettingsField::pit_entry_auto_stop, + SettingsField::launch_sensitivity, SettingsField::day_brightness, + SettingsField::night_brightness, SettingsField::auto_dim, + SettingsField::orientation, }; [[nodiscard]] ValueChoiceList choices_for(SettingsField field, diff --git a/firmware/components/ui/session_trigger.cpp b/firmware/components/ui/session_trigger.cpp new file mode 100644 index 0000000..6a95e10 --- /dev/null +++ b/firmware/components/ui/session_trigger.cpp @@ -0,0 +1,94 @@ +#include "track_timer/ui/session_trigger.hpp" + +namespace track_timer::ui { + +void apply_trigger(const settings::SessionTrigger trigger, + settings::DeviceSettings& settings) noexcept +{ + settings.session_trigger = trigger; + // The trigger is the only thing that writes this now, so the tested gate automation + // keeps reading the boolean it always has without knowing a trigger exists. + settings.pit_exit_auto_start_enabled = trigger == settings::SessionTrigger::gps; +} + +settings::SessionTrigger trigger_from_settings( + const settings::DeviceSettings& settings) noexcept +{ + return settings.session_trigger; +} + +const char* session_trigger_name(const settings::SessionTrigger trigger) noexcept +{ + switch (trigger) { + case settings::SessionTrigger::manual: + return "manual"; + case settings::SessionTrigger::imu: + return "imu"; + case settings::SessionTrigger::gps: + return "gps"; + } + return "manual"; +} + +const char* session_trigger_label(const settings::SessionTrigger trigger) noexcept +{ + switch (trigger) { + case settings::SessionTrigger::manual: + return "MANUAL"; + case settings::SessionTrigger::imu: + return "IMU"; + case settings::SessionTrigger::gps: + return "GPS"; + } + return "MANUAL"; +} + +const char* session_trigger_summary(const settings::SessionTrigger trigger) noexcept +{ + switch (trigger) { + case settings::SessionTrigger::manual: + return "START BEGINS THE SESSION"; + case settings::SessionTrigger::imu: + return "STARTS ON LAUNCH"; + case settings::SessionTrigger::gps: + return "STARTS AT THE LINE"; + } + return "START BEGINS THE SESSION"; +} + +TriggerReadiness trigger_readiness(const settings::SessionTrigger trigger, + const std::uint16_t launch_milli_g, + const bool imu_calibrated, + const bool gnss_available) noexcept +{ + switch (trigger) { + case settings::SessionTrigger::manual: + return {true, ""}; + case settings::SessionTrigger::imu: + if (launch_milli_g == 0) { + // Otherwise the device waits for a threshold that cannot be reached, with + // nothing on screen to say why. + return {false, "SET LAUNCH G TO ARM"}; + } + if (!imu_calibrated) { + return {false, "WAITING FOR IMU CALIBRATION"}; + } + return {true, "WAITING FOR LAUNCH"}; + case settings::SessionTrigger::gps: + if (!gnss_available) { + return {false, "GPS UNAVAILABLE"}; + } + return {true, "WAITING FOR THE LINE"}; + } + return {true, ""}; +} + +bool launch_detected(const float longitudinal_g, const std::uint16_t launch_milli_g) noexcept +{ + if (launch_milli_g == 0) { + return false; + } + return longitudinal_g * 1000.0F >= static_cast(launch_milli_g); +} + +} // namespace track_timer::ui diff --git a/firmware/components/ui/shell_navigation.cpp b/firmware/components/ui/shell_navigation.cpp index 3c444dd..2ede9bc 100644 --- a/firmware/components/ui/shell_navigation.cpp +++ b/firmware/components/ui/shell_navigation.cpp @@ -25,6 +25,7 @@ namespace { return NavigationAction::open_diagnostics; case MenuItem::mode: case MenuItem::track: + case MenuItem::trigger: break; } return NavigationAction::back; @@ -83,7 +84,7 @@ void ShellNavigation::enter(ShellResult& result) noexcept const auto item = menu_item(); state_.section_index = 0; if (item == MenuItem::mode || item == MenuItem::track || - item == MenuItem::setup) { + item == MenuItem::trigger || item == MenuItem::setup) { state_.level = ShellLevel::section; result.outcome = ShellOutcome::entered; if (item == MenuItem::setup) { @@ -109,6 +110,11 @@ void ShellNavigation::enter(ShellResult& result) noexcept result.outcome = ShellOutcome::track_selected; return; } + if (menu_item() == MenuItem::trigger) { + // Choosing a trigger is the whole interaction, as choosing a mode is. + result.outcome = ShellOutcome::trigger_selected; + return; + } if (state_.section_index == static_cast(SetupItem::device_settings)) { state_.level = ShellLevel::field; state_.field_index = 0; @@ -221,6 +227,11 @@ void ShellNavigation::set_value_count(const std::size_t count) noexcept } } +void ShellNavigation::select_section(const std::size_t index) noexcept +{ + state_.section_index = index < section_count_ ? index : 0; +} + void ShellNavigation::select_value(const std::size_t index) noexcept { state_.value_index = index < value_count_ ? index : 0; @@ -288,6 +299,8 @@ const char* shell_outcome_name(const ShellOutcome outcome) noexcept return "track-selected"; case ShellOutcome::value_selected: return "value-selected"; + case ShellOutcome::trigger_selected: + return "trigger-selected"; } return "unknown"; } @@ -299,6 +312,8 @@ const char* menu_item_name(const MenuItem item) noexcept return "mode"; case MenuItem::track: return "track"; + case MenuItem::trigger: + return "trigger"; case MenuItem::setup: return "setup"; case MenuItem::review: diff --git a/firmware/components/ui/trackday_screen.cpp b/firmware/components/ui/trackday_screen.cpp index 7a2c287..2fd9040 100644 --- a/firmware/components/ui/trackday_screen.cpp +++ b/firmware/components/ui/trackday_screen.cpp @@ -156,9 +156,13 @@ void TrackdayScreen::update(const TrackdayModeViewModel& model) noexcept 0); } - lv_label_set_text(status_label_, - model.phase == RunningPhase::session ? mode_note_ - : "DOUBLE TAP TO CONTINUE"); + // Pending carries its own detail - what is being waited for - since "PENDING" alone + // does not separate a launch that has not happened from a receiver that does not exist. + const auto* footer = model.phase == RunningPhase::session ? mode_note_ + : model.phase == RunningPhase::pending + ? (model.detail[0] != '\0' ? model.detail.data() : mode_note_) + : "DOUBLE TAP TO CONTINUE"; + lv_label_set_text(status_label_, footer); lv_obj_set_style_text_color( status_label_, lv_color_hex(model.phase == RunningPhase::session ? kMuted : colour), 0); diff --git a/firmware/main/screen_router.cpp b/firmware/main/screen_router.cpp index 4f29039..14fc6d9 100644 --- a/firmware/main/screen_router.cpp +++ b/firmware/main/screen_router.cpp @@ -24,6 +24,7 @@ #include "track_timer/ui/presenter.hpp" #include "track_timer/ui/ready_screen.hpp" #include "track_timer/ui/rest_session.hpp" +#include "track_timer/ui/session_trigger.hpp" #include "track_timer/ui/session_review.hpp" #include "track_timer/ui/session_review_screen.hpp" #include "track_timer/ui/setup_menu_screen.hpp" @@ -150,10 +151,21 @@ constexpr std::uint32_t kRuby = 0xFF8FA3; constexpr ui::CarouselEntry kMenuEntries[] = { {LV_SYMBOL_POWER, "MODE", kRuby}, {LV_SYMBOL_GPS, "TRACK", kAzure}, + {LV_SYMBOL_CHARGE, "TRIGGER", kGreen}, {LV_SYMBOL_SETTINGS, "SETUP", kAmber}, {LV_SYMBOL_LIST, "REVIEW", kAzure}, {LV_SYMBOL_EYE_OPEN, "DIAGNOSTICS", kGreen}, }; +static_assert(std::size(kMenuEntries) == ui::kMenuItemCount, + "the carousel must offer exactly the items the shell can select"); + +// Manual first: it is what the device did before a trigger existed, and what a driver +// falls back to when a trigger cannot arm. +constexpr ui::CarouselEntry kTriggerEntries[] = { + {LV_SYMBOL_PLAY, "MANUAL", kGreen}, + {LV_SYMBOL_CHARGE, "IMU", kAmber}, + {LV_SYMBOL_GPS, "GPS", kAzure}, +}; // Track Day is first: it is the safe default, since it withholds the live lap times that // many track-day regulations prohibit. @@ -230,7 +242,15 @@ class ScreenRouter { session_was_active_ = active; lv_screen_load(active ? trackday_root_ : home_screen()); } - if (!active || trackday_ == nullptr) { + if (!active) { + // Armed but not started: the screen stays, and the detail is re-evaluated + // because what it is waiting for can change - the IMU finishes calibrating. + if (armed_) { + update_armed(); + } + return; + } + if (trackday_ == nullptr) { return; } @@ -282,6 +302,17 @@ class ScreenRouter { input.calibrating = input.sample_available && calibration_.state() != imu::CalibrationState::ready; + // Forward acceleration only: a launch is acceleration down the road, so braking, + // cornering and kerbs cannot start a session. + if (armed_ && armed_ready_ && armed_trigger_ == settings::SessionTrigger::imu && + resolved.valid && + ui::launch_detected(resolved.longitudinal_g, + settings_.launch_sensitivity_milli_g)) { + ESP_LOGI("track_timer", "launch detected at %d milli-g", + static_cast(resolved.longitudinal_g * 1000.0F)); + begin_session(); + } + // The meter divides its input by g and applies a display rotation, so the // resolved values are handed back in m/s2 on the axes it expects. board::ImuSample resolved_sample{}; @@ -371,7 +402,7 @@ class ScreenRouter { carousel_ = new (carousel_storage_) ui::CarouselScreen(carousel_root_, on_input, this); - carousel_->set_entries(kMenuEntries, 4); + carousel_->set_entries(kMenuEntries, ui::kMenuItemCount); // A hold anywhere on the Ready dashboard opens the menu; the carousel screen // takes swipes, presses and the back gesture. @@ -438,8 +469,89 @@ class ScreenRouter { // Only Track Day has a running-session screen so far. Race and G-Only still need // their own layouts, tracked on #132. + // START begins the session outright only under MANUAL. The other triggers arm the + // timer and show it pending, so the driver can see the device is ready and waiting + // rather than wondering whether the press registered. void start_session() noexcept { + const auto trigger = ui::trigger_from_settings(settings_); + if (trigger != settings::SessionTrigger::manual) { + arm_session(trigger); + return; + } + begin_session(); + } + + void arm_session(const settings::SessionTrigger trigger) noexcept + { + const auto readiness = ui::trigger_readiness( + trigger, settings_.launch_sensitivity_milli_g, + calibration_.state() == imu::CalibrationState::ready, gnss_available()); + armed_trigger_ = trigger; + armed_ = true; + armed_ready_ = readiness.can_arm; + pending_detail_ = readiness.detail; + ESP_LOGI("track_timer", "armed on %s: %s (%s)", ui::session_trigger_name(trigger), + readiness.can_arm ? "waiting" : "cannot arm", readiness.detail); + describe_mode(); + refresh_pending(); + lv_screen_load(trackday_root_); + } + + void disarm_session() noexcept + { + if (!armed_) { + return; + } + armed_ = false; + armed_ready_ = false; + pending_detail_ = ""; + ESP_LOGI("track_timer", "disarmed"); + lv_screen_load(home_screen()); + } + + // There is no receiver and no driver yet, so the GPS trigger arms and waits on a + // crossing that cannot arrive. The path from the gate automation to begin_session is + // built and reachable, so landing GNSS lights it up rather than needing this again. + [[nodiscard]] static bool gnss_available() noexcept { return false; } + + void refresh_pending() noexcept + { + if (!armed_ || trackday_ == nullptr) { + return; + } + domain::UiSnapshot ui_snapshot{}; + ui_snapshot.session_active = true; + ui_snapshot.session_remaining_ms = + static_cast(settings_.session_duration_seconds) * 1'000; + + ui::ActiveSessionDisplayConfig display{}; + display.average_lap_seconds = settings_.average_lap_seconds; + display.trackday_mode_enabled = true; + display.session_duration_seconds = settings_.session_duration_seconds; + display.rest_duration_seconds = settings_.rest_duration_seconds; + display.phase = ui::RunningPhase::pending; + display.pending_detail = pending_detail_; + + active_session_.update(ui_snapshot, + static_cast(esp_timer_get_time() / 1000), + display); + trackday_->update(active_session_.view_model().trackday); + } + + void update_armed() noexcept + { + const auto readiness = ui::trigger_readiness( + armed_trigger_, settings_.launch_sensitivity_milli_g, + calibration_.state() == imu::CalibrationState::ready, gnss_available()); + armed_ready_ = readiness.can_arm; + pending_detail_ = readiness.detail; + refresh_pending(); + } + + void begin_session() noexcept + { + armed_ = false; const auto now_ms = static_cast(esp_timer_get_time() / 1000); session::SessionConfiguration configuration{}; configuration.session_duration_ms = @@ -454,23 +566,30 @@ class ScreenRouter { const auto started = session_.start(now_ms); ESP_LOGI("track_timer", "session start: %d over %u s", static_cast(started), static_cast(settings_.session_duration_seconds)); - if (trackday_ != nullptr) { - trackday_->set_track_name(active_track_.name[0] != '\0' - ? active_track_.name.data() - : "TIMER ONLY"); - switch (ui::mode_from_settings(settings_)) { - case ui::DeviceMode::track_day: - trackday_->set_mode_note("TRACK DAY - lap times available in Review"); - break; - case ui::DeviceMode::race: - // Race wants lap times and a delta alongside the countdown. Neither - // exists without GNSS, so it borrows the countdown and says so. - trackday_->set_mode_note("RACE - lap times pending GNSS"); - break; - case ui::DeviceMode::g_only: - trackday_->set_mode_note("G-ONLY - session timer"); - break; - } + describe_mode(); + } + + // The track name and mode note are the same whether the session is running or waiting + // to be triggered, so both paths set them from here. + void describe_mode() noexcept + { + if (trackday_ == nullptr) { + return; + } + trackday_->set_track_name(active_track_.name[0] != '\0' ? active_track_.name.data() + : "TIMER ONLY"); + switch (ui::mode_from_settings(settings_)) { + case ui::DeviceMode::track_day: + trackday_->set_mode_note("TRACK DAY - lap times available in Review"); + break; + case ui::DeviceMode::race: + // Race wants lap times and a delta alongside the countdown. Neither exists + // without GNSS, so it borrows the countdown and says so. + trackday_->set_mode_note("RACE - lap times pending GNSS"); + break; + case ui::DeviceMode::g_only: + trackday_->set_mode_note("G-ONLY - session timer"); + break; } } @@ -599,7 +718,20 @@ class ScreenRouter { shell_.set_section_count(count == 0 ? 1 : count); carousel_->set_entries(track_entries_.data(), count); carousel_->set_title(count == 0 ? "NO TRACKS ON CARD" : "TRACK"); - carousel_->set_position(state.section_index); + // Matched by identifier rather than by a remembered position: the catalog is + // rebuilt from the card at boot, so a position only means anything until the card + // changes. A circuit no longer on the card falls back to the first entry. + std::size_t selected = 0; + for (std::size_t index = 0; index < count; ++index) { + if (std::strncmp(view.definitions[index].track_id.data(), + settings_.selected_track_id.data(), + settings_.selected_track_id.size()) == 0) { + selected = index; + break; + } + } + shell_.select_section(selected); + carousel_->set_position(selected); } // Reads the chosen definition from the card, parses it and applies it to the timing @@ -640,7 +772,7 @@ class ScreenRouter { void show_menu(const ui::ShellState& state) noexcept { - carousel_->set_entries(kMenuEntries, 4); + carousel_->set_entries(kMenuEntries, ui::kMenuItemCount); carousel_->set_title("MENU"); carousel_->set_position(state.menu_index); } @@ -652,14 +784,30 @@ class ScreenRouter { return; } shell_.set_section_count(ui::kSectionItemCount); - const auto mode = shell_.menu_item() == ui::MenuItem::mode; - carousel_->set_entries(mode ? kModeEntries : kSetupEntries, 3); - // Showing the live mode in the title means the driver can see what is selected + const auto item = shell_.menu_item(); + const auto* entries = item == ui::MenuItem::mode ? kModeEntries + : item == ui::MenuItem::trigger ? kTriggerEntries + : kSetupEntries; + carousel_->set_entries(entries, 3); + // Showing the live selection in the title means the driver can see what is set // before changing it, rather than having to remember. - carousel_->set_title(mode ? ui::device_mode_label( - ui::mode_from_settings(settings_)) - : "SETUP"); - carousel_->set_position(state.section_index); + carousel_->set_title( + item == ui::MenuItem::mode + ? ui::device_mode_label(ui::mode_from_settings(settings_)) + : item == ui::MenuItem::trigger + ? ui::session_trigger_label(ui::trigger_from_settings(settings_)) + : "SETUP"); + // Open on what is in force. Setup has no "current" child, so it keeps wherever the + // driver last was. + if (item == ui::MenuItem::mode) { + shell_.select_section( + static_cast(ui::mode_from_settings(settings_))); + } + else if (item == ui::MenuItem::trigger) { + shell_.select_section( + static_cast(ui::trigger_from_settings(settings_))); + } + carousel_->set_position(shell_.state().section_index); } void show_fields(const ui::ShellState& state) noexcept @@ -846,6 +994,10 @@ class ScreenRouter { } // While a clock is running the double tap belongs to the session, not the shell, // which refuses to open a menu then anyway. + if (armed_ && action == ui::InputAction::double_tap) { + disarm_session(); + return; + } if (session_was_active_ && action == ui::InputAction::double_tap) { advance_session_phase(); return; @@ -887,6 +1039,16 @@ class ScreenRouter { shell_.close(); lv_screen_load(home_screen()); break; + case ui::ShellOutcome::trigger_selected: { + const auto chosen = + static_cast(result.state.section_index); + ui::apply_trigger(chosen, settings_); + persist_settings(); + ESP_LOGI("track_timer", "trigger: %s", ui::session_trigger_name(chosen)); + shell_.close(); + lv_screen_load(home_screen()); + break; + } case ui::ShellOutcome::track_selected: apply_selected_track(result.state.section_index); shell_.close(); @@ -1026,6 +1188,10 @@ class ScreenRouter { session::SessionController session_{}; ui::ActiveSessionController active_session_{}; bool session_was_active_{false}; + bool armed_{false}; + bool armed_ready_{false}; + settings::SessionTrigger armed_trigger_{settings::SessionTrigger::manual}; + const char* pending_detail_{""}; ui::ImuMeterController imu_meter_{}; track::TrackDefinition active_track_{}; bool track_armed_{false}; diff --git a/tests/cpp/test_active_session.cpp b/tests/cpp/test_active_session.cpp index 5ff73c4..a80729c 100644 --- a/tests/cpp/test_active_session.cpp +++ b/tests/cpp/test_active_session.cpp @@ -226,6 +226,23 @@ int main() assert(view.urgency == ui::SessionUrgency::critical); } + // Armed and waiting: the clock holds at the configured duration and does not move, + // because nothing has started. The bar is full for the same reason. + config.phase = ui::RunningPhase::pending; + config.pending_detail = "WAITING FOR LAUNCH"; + phases.update(running, 500, config); + { + const auto& view = phases.view_model().trackday; + assert(std::strcmp(view.countdown.data(), "20:00") == 0); + assert(std::strcmp(ui::running_phase_caption(view.phase), "PENDING") == 0); + assert(std::strcmp(view.detail.data(), "WAITING FOR LAUNCH") == 0); + assert(view.remaining_ratio == 1.0F); + // Pending is not a countdown, so it must not borrow a point on the ramp: green + // would read as "plenty of time left" on a clock that is not running. + assert(ui::trackday_rgb(view.phase, view.urgency) == ui::kPendingRgb); + assert(ui::kPendingRgb != ui::urgency_rgb(ui::SessionUrgency::ample)); + } + // The overrun colour must not be mistakable for any point on the ramp. for (const auto urgency : {ui::SessionUrgency::ample, ui::SessionUrgency::easing, ui::SessionUrgency::closing, ui::SessionUrgency::urgent, diff --git a/tests/cpp/test_session_trigger.cpp b/tests/cpp/test_session_trigger.cpp new file mode 100644 index 0000000..19ab5d3 --- /dev/null +++ b/tests/cpp/test_session_trigger.cpp @@ -0,0 +1,138 @@ +#include "track_timer/ui/session_trigger.hpp" + +#include "track_timer/ui/value_picker.hpp" + +#include +#include +#include +#include + +namespace { + +using namespace track_timer; + +// The trigger owns pit-exit auto-start rather than sitting beside it. Offering one +// behaviour in two places invites the two disagreeing, which is the rule Mode set. +void the_trigger_owns_pit_exit_auto_start() +{ + settings::DeviceSettings settings{}; + assert(settings.session_trigger == settings::SessionTrigger::manual); + assert(!settings.pit_exit_auto_start_enabled); + + ui::apply_trigger(settings::SessionTrigger::gps, settings); + assert(ui::trigger_from_settings(settings) == settings::SessionTrigger::gps); + assert(settings.pit_exit_auto_start_enabled); + + ui::apply_trigger(settings::SessionTrigger::imu, settings); + assert(!settings.pit_exit_auto_start_enabled); + ui::apply_trigger(settings::SessionTrigger::manual, settings); + assert(!settings.pit_exit_auto_start_enabled); + + // Ending a session on pit entry is a different question from starting one, so it + // stays where it was. + settings.pit_entry_auto_stop_enabled = true; + ui::apply_trigger(settings::SessionTrigger::gps, settings); + assert(settings.pit_entry_auto_stop_enabled); +} + +void the_owned_setting_is_no_longer_offered_as_a_field() +{ + const auto& fields = ui::kPickerFields; + assert(std::find(fields.begin(), fields.end(), + ui::SettingsField::pit_exit_auto_start) == fields.end()); + // Its counterpart is untouched. + assert(std::find(fields.begin(), fields.end(), + ui::SettingsField::pit_entry_auto_stop) != fields.end()); + // And the threshold the IMU trigger reads is still reachable. + assert(std::find(fields.begin(), fields.end(), + ui::SettingsField::launch_sensitivity) != fields.end()); +} + +// A trigger the driver has selected but that cannot fire is worse than no trigger: the +// device simply sits there. Every case that cannot arm has to say why. +void a_trigger_that_cannot_fire_says_so() +{ + const auto manual = + ui::trigger_readiness(settings::SessionTrigger::manual, 0, false, false); + assert(manual.can_arm); + + // LAUNCH at zero means off, so the IMU trigger would wait forever. + const auto no_threshold = + ui::trigger_readiness(settings::SessionTrigger::imu, 0, true, false); + assert(!no_threshold.can_arm); + assert(std::strlen(no_threshold.detail) > 0); + + // Gravity has to be known before forward acceleration means anything. + const auto uncalibrated = + ui::trigger_readiness(settings::SessionTrigger::imu, 500, false, false); + assert(!uncalibrated.can_arm); + assert(std::strlen(uncalibrated.detail) > 0); + + const auto ready = ui::trigger_readiness(settings::SessionTrigger::imu, 500, true, false); + assert(ready.can_arm); + assert(std::strlen(ready.detail) > 0); // says what it is waiting for + + // There is no receiver yet, so GPS arms into a wait that cannot end. + const auto no_gnss = + ui::trigger_readiness(settings::SessionTrigger::gps, 500, true, false); + assert(!no_gnss.can_arm); + assert(std::strlen(no_gnss.detail) > 0); + assert(ui::trigger_readiness(settings::SessionTrigger::gps, 0, false, true).can_arm); +} + +// Forward acceleration only. Braking, cornering and kerbs must not start a session. +void a_launch_is_forward_acceleration() +{ + constexpr std::uint16_t kThreshold = 500; // 0.50 g + + assert(!ui::launch_detected(0.0F, kThreshold)); + assert(!ui::launch_detected(0.49F, kThreshold)); + assert(ui::launch_detected(0.50F, kThreshold)); + assert(ui::launch_detected(0.90F, kThreshold)); + + // Braking is longitudinal too, but the other way, and must never trigger. + assert(!ui::launch_detected(-0.90F, kThreshold)); + assert(!ui::launch_detected(-3.0F, kThreshold)); + + // Zero means off, so nothing fires however hard the car accelerates. + assert(!ui::launch_detected(4.0F, 0)); +} + +void every_trigger_is_named_and_labelled() +{ + for (const auto trigger : {settings::SessionTrigger::manual, settings::SessionTrigger::imu, + settings::SessionTrigger::gps}) { + assert(std::strlen(ui::session_trigger_name(trigger)) > 0); + assert(std::strlen(ui::session_trigger_label(trigger)) > 0); + assert(std::strlen(ui::session_trigger_summary(trigger)) > 0); + } + assert(std::strcmp(ui::session_trigger_label(settings::SessionTrigger::manual), + "MANUAL") == 0); + assert(std::strcmp(ui::session_trigger_label(settings::SessionTrigger::imu), "IMU") == 0); + assert(std::strcmp(ui::session_trigger_label(settings::SessionTrigger::gps), "GPS") == 0); +} + +// The carousel index is cast straight to the enum, so the order has to match. +void the_menu_order_matches_the_enum() +{ + assert(static_cast(settings::SessionTrigger::manual) == 0); + assert(static_cast(settings::SessionTrigger::imu) == 1); + assert(static_cast(settings::SessionTrigger::gps) == 2); + assert(ui::kSessionTriggerCount == 3); +} + +} // namespace + +int main() +{ + the_trigger_owns_pit_exit_auto_start(); + the_owned_setting_is_no_longer_offered_as_a_field(); + a_trigger_that_cannot_fire_says_so(); + a_launch_is_forward_acceleration(); + every_trigger_is_named_and_labelled(); + the_menu_order_matches_the_enum(); + + std::cout << "Session trigger: ownership of pit-exit auto-start, refusal to arm when " + "it cannot fire, and forward-only launch detection passed\n"; + return 0; +} diff --git a/tests/cpp/test_settings.cpp b/tests/cpp/test_settings.cpp index 6f089cb..b2d9dd8 100644 --- a/tests/cpp/test_settings.cpp +++ b/tests/cpp/test_settings.cpp @@ -59,6 +59,7 @@ DeviceSettings customized_settings() settings.lap_boundary = LapBoundaryMode::start; settings.pit_exit_auto_start_enabled = true; settings.pit_entry_auto_stop_enabled = true; + settings.session_trigger = SessionTrigger::imu; std::strcpy(settings.selected_track_id.data(), "synthetic-test-loop"); return settings; } @@ -83,6 +84,9 @@ void test_validation_and_codec() invalid.average_lap_seconds = 0; assert(!valid_settings(invalid)); invalid = settings; + invalid.session_trigger = static_cast(9); + assert(!valid_settings(invalid)); + invalid = settings; invalid.lap_boundary = static_cast(255); assert(!valid_settings(invalid)); assert(encode_settings(invalid).size == 0); @@ -177,6 +181,19 @@ void test_migration_corruption_and_storage_errors() assert(version_four.current().pit_exit_auto_start_enabled); assert(version_four.current().average_lap_seconds == 103); + MemorySettingsStore version_five_store; + version_five_store.found = true; + version_five_store.blob = encode_legacy_settings_v5(customized_settings()); + SettingsManager version_five{version_five_store}; + const auto version_five_migration = version_five.load(); + assert(version_five_migration.source == SettingsSource::migrated_v5); + assert(version_five_migration.current_format_persisted); + // v5 held the durations exactly, seconds and all. + assert(version_five.current().session_duration_seconds == 30 * 60 + 30); + // Nothing before v6 had a trigger, so it defaults to manual, which is what those + // devices were doing. + assert(version_five.current().session_trigger == SessionTrigger::manual); + MemorySettingsStore corrupt_store; corrupt_store.found = true; corrupt_store.blob = encode_settings(customized_settings()); diff --git a/tests/cpp/test_shell_navigation.cpp b/tests/cpp/test_shell_navigation.cpp index c8a8318..dd8bc63 100644 --- a/tests/cpp/test_shell_navigation.cpp +++ b/tests/cpp/test_shell_navigation.cpp @@ -35,14 +35,27 @@ void only_a_hold_opens_the_menu() assert(shell.menu_item() == MenuItem::mode); } -// Track sits between Mode and Setup: at a circuit it is the most frequently changed -// thing, so it must not be buried under Setup. +// Hops to a menu item by name. Counting swipes meant that inserting an item silently +// pointed these tests at a different one. +void to_menu(ShellNavigation& shell, const MenuItem item) +{ + for (std::size_t guard = 0; guard <= kMenuItemCount; ++guard) { + if (shell.menu_item() == item) { + return; + } + (void)shell.dispatch(InputAction::swipe_left); + } + assert(false && "menu item not reachable"); +} + +// Track and Trigger sit between Mode and Setup: at a circuit both are set before going +// out, so neither should be buried under Setup with the rarely-touched options. void the_menu_offers_mode_then_track_and_wraps() { auto shell = opened(); assert(shell.menu_item() == MenuItem::mode); - for (const auto expected : {MenuItem::track, MenuItem::setup, MenuItem::review, - MenuItem::diagnostics, MenuItem::mode}) { + for (const auto expected : {MenuItem::track, MenuItem::trigger, MenuItem::setup, + MenuItem::review, MenuItem::diagnostics, MenuItem::mode}) { (void)shell.dispatch(InputAction::swipe_left); assert(shell.menu_item() == expected); } @@ -108,9 +121,9 @@ void a_mode_is_three_gestures_from_the_dashboard() void review_and_diagnostics_hand_over_to_the_destination_model() { auto shell = opened(); - for (int i = 0; i < 3; ++i) { - (void)shell.dispatch(InputAction::swipe_left); // mode -> track -> setup -> review - } + // Swiping to the item by name rather than by a count, so inserting a menu item does + // not silently point this test at a different one. + to_menu(shell, MenuItem::review); auto result = shell.dispatch(InputAction::press); assert(result.emits_action && result.action == NavigationAction::open_review); assert(result.state.level == ShellLevel::menu); @@ -124,8 +137,7 @@ void review_and_diagnostics_hand_over_to_the_destination_model() void a_setting_value_is_reachable_by_descending_three_levels() { auto shell = opened(); - (void)shell.dispatch(InputAction::swipe_left); // TRACK - (void)shell.dispatch(InputAction::swipe_left); // SETUP + to_menu(shell, MenuItem::setup); auto result = shell.dispatch(InputAction::press); assert(result.state.level == ShellLevel::section); assert(result.emits_action && result.action == NavigationAction::open_setup); @@ -147,8 +159,7 @@ void a_setting_value_is_reachable_by_descending_three_levels() void every_level_climbs_back_out_one_at_a_time() { auto shell = opened(); - (void)shell.dispatch(InputAction::swipe_left); - (void)shell.dispatch(InputAction::swipe_left); + to_menu(shell, MenuItem::setup); (void)shell.dispatch(InputAction::press); (void)shell.dispatch(InputAction::press); shell.set_value_count(4); @@ -169,8 +180,7 @@ void every_level_climbs_back_out_one_at_a_time() void a_hold_anywhere_inside_the_menu_returns_to_the_timer() { auto shell = opened(); - (void)shell.dispatch(InputAction::swipe_left); - (void)shell.dispatch(InputAction::swipe_left); + to_menu(shell, MenuItem::setup); (void)shell.dispatch(InputAction::press); (void)shell.dispatch(InputAction::press); assert(shell.state().level == ShellLevel::field); @@ -202,8 +212,7 @@ void a_live_session_blocks_and_closes_the_menu() void changing_the_value_count_resets_an_out_of_range_index() { auto shell = opened(); - (void)shell.dispatch(InputAction::swipe_left); - (void)shell.dispatch(InputAction::swipe_left); + to_menu(shell, MenuItem::setup); (void)shell.dispatch(InputAction::press); (void)shell.dispatch(InputAction::press); shell.set_value_count(10); @@ -225,6 +234,45 @@ void names_are_stable_for_logging_and_replay() assert(std::strcmp(setup_item_name(SetupItem::track_selection), "track-selection") == 0); } +// A menu that always opens on its first item cannot tell the driver what is set, only let +// them change it. The value level already did this; the section level did not. +void a_section_opens_on_the_item_in_force() +{ + auto shell = opened(); + to_menu(shell, MenuItem::trigger); + (void)shell.dispatch(InputAction::press); + assert(shell.state().level == ShellLevel::section); + shell.set_section_count(3); + + // Entering resets to the first item, which is what the caller then corrects. + assert(shell.state().section_index == 0); + shell.select_section(2); + assert(shell.state().section_index == 2); + + // And it still moves from there rather than snapping back. + (void)shell.dispatch(InputAction::swipe_left); + assert(shell.state().section_index == 0); // wrapped past the end of three + (void)shell.dispatch(InputAction::swipe_right); + assert(shell.state().section_index == 2); +} + +// A stored track that is no longer on the card must not leave an index pointing past the +// end of a shorter catalog. +void an_out_of_range_section_falls_back_to_the_first() +{ + auto shell = opened(); + to_menu(shell, MenuItem::track); + (void)shell.dispatch(InputAction::press); + shell.set_section_count(3); + shell.select_section(7); + assert(shell.state().section_index == 0); + + // Shrinking the catalog under a valid selection has to be safe too. + shell.select_section(2); + shell.set_section_count(1); + assert(shell.state().section_index == 0); +} + } // namespace int main() @@ -242,6 +290,9 @@ int main() changing_the_value_count_resets_an_out_of_range_index(); names_are_stable_for_logging_and_replay(); + a_section_opens_on_the_item_in_force(); + an_out_of_range_section_falls_back_to_the_first(); + std::cout << "Hold gate, Mode selection, descending editor levels, and session lock " "passed\n"; return 0;