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;