diff --git a/CHANGELOG.md b/CHANGELOG.md index 969f344..2ac78fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,23 @@ The project follows Semantic Versioning once the first firmware release is tagge ### Added +- a **0-X** mode: the time from a standing start to a target speed, counting up from + `0.00`, stopping the instant the target is reached and turning green. Coming back to rest + re-arms it; a tap resets it sooner. The target is chosen in `SETUP` -> `0-X TARGET` +- both ends of a 0-X run are **interpolated rather than quantised**. A stationary receiver + reports a few centimetres per second of noise, not zero, so the start is estimated by + extrapolating back along the acceleration between the first two moving fixes to where + speed would have been zero; the finish is interpolated between the fixes straddling the + target. At 25 Hz a fix lands every 40 ms, so without both the second decimal would be + decoration. A 0-60 at 4 m/s2 measures 6.71 s against a true 6.7056 +- a 0-X run whose fixes dry up is **abandoned, not finished**. The number stops climbing + either way, which is the whole problem: a stalled run looks exactly like an arrival, so + it says `LOST FIX` and is never green +- the settings record is at **version 8**, carrying the 0-X target. Unlike appending a + mode, a new field changes a fixed-width record's length, so this needed a migration + rather than only a wider validation bound. A v7 device keeps every setting it had and + takes the default target, because it never had an opinion about one + - a **Compass** mode: a fixed card with north up, a marker travelling its rim, and the bearing in degrees at the centre. The heading was already decoded - `headMot` and `headAcc` have been reaching `GnssFix` every epoch - so this needed no parser work at all diff --git a/Makefile b/Makefile index e1bc8a1..d341b24 100644 --- a/Makefile +++ b/Makefile @@ -48,6 +48,7 @@ GPS_ONLY_TEST_BINARY := build/host/gps_only_test CLOCK_TEST_BINARY := build/host/clock_test SPEED_ONLY_TEST_BINARY := build/host/speed_only_test COMPASS_TEST_BINARY := build/host/compass_test +ACCELERATION_TEST_BINARY := build/host/acceleration_test IMU_CALIBRATION_TEST_BINARY := build/host/imu_calibration_test SESSION_URGENCY_TEST_BINARY := build/host/session_urgency_test TIME_ROLLER_TEST_BINARY := build/host/time_roller_test @@ -66,9 +67,9 @@ SIMULATOR_BUILD_DIR ?= build/simulator SIMULATOR_IMAGE ?= track-session-timer-simulator:lvgl-9.5.0 CMAKE ?= cmake -.PHONY: check venv python-env test track-validate uk-track-pack test-track-pack track-pack-test simulator-track-catalog-test simulator-fixture-validate repo-check host-test simulator-model-test session-state-test settings-test settings-editor-test projection-test intersection-test crossing-validation-test crossing-time-test lap-state-machine-test timing-engine-test gate-event-engine-test track-definition-test track-capture-test track-matching-test track-selection-test log-format-test async-logger-test session-review-test diagnostics-test active-session-test display-policy-test imu-meter-test rest-session-test track-catalog-test shell-navigation-test device-mode-test gps-only-test clock-test speed-only-test compass-test imu-calibration-test session-urgency-test time-roller-test session-trigger-test summary-store-test summary-file-test review-cards-test ubx-parser-test fix-validation-test gnss-pipeline-test gnss-configure-test i2c-transport-test gate-capture-test gate-session-automation-test ui-foundation-test navigation-test simulator-configure simulator-build simulator-test simulator-run simulator-container-image simulator-container-test simulator-clean firmware-build firmware-container-build firmware-container-flash firmware-container-monitor firmware-container-flash-monitor firmware-container-erase firmware-capture-boot firmware-device-info firmware-clean issue-preview label-preview +.PHONY: check venv python-env test track-validate uk-track-pack test-track-pack track-pack-test simulator-track-catalog-test simulator-fixture-validate repo-check host-test simulator-model-test session-state-test settings-test settings-editor-test projection-test intersection-test crossing-validation-test crossing-time-test lap-state-machine-test timing-engine-test gate-event-engine-test track-definition-test track-capture-test track-matching-test track-selection-test log-format-test async-logger-test session-review-test diagnostics-test active-session-test display-policy-test imu-meter-test rest-session-test track-catalog-test shell-navigation-test device-mode-test gps-only-test clock-test speed-only-test compass-test acceleration-test imu-calibration-test session-urgency-test time-roller-test session-trigger-test summary-store-test summary-file-test review-cards-test ubx-parser-test fix-validation-test gnss-pipeline-test gnss-configure-test i2c-transport-test gate-capture-test gate-session-automation-test ui-foundation-test navigation-test simulator-configure simulator-build simulator-test simulator-run simulator-container-image simulator-container-test simulator-clean firmware-build firmware-container-build firmware-container-flash firmware-container-monitor firmware-container-flash-monitor firmware-container-erase firmware-capture-boot firmware-device-info firmware-clean issue-preview label-preview -check: python-env test track-validate test-track-pack track-pack-test simulator-track-catalog-test simulator-fixture-validate repo-check host-test simulator-model-test session-state-test settings-test settings-editor-test projection-test intersection-test crossing-validation-test crossing-time-test lap-state-machine-test timing-engine-test gate-event-engine-test track-definition-test track-capture-test track-matching-test track-selection-test log-format-test async-logger-test session-review-test diagnostics-test active-session-test display-policy-test imu-meter-test rest-session-test track-catalog-test shell-navigation-test device-mode-test gps-only-test clock-test speed-only-test compass-test imu-calibration-test session-urgency-test time-roller-test session-trigger-test summary-store-test summary-file-test review-cards-test ubx-parser-test fix-validation-test gnss-pipeline-test gnss-configure-test i2c-transport-test gate-capture-test gate-session-automation-test ui-foundation-test navigation-test +check: python-env test track-validate test-track-pack track-pack-test simulator-track-catalog-test simulator-fixture-validate repo-check host-test simulator-model-test session-state-test settings-test settings-editor-test projection-test intersection-test crossing-validation-test crossing-time-test lap-state-machine-test timing-engine-test gate-event-engine-test track-definition-test track-capture-test track-matching-test track-selection-test log-format-test async-logger-test session-review-test diagnostics-test active-session-test display-policy-test imu-meter-test rest-session-test track-catalog-test shell-navigation-test device-mode-test gps-only-test clock-test speed-only-test compass-test acceleration-test imu-calibration-test session-urgency-test time-roller-test session-trigger-test summary-store-test summary-file-test review-cards-test ubx-parser-test fix-validation-test gnss-pipeline-test gnss-configure-test i2c-transport-test gate-capture-test gate-session-automation-test ui-foundation-test navigation-test venv: python3 -m venv .venv @@ -586,6 +587,15 @@ imu-calibration-test: tests/cpp/test_imu_calibration.cpp -o $(IMU_CALIBRATION_TEST_BINARY) $(IMU_CALIBRATION_TEST_BINARY) +acceleration-test: + mkdir -p build/host + $(CXX) -std=c++17 -Wall -Wextra -Werror -pedantic \ + -Ifirmware/components/domain/include \ + -Ifirmware/components/ui/include \ + firmware/components/ui/gps_only.cpp firmware/components/ui/acceleration.cpp \ + tests/cpp/test_acceleration.cpp -o $(ACCELERATION_TEST_BINARY) + $(ACCELERATION_TEST_BINARY) + compass-test: mkdir -p build/host $(CXX) -std=c++17 -Wall -Wextra -Werror -pedantic \ diff --git a/README.md b/README.md index c023c9c..f2e492a 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,8 @@ device loads them and runs timer-only. #184's test loop is the route to a first With nothing attached GPS Only reports "NO RECEIVER", which is the truth rather than a fault. -Three modes are built on those fixes. **Compass** shows course over ground on a fixed card, +Four modes are built on those fixes. **0-X** times a standing start to a target speed, +interpolating both ends rather than quantising them to the fix rate. **Compass** shows course over ground on a fixed card, and hides its marker entirely when the car is too slow for the receiver to know which way it is going - a parked vehicle has no heading, and the board has no magnetometer to supply one. **Speed Only** shows road speed in mph at 248 px - diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md index 9559b2d..75f68dc 100644 --- a/docs/SETTINGS.md +++ b/docs/SETTINGS.md @@ -6,13 +6,14 @@ file or NVS operations. ## Current schema -Version 4 stores: +Version 8 stores: - session and rest durations - launch sensitivity - average lap time and lower-display choice - day and night brightness presets -- operating mode (timer, G-meter, GPS Only, Clock, Speed Only or Compass) +- operating mode (timer, G-meter, GPS Only, Clock, Speed Only, Compass or 0-X) +- the 0-X target speed - Trackday Mode enable/disable state - repeating lap boundary (`START LINE` or `FINISH LINE`) - independent Pit Exit Auto-Start and Pit Entry Auto-Stop enable/disable states diff --git a/firmware/components/settings/component.cpp b/firmware/components/settings/component.cpp index f1a8540..104d59d 100644 --- a/firmware/components/settings/component.cpp +++ b/firmware/components/settings/component.cpp @@ -20,7 +20,8 @@ constexpr std::size_t kLegacyV5PayloadSize = 74; // v6 appends the session trigger, which nothing before it had. // v7 changed what the launch field may hold, not the shape of the payload. constexpr std::size_t kLegacyV6PayloadSize = 75; -constexpr std::size_t kCurrentPayloadSize = 75; +constexpr std::size_t kLegacyV7PayloadSize = 75; +constexpr std::size_t kCurrentPayloadSize = 76; inline constexpr std::uint32_t kMaximumDurationSeconds = 24U * 60U * 60U; inline constexpr std::uint32_t kMinimumSessionSeconds = 60U; @@ -157,6 +158,18 @@ std::uint16_t nearest_launch_sensitivity(const std::uint16_t milli_g) noexcept return best; } +[[nodiscard]] bool valid_acceleration_target(const std::uint8_t mph) noexcept +{ + // One of the offered speeds, not merely a plausible number. A record carrying a target + // the picker cannot reach is a record this firmware did not write. + for (const auto offered : kAccelerationTargetsMph) { + if (offered == mph) { + return true; + } + } + return false; +} + bool valid_settings(const DeviceSettings& settings) noexcept { return settings.session_duration_seconds >= kMinimumSessionSeconds && @@ -166,7 +179,8 @@ bool valid_settings(const DeviceSettings& settings) noexcept settings.average_lap_seconds <= 59 * 60 + 59 && valid_brightness(settings.day_brightness_percent) && valid_brightness(settings.night_brightness_percent) && - settings.operating_mode <= OperatingMode::compass && + settings.operating_mode <= OperatingMode::acceleration && + valid_acceleration_target(settings.acceleration_target_mph) && settings.orientation <= OrientationMode::automatic && settings.lower_display <= LowerDisplayMode::laps_remaining && (settings.average_lap_seconds > 0 || @@ -271,14 +285,26 @@ SettingsBlob encode_legacy_settings_v6(const DeviceSettings& settings) noexcept return make_blob(6, payload.data(), payload.size()); } -SettingsBlob encode_settings(const DeviceSettings& settings) noexcept +SettingsBlob encode_legacy_settings_v7(const DeviceSettings& settings) noexcept { const auto legacy = encode_legacy_settings_v6(settings); if (legacy.size == 0) { return {}; } - std::array payload{}; + std::array payload{}; std::copy_n(legacy.bytes.data() + kHeaderSize, kLegacyV6PayloadSize, payload.data()); + return make_blob(7, payload.data(), payload.size()); +} + +SettingsBlob encode_settings(const DeviceSettings& settings) noexcept +{ + const auto legacy = encode_legacy_settings_v7(settings); + if (legacy.size == 0) { + return {}; + } + std::array payload{}; + std::copy_n(legacy.bytes.data() + kHeaderSize, kLegacyV7PayloadSize, payload.data()); + payload[75] = settings.acceleration_target_mph; return make_blob(kCurrentSettingsVersion, payload.data(), payload.size()); } @@ -322,7 +348,7 @@ DecodeResult decode_settings(const SettingsBlob& blob, DeviceSettings& settings) return DecodeResult::migrated_v1; } if (version != 2 && version != 3 && version != 4 && version != 5 && version != 6 && - version != kCurrentSettingsVersion) { + version != 7 && version != kCurrentSettingsVersion) { return DecodeResult::unsupported_version; } const auto expected_payload_size = @@ -331,6 +357,7 @@ DecodeResult decode_settings(const SettingsBlob& blob, DeviceSettings& settings) : version == 4 ? kLegacyV4PayloadSize : version == 5 ? kLegacyV5PayloadSize : version == 6 ? kLegacyV6PayloadSize + : version == 7 ? kLegacyV7PayloadSize : kCurrentPayloadSize; if (payload_size != expected_payload_size) { return DecodeResult::corrupt; @@ -365,10 +392,20 @@ DecodeResult decode_settings(const SettingsBlob& blob, DeviceSettings& settings) if (version >= 6) { candidate.session_trigger = static_cast(payload[74]); } - if (version < kCurrentSettingsVersion) { + if (version >= 8) { + candidate.acceleration_target_mph = payload[75]; + } + // Anything older predates 0-X entirely, so it keeps the default rather than being + // treated as a device that chose it. A migrated record is a record that never had an + // opinion about this, not one whose opinion was zero. + if (version < 7) { // Before v7 the ladder ran to 4 g and offered zero as "off". Snapping keeps the // rest of the blob rather than failing validation over one field and taking every // unrelated setting back to defaults. + // + // Pinned to 7 rather than to kCurrentSettingsVersion: this corrects a v6-and-older + // ladder, and a record written by v7 already has a snapped value. Leaving it as + // "older than current" would have silently re-snapped every future version too. candidate.launch_sensitivity_milli_g = nearest_launch_sensitivity(candidate.launch_sensitivity_milli_g); } @@ -389,6 +426,7 @@ DecodeResult decode_settings(const SettingsBlob& blob, DeviceSettings& settings) : version == 4 ? DecodeResult::migrated_v4 : version == 5 ? DecodeResult::migrated_v5 : version == 6 ? DecodeResult::migrated_v6 + : version == 7 ? DecodeResult::migrated_v7 : DecodeResult::current; } @@ -445,6 +483,9 @@ SettingsLoadReport SettingsManager::load() noexcept case DecodeResult::migrated_v6: current_ = decoded; return {SettingsSource::migrated_v6, persist(current_)}; + case DecodeResult::migrated_v7: + current_ = decoded; + return {SettingsSource::migrated_v7, 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 0d78674..37ce907 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 = 7; +inline constexpr std::uint16_t kCurrentSettingsVersion = 8; inline constexpr std::size_t kSettingsBlobCapacity = 128; inline constexpr std::size_t kTrackIdentifierCapacity = 48; @@ -28,6 +28,8 @@ enum class OperatingMode : std::uint8_t { // A compass card from the receiver's course over ground. Appended again: the order of // this enum is the stored format, whatever order the modes are offered in. compass, + // Standing-start acceleration timing, to a target speed chosen in Setup. + acceleration, }; enum class OrientationMode : std::uint8_t { @@ -67,6 +69,11 @@ inline constexpr std::array kLaunchSensitivityMilliG{ // A deliberate pit exit, without tripping on a bump. inline constexpr std::uint16_t kDefaultLaunchSensitivityMilliG = 300; +// The speeds a 0-X run can be timed to. Sixty is the one everybody means, which is why it +// is the default; thirty is reachable in a car park and a hundred is a straight. +inline constexpr std::array kAccelerationTargetsMph{30, 40, 50, 60, 70, 100}; +inline constexpr std::uint8_t kDefaultAccelerationTargetMph = 60; + // Snaps any stored value onto the ladder. Settings written before v7 hold values that are // no longer offered, and rejecting them would fail the whole blob and take every unrelated // setting back to defaults with it. @@ -95,6 +102,11 @@ struct DeviceSettings { // 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}; + // The speed a 0-X run is timed to, in mph. Appended, like every field before it - but + // unlike appending a mode this is a new byte in a fixed-width record, so it is what + // took the format to version 8 and needed a migration path rather than only a wider + // validation bound. + std::uint8_t acceleration_target_mph{kDefaultAccelerationTargetMph}; }; struct LegacySettingsV1 { @@ -131,6 +143,7 @@ enum class DecodeResult : std::uint8_t { migrated_v4, migrated_v5, migrated_v6, + migrated_v7, corrupt, unsupported_version, }; @@ -143,6 +156,7 @@ enum class SettingsSource : std::uint8_t { migrated_v4, migrated_v5, migrated_v6, + migrated_v7, defaults_missing, defaults_corrupt, defaults_unsupported, @@ -193,6 +207,8 @@ struct FeatureAvailability { const LegacySettingsV1& settings) noexcept; [[nodiscard]] SettingsBlob encode_legacy_settings_v2( const DeviceSettings& settings) noexcept; +[[nodiscard]] SettingsBlob encode_legacy_settings_v7( + const DeviceSettings& settings) noexcept; [[nodiscard]] SettingsBlob encode_legacy_settings_v6( const DeviceSettings& settings) noexcept; [[nodiscard]] SettingsBlob encode_legacy_settings_v5( diff --git a/firmware/components/ui/CMakeLists.txt b/firmware/components/ui/CMakeLists.txt index 2062453..52fe4cb 100644 --- a/firmware/components/ui/CMakeLists.txt +++ b/firmware/components/ui/CMakeLists.txt @@ -1,6 +1,6 @@ idf_component_register( SRCS "component.cpp" "active_session.cpp" "input.cpp" "shell_navigation.cpp" "device_mode.cpp" "value_picker.cpp" "gesture_input.cpp" "carousel_screen.cpp" "clock.cpp" "clock_screen.cpp" "time_roller.cpp" "time_roller_screen.cpp" "trackday_screen.cpp" "timer_font.cpp" "diagnostics.cpp" "diagnostics_screen.cpp" - "display_policy.cpp" "foundation.cpp" "gate_capture.cpp" "gate_capture_screen.cpp" "gps_only.cpp" "gps_only_screen.cpp" "speed_only.cpp" "speed_only_screen.cpp" "compass.cpp" "compass_screen.cpp" "g_meter_screen.cpp" "g_radar_screen.cpp" "imu_meter.cpp" + "display_policy.cpp" "foundation.cpp" "gate_capture.cpp" "gate_capture_screen.cpp" "gps_only.cpp" "gps_only_screen.cpp" "speed_only.cpp" "speed_only_screen.cpp" "compass.cpp" "compass_screen.cpp" "acceleration.cpp" "acceleration_screen.cpp" "g_meter_screen.cpp" "g_radar_screen.cpp" "imu_meter.cpp" "lvgl_visual_system.cpp" "navigation.cpp" "presenter.cpp" "ready_screen.cpp" "rest_session.cpp" "review_cards.cpp" "session_trigger.cpp" "session_review.cpp" "session_review_screen.cpp" "settings_editor.cpp" "settings_screen.cpp" diff --git a/firmware/components/ui/acceleration.cpp b/firmware/components/ui/acceleration.cpp new file mode 100644 index 0000000..6051179 --- /dev/null +++ b/firmware/components/ui/acceleration.cpp @@ -0,0 +1,237 @@ +#include "track_timer/ui/acceleration.hpp" + +#include "track_timer/ui/foundation.hpp" + +#include +#include + +namespace track_timer::ui { +namespace { + +constexpr const char* kNoValue = "--.--"; +constexpr float kNanosecondsPerSecond = 1'000'000'000.0F; + +void write_text(char* const destination, const std::size_t capacity, + const char* const text) noexcept +{ + std::snprintf(destination, capacity, "%s", text); +} + +[[nodiscard]] float mph_to_mps(const std::uint8_t mph) noexcept +{ + return static_cast(mph) / kMetresPerSecondToMph; +} + +} // namespace + +std::int64_t extrapolated_zero_ns(const std::int64_t first_ns, const float first_mps, + const std::int64_t second_ns, + const float second_mps) noexcept +{ + const auto rise = second_mps - first_mps; + // Not accelerating between these two, so there is nothing to extrapolate along. The + // first sample is the best estimate available rather than a division by zero. + if (!(rise > 0.0F) || second_ns <= first_ns) { + return first_ns; + } + const auto run_ns = static_cast(second_ns - first_ns); + const auto back_ns = first_mps * run_ns / rise; + return first_ns - static_cast(std::lround(back_ns)); +} + +std::int64_t interpolated_crossing_ns(const std::int64_t before_ns, const float before_mps, + const std::int64_t after_ns, const float after_mps, + const float target_mps) noexcept +{ + const auto rise = after_mps - before_mps; + if (!(rise > 0.0F) || after_ns <= before_ns) { + return after_ns; + } + const auto fraction = (target_mps - before_mps) / rise; + const auto span_ns = static_cast(after_ns - before_ns); + return before_ns + static_cast(std::lround(fraction * span_ns)); +} + +void AccelerationRun::reset() noexcept +{ + start_ns_ = domain::kUnavailableTime; + finish_ns_ = domain::kUnavailableTime; + has_previous_ = false; + state_ = AccelerationState::armed; +} + +void AccelerationRun::update(const AccelerationSnapshot& snapshot) noexcept +{ + const auto receiver = gps_only_state(snapshot.gnss); + if (receiver != GpsOnlyState::fixed) { + state_ = receiver == GpsOnlyState::no_receiver ? AccelerationState::no_receiver + : AccelerationState::no_fix; + has_previous_ = false; + return; + } + const auto& fix = snapshot.gnss.fix; + if (!std::isfinite(fix.speed_accuracy_mps) || fix.speed_accuracy_mps <= 0.0F || + fix.speed_accuracy_mps > kAccelerationMaximumSpeedAccuracyMps) { + // Measuring against this would report the receiver's uncertainty rather than the + // car's acceleration, so no run starts and one in progress is abandoned. + state_ = state_ == AccelerationState::running ? AccelerationState::abandoned + : AccelerationState::no_speed; + has_previous_ = false; + return; + } + + // A finished or abandoned run holds its number until the car comes back to rest, which + // is how it re-arms without being told. A tap does the same thing sooner. + if (state_ == AccelerationState::complete || state_ == AccelerationState::abandoned) { + if (fix.speed_mps < kAccelerationRestMps) { + reset(); + } + return; + } + + const auto now_ns = fix.measurement_time_ns; + const auto speed = fix.speed_mps; + + if (state_ == AccelerationState::running && last_fix_arrival_us_ != domain::kUnavailableTime && + snapshot.now_monotonic_us != domain::kUnavailableTime && + snapshot.now_monotonic_us - last_fix_arrival_us_ > kAccelerationStaleUs) { + // The fixes stopped. The number would otherwise sit still, which is exactly what + // arriving at the target looks like. + state_ = AccelerationState::abandoned; + return; + } + + if (speed < kAccelerationRestMps) { + // Stationary: armed, and any partial run is discarded rather than carried into the + // next launch. + start_ns_ = domain::kUnavailableTime; + finish_ns_ = domain::kUnavailableTime; + state_ = AccelerationState::armed; + } + else if (state_ == AccelerationState::armed && has_previous_) { + // First motion. The start is where speed would have been zero, not where it was + // first seen to be non-zero. + start_ns_ = extrapolated_zero_ns(last_fix_ns_, last_speed_mps_, now_ns, speed); + state_ = AccelerationState::running; + } + + if (state_ == AccelerationState::running && has_previous_) { + const auto target_mps = mph_to_mps(snapshot.target_mph); + if (last_speed_mps_ < target_mps && speed >= target_mps) { + finish_ns_ = + interpolated_crossing_ns(last_fix_ns_, last_speed_mps_, now_ns, speed, + target_mps); + state_ = AccelerationState::complete; + } + } + + last_fix_ns_ = now_ns; + last_speed_mps_ = speed; + last_fix_arrival_us_ = fix.arrival_monotonic_us; + has_previous_ = true; +} + +AccelerationState AccelerationRun::state() const noexcept { return state_; } + +float AccelerationRun::elapsed_seconds() const noexcept +{ + if (start_ns_ == domain::kUnavailableTime) { + return 0.0F; + } + const auto end_ns = finish_ns_ != domain::kUnavailableTime ? finish_ns_ : last_fix_ns_; + if (end_ns <= start_ns_) { + return 0.0F; + } + return static_cast(end_ns - start_ns_) / kNanosecondsPerSecond; +} + +AccelerationViewModel AccelerationRun::view_model( + const AccelerationSnapshot& snapshot) const noexcept +{ + AccelerationViewModel model{}; + model.state = state_; + std::snprintf(model.target.data(), model.target.size(), "0-%u MPH", + static_cast(snapshot.target_mph)); + + switch (state_) { + case AccelerationState::no_receiver: + write_text(model.time.data(), model.time.size(), kNoValue); + write_text(model.status.data(), model.status.size(), "NO RECEIVER"); + model.status_rgb = color::critical_bright; + model.time_rgb = color::critical_bright; + return model; + case AccelerationState::no_fix: + write_text(model.time.data(), model.time.size(), kNoValue); + write_text(model.status.data(), model.status.size(), "NO FIX"); + model.status_rgb = color::caution_bright; + model.time_rgb = color::caution_bright; + return model; + case AccelerationState::no_speed: + write_text(model.time.data(), model.time.size(), kNoValue); + // Its own words: the fix is fine and the position is good, and sending somebody to + // look at satellites would be the wrong problem. + write_text(model.status.data(), model.status.size(), "NO SPEED"); + model.status_rgb = color::caution_bright; + model.time_rgb = color::caution_bright; + return model; + case AccelerationState::abandoned: + // The number it reached, so the run is not simply erased - but never green, and + // said in words, because a frozen time looks exactly like a finished one. + write_text(model.status.data(), model.status.size(), "LOST FIX"); + model.status_rgb = color::critical_bright; + model.time_rgb = color::critical_bright; + break; + case AccelerationState::armed: + write_text(model.status.data(), model.status.size(), "READY"); + model.status_rgb = color::text_secondary; + model.time_rgb = color::text_primary; + break; + case AccelerationState::running: + write_text(model.status.data(), model.status.size(), "RUN"); + model.status_rgb = color::text_secondary; + model.time_rgb = color::text_primary; + break; + case AccelerationState::complete: + write_text(model.status.data(), model.status.size(), "DONE"); + model.status_rgb = color::positive_bright; + model.time_rgb = color::positive_bright; + break; + } + + auto seconds = elapsed_seconds(); + if (!(seconds >= 0.0F)) { + seconds = 0.0F; + } + if (seconds > kAccelerationMaximumSeconds) { + seconds = kAccelerationMaximumSeconds; + } + model.time_valid = true; + // Two decimals, which is what the interpolation earns: without it every reading would + // be quantised to the 40 ms between fixes and the second decimal would be decoration. + std::snprintf(model.time.data(), model.time.size(), "%.2f", + static_cast(seconds)); + return model; +} + +const char* acceleration_state_name(const AccelerationState state) noexcept +{ + switch (state) { + case AccelerationState::no_receiver: + return "no-receiver"; + case AccelerationState::no_fix: + return "no-fix"; + case AccelerationState::no_speed: + return "no-speed"; + case AccelerationState::armed: + return "armed"; + case AccelerationState::running: + return "running"; + case AccelerationState::complete: + return "complete"; + case AccelerationState::abandoned: + return "abandoned"; + } + return "unknown"; +} + +} // namespace track_timer::ui diff --git a/firmware/components/ui/acceleration_screen.cpp b/firmware/components/ui/acceleration_screen.cpp new file mode 100644 index 0000000..f4f4126 --- /dev/null +++ b/firmware/components/ui/acceleration_screen.cpp @@ -0,0 +1,152 @@ +#include "track_timer/ui/acceleration_screen.hpp" + +#include "track_timer/ui/foundation.hpp" +#include "track_timer/ui/lvgl_visual_system.hpp" +#include "track_timer/ui/timer_font.hpp" + +#include +#include + +namespace track_timer::ui { +namespace { + +constexpr std::int32_t kScreenWidth = 600; +constexpr std::int32_t kSideMargin = 30; + +constexpr std::int32_t cell_width_for(const std::int32_t font_px) noexcept +{ + return font_px * 7235 / 10000; +} +constexpr std::int32_t cell_height_for(const std::int32_t font_px) noexcept +{ + return font_px * 1218 / 1000; +} + +// The countdown's size, shared rather than spent: four digits and a point come to the same +// width as Clock's HH:MM, so this costs no font-cache entry. +constexpr std::int32_t kTimePx = 170; +constexpr std::int32_t kDigitCellWidth = cell_width_for(kTimePx); +constexpr std::int32_t kPointWidth = kTimePx * 288 / 1000; +constexpr std::int32_t kDigitCellHeight = cell_height_for(kTimePx); +constexpr std::int32_t kBlockWidth = kDigitCellWidth * 4 + kPointWidth; +constexpr std::int32_t kBlockLeft = (kScreenWidth - kBlockWidth) / 2; +static_assert(kBlockWidth <= kScreenWidth - 2 * kSideMargin, + "the reading must fit the panel at this size, margins included"); + +constexpr std::int32_t kTitleTop = 12; +constexpr std::int32_t kBlockTop = 86; +constexpr std::int32_t kTargetTop = kBlockTop + kDigitCellHeight + 10; + +} // namespace + +AccelerationScreen::AccelerationScreen(lv_obj_t* const root) noexcept : root_(root) +{ + style_screen(root_); + + title_ = create_label(root_, Typography::body, color::text_secondary, LV_TEXT_ALIGN_LEFT); + lv_obj_set_pos(title_, kSideMargin, kTitleTop); + lv_label_set_text(title_, "0-X"); + + status_ = create_label(root_, Typography::body, color::text_secondary, + LV_TEXT_ALIGN_RIGHT); + lv_obj_set_width(status_, 300); + lv_obj_set_pos(status_, kScreenWidth - 300 - kSideMargin, kTitleTop); + lv_label_set_text(status_, ""); + + const auto* font = countdown_font(kTimePx); + auto x = kBlockLeft; + // Two digits, the point, two digits. The point is created and forgotten: it is the one + // glyph here that never changes. + for (std::size_t index = 0; index < digits_.size() + 1; ++index) { + const auto is_point = index == 2; + auto* cell = lv_label_create(root_); + if (font != nullptr) { + lv_obj_set_style_text_font(cell, font, 0); + } + lv_obj_set_style_text_color(cell, lv_color_hex(color::text_primary), 0); + // Fixed cells, as every large numeral on this device uses: a 1 occupies the space + // an 8 does, so the reading cannot twitch sideways as it climbs. + lv_obj_set_size(cell, is_point ? kPointWidth : kDigitCellWidth, kDigitCellHeight); + lv_obj_set_pos(cell, x, kBlockTop); + lv_obj_set_style_text_align(cell, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_text(cell, is_point ? "." : ""); + if (is_point) { + point_ = cell; + } + else { + digits_[index > 2 ? index - 1 : index] = cell; + } + x += is_point ? kPointWidth : kDigitCellWidth; + } + for (auto& shown : digits_shown_) { + shown = '\0'; + } + + target_ = create_label(root_, Typography::timer_primary, color::text_secondary); + lv_obj_set_width(target_, kScreenWidth); + lv_obj_set_pos(target_, 0, kTargetTop); + lv_label_set_text(target_, ""); + + status_shown_[0] = '\0'; + target_shown_[0] = '\0'; +} + +void AccelerationScreen::update(const AccelerationViewModel& model) noexcept +{ + if (std::strcmp(status_shown_.data(), model.status.data()) != 0) { + lv_label_set_text(status_, model.status.data()); + lv_obj_set_style_text_color(status_, lv_color_hex(model.status_rgb), 0); + std::snprintf(status_shown_.data(), status_shown_.size(), "%s", model.status.data()); + } + + // The colour is the signal that a run finished, so it is applied to every cell + // including the point - a green reading with a white point would read as a glitch. + if (model.time_rgb != time_rgb_shown_) { + time_rgb_shown_ = model.time_rgb; + const auto colour = lv_color_hex(model.time_rgb); + for (auto* cell : digits_) { + lv_obj_set_style_text_color(cell, colour, 0); + } + if (point_ != nullptr) { + lv_obj_set_style_text_color(point_, colour, 0); + } + } + + // Right-aligned into the cells either side of the point, so "6.71" leaves the leading + // cell blank rather than padding with a zero that reads as part of the number, and + // "12.34" fills it. The point never moves, which is what keeps the two stable against + // each other as the reading crosses ten seconds. + const auto length = std::strlen(model.time.data()); + const auto* const text = model.time.data(); + const auto* const point = static_cast(std::memchr(text, '.', length)); + const auto whole = point != nullptr ? static_cast(point - text) : length; + for (std::size_t index = 0; index < digits_.size(); ++index) { + char character = '\0'; + if (index < 2) { + // Tens and units, filled from the right of the whole part. + const auto offset = 2 - index; + if (whole >= offset) { + character = text[whole - offset]; + } + } + else if (point != nullptr) { + const auto decimal = index - 2; + if (whole + 1 + decimal < length) { + character = text[whole + 1 + decimal]; + } + } + if (character == digits_shown_[index]) { + continue; + } + digits_shown_[index] = character; + const char cell[2] = {character, '\0'}; + lv_label_set_text(digits_[index], cell); + } + + if (std::strcmp(target_shown_.data(), model.target.data()) != 0) { + lv_label_set_text(target_, model.target.data()); + std::snprintf(target_shown_.data(), target_shown_.size(), "%s", model.target.data()); + } +} + +} // namespace track_timer::ui diff --git a/firmware/components/ui/device_mode.cpp b/firmware/components/ui/device_mode.cpp index 9592f06..22933a1 100644 --- a/firmware/components/ui/device_mode.cpp +++ b/firmware/components/ui/device_mode.cpp @@ -22,6 +22,9 @@ ModeVisibility visibility_for(const DeviceMode mode) noexcept // A compass card and nothing else. gnss_data again marks where the number comes // from rather than what is drawn, and again this is not a session view. return {false, false, false, false, true}; + case DeviceMode::acceleration: + // A measurement, and nothing that could be mistaken for timing a session. + return {false, false, false, false, true}; case DeviceMode::gps_only: // A diagnostic view of the receiver alone. Showing a session timer here would // invite using it to time something, which is the one thing this mode is not for. @@ -48,6 +51,9 @@ DeviceMode mode_from_settings(const settings::DeviceSettings& settings) noexcept if (settings.operating_mode == settings::OperatingMode::compass) { return DeviceMode::compass; } + if (settings.operating_mode == settings::OperatingMode::acceleration) { + return DeviceMode::acceleration; + } if (settings.operating_mode == settings::OperatingMode::gps_only) { return DeviceMode::gps_only; } @@ -80,6 +86,10 @@ void apply_mode(const DeviceMode mode, settings::DeviceSettings& settings) noexc settings.operating_mode = settings::OperatingMode::compass; settings.trackday_mode_enabled = false; break; + case DeviceMode::acceleration: + settings.operating_mode = settings::OperatingMode::acceleration; + settings.trackday_mode_enabled = false; + break; case DeviceMode::gps_only: settings.operating_mode = settings::OperatingMode::gps_only; settings.trackday_mode_enabled = false; @@ -104,6 +114,8 @@ const char* device_mode_name(const DeviceMode mode) noexcept return "speed-only"; case DeviceMode::compass: return "compass"; + case DeviceMode::acceleration: + return "acceleration"; case DeviceMode::gps_only: return "gps-only"; case DeviceMode::clock: @@ -125,6 +137,8 @@ const char* device_mode_label(const DeviceMode mode) noexcept return "SPEED"; case DeviceMode::compass: return "COMPASS"; + case DeviceMode::acceleration: + return "0-X"; case DeviceMode::gps_only: return "GPS ONLY"; case DeviceMode::clock: @@ -146,6 +160,8 @@ const char* device_mode_summary(const DeviceMode mode) noexcept return "Road speed in mph, as large as the panel allows."; case DeviceMode::compass: return "Course over ground. Needs movement: a parked car has no heading."; + case DeviceMode::acceleration: + return "Standing start to a target speed, set in Setup."; case DeviceMode::gps_only: return "Road speed, position and receiver data. Diagnosis, not timing."; case DeviceMode::clock: diff --git a/firmware/components/ui/include/track_timer/ui/acceleration.hpp b/firmware/components/ui/include/track_timer/ui/acceleration.hpp new file mode 100644 index 0000000..0b3f92f --- /dev/null +++ b/firmware/components/ui/include/track_timer/ui/acceleration.hpp @@ -0,0 +1,127 @@ +#pragma once + +#include "track_timer/ui/gps_only.hpp" + +#include +#include +#include +#include + +namespace track_timer::ui { + +// The view model behind 0-X mode: the time from a standing start to a target speed. +// +// This is an instrument rather than a readout, and the difference shows in two places. +// +// The first is that **zero is not zero**. A stationary receiver reports a few centimetres +// per second of noise, not 0.000, so the instant the car began moving cannot be read off a +// sample. It is estimated instead, by extrapolating back along the acceleration between +// the first two moving fixes to where speed would have been zero. The target end is +// interpolated the same way, between the two fixes that straddle it. At 25 Hz a fix lands +// every 40 ms, so without both interpolations every time is quantised to 40 ms - which is +// the difference between an instrument and a stopwatch. +// +// The second is that **a run that stopped being measured must not look like a run that +// finished**. If the fixes dry up the number stops climbing, which is exactly what arriving +// at the target looks like. Clock has the same problem with a frozen face and answers it +// the same way: say so rather than letting a stalled reading pass for a good one. + +inline constexpr std::size_t kAccelerationTimeCapacity = 7; // "99.99" or "--.--" +inline constexpr std::size_t kAccelerationTargetCapacity = 16; // "0-60 MPH" +inline constexpr std::size_t kAccelerationStatusCapacity = 16; + +// Below this the car is treated as stationary: ready to launch, and the point a completed +// run re-arms from. Above the receiver's own noise floor at rest, well below a walking +// pace, so a car rolling in a paddock does not read as armed. +inline constexpr float kAccelerationRestMps = 0.5F; + +// The speed accuracy a run has to be measured at. Wider than this and the number would be +// a reading of the receiver's uncertainty rather than of the car. +inline constexpr float kAccelerationMaximumSpeedAccuracyMps = 0.5F; + +// How long without a fresh fix before a run in progress is abandoned. Four epochs at +// 25 Hz: long enough that ordinary jitter cannot trip it, short enough that a frozen +// number is never mistaken for a finished one for more than a blink. +inline constexpr std::int64_t kAccelerationStaleUs = 160'000; + +// Nothing beyond this is a measurement. A 0-60 is three to fifteen seconds; a run still +// counting at a hundred seconds did not happen. +inline constexpr float kAccelerationMaximumSeconds = 99.99F; + +enum class AccelerationState : std::uint8_t { + no_receiver, + no_fix, + // A fix, but the receiver is not sure enough of its speed to measure against. + no_speed, + // Stationary and ready. Shows a literal 0.00, which is true: the car has not moved. + armed, + // Moving, below the target, clock climbing. + running, + // Target reached. The time is frozen and green. + complete, + // The fixes stopped mid-run. Never green, and never confusable with complete. + abandoned, +}; + +struct AccelerationSnapshot { + GpsOnlySnapshot gnss{}; + std::uint8_t target_mph{60}; + // The MCU's monotonic clock, for judging whether a run has gone stale. Shares no epoch + // with the fix timestamps, which is why it is only ever compared with itself. + std::int64_t now_monotonic_us{domain::kUnavailableTime}; +}; + +struct AccelerationViewModel { + std::array time{}; // "5.47", or "--.--" + std::array target{}; // "0-60 MPH" + std::array status{}; + std::uint32_t status_rgb{0}; + std::uint32_t time_rgb{0}; + bool time_valid{false}; + AccelerationState state{AccelerationState::no_receiver}; +}; + +// Holds the run. A view model alone cannot do this: the start instant has to be remembered +// across fixes, and the finish has to be computed from the pair of fixes that straddle the +// target rather than from whichever one happens to be current. +class AccelerationRun { + public: + void update(const AccelerationSnapshot& snapshot) noexcept; + // Back to armed if the car is stationary, and discards a finished or abandoned run. + void reset() noexcept; + + [[nodiscard]] AccelerationState state() const noexcept; + [[nodiscard]] float elapsed_seconds() const noexcept; + [[nodiscard]] AccelerationViewModel view_model( + const AccelerationSnapshot& snapshot) const noexcept; + + private: + AccelerationState state_{AccelerationState::no_receiver}; + // Receiver time, not arrival time. The run is measured against the clock the fixes were + // taken on, so a late poll cannot lengthen a run. + std::int64_t start_ns_{domain::kUnavailableTime}; + std::int64_t finish_ns_{domain::kUnavailableTime}; + std::int64_t last_fix_ns_{domain::kUnavailableTime}; + std::int64_t last_fix_arrival_us_{domain::kUnavailableTime}; + float last_speed_mps_{0.0F}; + bool has_previous_{false}; +}; + +// Where speed would have been zero, extrapolated back along the acceleration between two +// fixes. Exposed because it is the half of the measurement with no second opinion: the +// finish can be sanity-checked against a stopwatch, the start cannot. +[[nodiscard]] std::int64_t extrapolated_zero_ns(std::int64_t first_ns, float first_mps, + std::int64_t second_ns, + float second_mps) noexcept; + +// When speed crossed `target_mps`, interpolated between the two fixes straddling it. +[[nodiscard]] std::int64_t interpolated_crossing_ns(std::int64_t before_ns, float before_mps, + std::int64_t after_ns, float after_mps, + float target_mps) noexcept; + +[[nodiscard]] const char* acceleration_state_name(AccelerationState state) noexcept; + +static_assert(std::is_trivially_copyable_v); +static_assert(std::is_trivially_copyable_v); + +} // namespace track_timer::ui diff --git a/firmware/components/ui/include/track_timer/ui/acceleration_screen.hpp b/firmware/components/ui/include/track_timer/ui/acceleration_screen.hpp new file mode 100644 index 0000000..fd8d848 --- /dev/null +++ b/firmware/components/ui/include/track_timer/ui/acceleration_screen.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include "track_timer/ui/acceleration.hpp" + +#include + +#include +#include + +namespace track_timer::ui { + +// 0-X mode's panel: the elapsed time filling the width, the target beneath it. +// +// `12.34` is four digits and a point, and the numeral face carries `.` among its fourteen +// glyphs - so the whole reading renders in the large face. Its metrics come out identical +// to Clock's `HH:MM`: 536 px of the 540 available at 170 px, which is the countdown's size +// and already in the font cache. +// +// Two leading digits is the right width. A 0-60 is three to fifteen seconds, and a run +// still counting past ninety-nine did not happen - so the field is sized for every real +// measurement and no unreal one. +class AccelerationScreen { + public: + explicit AccelerationScreen(lv_obj_t* root) noexcept; + + void update(const AccelerationViewModel& model) noexcept; + + AccelerationScreen(const AccelerationScreen&) = delete; + AccelerationScreen& operator=(const AccelerationScreen&) = delete; + + private: + // "12.34" - four digits around a point that never moves. + static constexpr std::size_t kTimeDigits = 4; + + lv_obj_t* root_{nullptr}; + lv_obj_t* title_{nullptr}; + lv_obj_t* status_{nullptr}; + lv_obj_t* target_{nullptr}; + lv_obj_t* point_{nullptr}; + std::array digits_{}; + std::array digits_shown_{}; + std::array status_shown_{}; + std::array target_shown_{}; + std::uint32_t time_rgb_shown_{0}; +}; + +} // namespace track_timer::ui diff --git a/firmware/components/ui/include/track_timer/ui/device_mode.hpp b/firmware/components/ui/include/track_timer/ui/device_mode.hpp index e649170..15ed8e4 100644 --- a/firmware/components/ui/include/track_timer/ui/device_mode.hpp +++ b/firmware/components/ui/include/track_timer/ui/device_mode.hpp @@ -15,6 +15,7 @@ namespace track_timer::ui { // G-Only operating_mode = g_meter // Speed Only operating_mode = speed_only // Compass operating_mode = compass +// 0-X operating_mode = acceleration // GPS Only operating_mode = gps_only // Clock operating_mode = clock // @@ -35,10 +36,11 @@ enum class DeviceMode : std::uint8_t { g_only, speed_only, compass, + acceleration, gps_only, clock, }; -inline constexpr std::size_t kDeviceModeCount = 7; +inline constexpr std::size_t kDeviceModeCount = 8; // What each mode is permitted to show while a session is running. struct ModeVisibility { diff --git a/firmware/components/ui/include/track_timer/ui/settings_editor.hpp b/firmware/components/ui/include/track_timer/ui/settings_editor.hpp index 2f0263a..2080fae 100644 --- a/firmware/components/ui/include/track_timer/ui/settings_editor.hpp +++ b/firmware/components/ui/include/track_timer/ui/settings_editor.hpp @@ -23,6 +23,7 @@ enum class SettingsField : std::uint8_t { auto_dim, average_lap, lower_display, + acceleration_target, }; inline constexpr std::size_t kSettingsFieldCount = 14; 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 69f6b63..e5a219d 100644 --- a/firmware/components/ui/include/track_timer/ui/shell_navigation.hpp +++ b/firmware/components/ui/include/track_timer/ui/shell_navigation.hpp @@ -54,6 +54,10 @@ inline constexpr std::size_t kSectionItemCount = 3; enum class SetupItem : std::uint8_t { device_settings, gate_capture, + // The 0-X target. It is one value rather than a list of fields, so entering it skips + // the field level and opens that field's choices directly - Setup should not make + // somebody walk a list of twelve settings to reach the one the entry is named after. + acceleration_target, }; enum class ShellOutcome : std::uint8_t { 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 99761c4..4250b79 100644 --- a/firmware/components/ui/include/track_timer/ui/value_picker.hpp +++ b/firmware/components/ui/include/track_timer/ui/value_picker.hpp @@ -28,15 +28,25 @@ 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{ +// Where acceleration_target sits in kPickerFields. Setup's 0-X entry jumps straight to this +// field's values rather than walking the driver through the whole list to reach it, and a +// hand-written index that stopped matching the array is exactly the mistake that made +// DIAGNOSTICS unreachable in #158 - so it is asserted below rather than trusted. +inline constexpr std::size_t kAccelerationTargetFieldIndex = 11; + +inline constexpr std::array kPickerFields{ SettingsField::session_duration, SettingsField::rest_duration, SettingsField::average_lap, SettingsField::lower_display, SettingsField::lap_boundary, SettingsField::pit_entry_auto_stop, SettingsField::launch_sensitivity, SettingsField::day_brightness, SettingsField::night_brightness, SettingsField::auto_dim, - SettingsField::orientation, + SettingsField::orientation, SettingsField::acceleration_target, }; +static_assert(kPickerFields[kAccelerationTargetFieldIndex] == + SettingsField::acceleration_target, + "Setup's 0-X entry opens this index, so it must be the target field"); + [[nodiscard]] ValueChoiceList choices_for(SettingsField field, const settings::DeviceSettings& current) noexcept; diff --git a/firmware/components/ui/settings_editor.cpp b/firmware/components/ui/settings_editor.cpp index ae79a1d..69fd5e2 100644 --- a/firmware/components/ui/settings_editor.cpp +++ b/firmware/components/ui/settings_editor.cpp @@ -92,6 +92,10 @@ void format_value(const SettingsField field, const settings::DeviceSettings& set case SettingsField::rest_duration: format_duration_value(output.data(), output.size(), settings.rest_duration_seconds); break; + case SettingsField::acceleration_target: + std::snprintf(output.data(), output.size(), "%u MPH", + static_cast(settings.acceleration_target_mph)); + break; case SettingsField::launch_sensitivity: // Always a real threshold now; TRIGGER decides whether it is consulted. std::snprintf(output.data(), output.size(), "%u.%02u g", @@ -317,6 +321,10 @@ bool SettingsEditor::adjust(const bool forward) noexcept case SettingsField::launch_sensitivity: adjusted = step_choice(draft_.launch_sensitivity_milli_g, settings::kLaunchSensitivityMilliG, forward); break; + case SettingsField::acceleration_target: + adjusted = step_choice(draft_.acceleration_target_mph, + settings::kAccelerationTargetsMph, forward); + break; case SettingsField::day_brightness: adjusted = step_choice(draft_.day_brightness_percent, kBrightnessPercent, forward); break; diff --git a/firmware/components/ui/shell_navigation.cpp b/firmware/components/ui/shell_navigation.cpp index 83d1918..c6e143e 100644 --- a/firmware/components/ui/shell_navigation.cpp +++ b/firmware/components/ui/shell_navigation.cpp @@ -1,5 +1,7 @@ #include "track_timer/ui/shell_navigation.hpp" +#include "track_timer/ui/value_picker.hpp" + namespace track_timer::ui { namespace { @@ -127,6 +129,13 @@ void ShellNavigation::enter(ShellResult& result) noexcept result.outcome = ShellOutcome::gate_capture_selected; return; } + if (state_.section_index == static_cast(SetupItem::acceleration_target)) { + state_.level = ShellLevel::value; + state_.field_index = kAccelerationTargetFieldIndex; + state_.value_index = 0; + result.outcome = ShellOutcome::entered; + return; + } // The G-meter and display entries are drawn but not routed yet. result.outcome = ShellOutcome::ignored; return; @@ -339,6 +348,8 @@ const char* setup_item_name(const SetupItem item) noexcept return "device-settings"; case SetupItem::gate_capture: return "gate-capture"; + case SetupItem::acceleration_target: + return "acceleration-target"; } return "unknown"; } diff --git a/firmware/components/ui/value_picker.cpp b/firmware/components/ui/value_picker.cpp index f4250af..3fd2635 100644 --- a/firmware/components/ui/value_picker.cpp +++ b/firmware/components/ui/value_picker.cpp @@ -94,6 +94,12 @@ ValueChoiceList choices_for(const SettingsField field, case SettingsField::lap_boundary: return from_labels(kLapBoundary, 2, static_cast(current.lap_boundary)); + case SettingsField::acceleration_target: + return from_list(settings::kAccelerationTargetsMph, current.acceleration_target_mph, + [](ValueChoice& c, std::uint8_t v) { + std::snprintf(c.text.data(), c.text.size(), "%u MPH", + static_cast(v)); + }); case SettingsField::orientation: return from_labels(kOrientation, 5, static_cast(current.orientation)); @@ -119,6 +125,12 @@ bool apply_choice(const SettingsField field, const std::size_t index, } draft.launch_sensitivity_milli_g = settings::kLaunchSensitivityMilliG[index]; return true; + case SettingsField::acceleration_target: + if (index >= settings::kAccelerationTargetsMph.size()) { + return false; + } + draft.acceleration_target_mph = settings::kAccelerationTargetsMph[index]; + return true; case SettingsField::day_brightness: if (index >= kBrightnessPercent.size()) { return false; @@ -198,6 +210,8 @@ const char* picker_field_label(const SettingsField field) noexcept return "PIT EXIT START"; case SettingsField::pit_entry_auto_stop: return "PIT ENTRY STOP"; + case SettingsField::acceleration_target: + return "0-X TARGET"; case SettingsField::orientation: return "ORIENTATION"; default: diff --git a/firmware/main/screen_router.cpp b/firmware/main/screen_router.cpp index 61b9f30..e398e5f 100644 --- a/firmware/main/screen_router.cpp +++ b/firmware/main/screen_router.cpp @@ -25,6 +25,7 @@ #include "track_timer/gnss/pipeline.hpp" #include "track_timer/ui/clock_screen.hpp" #include "track_timer/ui/gps_only_screen.hpp" +#include "track_timer/ui/acceleration_screen.hpp" #include "track_timer/ui/compass_screen.hpp" #include "track_timer/ui/gate_capture_screen.hpp" #include "track_timer/ui/speed_only_screen.hpp" @@ -417,6 +418,7 @@ constexpr ui::CarouselEntry kModeEntries[] = { // Beside Speed, because it is the same kind of readout from the same fix - and like // it, a driving display rather than something reached when a thing has gone wrong. {LV_SYMBOL_REFRESH, "COMPASS", kAzure, "course over ground"}, + {LV_SYMBOL_CHARGE, "0-X", kRuby, "standing start to a target"}, {LV_SYMBOL_GPS, "GPS ONLY", kAzure}, // Last, after the diagnostic. It is the mode least likely to be wanted on a track day, // and the caption says where the time comes from - a clock that is an hour off BST for @@ -454,6 +456,7 @@ static_assert(mode_entry_is(ui::DeviceMode::race, "RACE")); static_assert(mode_entry_is(ui::DeviceMode::g_only, "G-ONLY")); static_assert(mode_entry_is(ui::DeviceMode::speed_only, "SPEED")); static_assert(mode_entry_is(ui::DeviceMode::compass, "COMPASS")); +static_assert(mode_entry_is(ui::DeviceMode::acceleration, "0-X")); static_assert(mode_entry_is(ui::DeviceMode::gps_only, "GPS ONLY")); static_assert(mode_entry_is(ui::DeviceMode::clock, "CLOCK")); @@ -464,6 +467,9 @@ constexpr ui::CarouselEntry kSetupEntries[] = { // position against that enum. Capture works on the circuit already loaded, so the // order of operations is choose the track, then capture its gates. {LV_SYMBOL_GPS, "CAPTURE GATES", kRuby, "promote a provisional circuit"}, + // Third, matching SetupItem::acceleration_target. Named for the mode it belongs to so + // it is findable by somebody looking for 0-X rather than for a speed. + {LV_SYMBOL_CHARGE, "0-X TARGET", kAmber, "speed a run is timed to"}, {LV_SYMBOL_REFRESH, "G-METER", kViolet}, {LV_SYMBOL_EYE_OPEN, "DISPLAY", kGreen}, }; @@ -629,6 +635,11 @@ class ScreenRouter { gate_capture_.update_fix(gnss_pipeline_.last_accepted(), now_us); } + // Fed every tick rather than only while its screen is up. A run is a measurement + // in progress: leaving the mode to glance at the receiver must not lose it, and + // the state machine costs nothing when nothing is moving. + acceleration_run_.update(acceleration_snapshot()); + const auto health = gnss_pipeline_.health(now_us); gps_only_snapshot_.receiver_present = health != domain::GnssHealth::unavailable; gps_only_snapshot_.fix_valid = @@ -952,6 +963,10 @@ class ScreenRouter { lv_screen_active() == compass_root_) { compass_->update(ui::compass_view(gps_only_snapshot_)); } + if (acceleration_ != nullptr && acceleration_root_ != nullptr && + lv_screen_active() == acceleration_root_) { + acceleration_->update(acceleration_run_.view_model(acceleration_snapshot())); + } // Same gate again, with one difference: the clock's snapshot has to be rebuilt // here rather than reused from service_gnss, because what it shows depends on how // long ago the reading arrived and that changes between polls. A clock refreshed @@ -1036,6 +1051,16 @@ class ScreenRouter { compass_ = new (compass_storage_) ui::CompassScreen(compass_root_); ui::attach_gesture_input(compass_root_, on_input, this); + // 0-X owns the whole panel. It does take a tap - that is its reset - which the + // shell handler routes before reaching the menu. + acceleration_root_ = lv_obj_create(nullptr); + if (acceleration_root_ == nullptr) { + return false; + } + acceleration_ = new (acceleration_storage_) + ui::AccelerationScreen(acceleration_root_); + ui::attach_gesture_input(acceleration_root_, on_input, this); + // Gate capture drives itself from its own buttons rather than from the carousel // gestures, so this root deliberately gets no gesture binding: a swipe here must // not navigate away from a half-captured line. @@ -1502,6 +1527,9 @@ class ScreenRouter { case ui::DeviceMode::compass: trackday_->set_mode_note("COMPASS - course over ground, needs movement"); break; + case ui::DeviceMode::acceleration: + trackday_->set_mode_note("0-X - standing start to a target speed"); + break; case ui::DeviceMode::gps_only: trackday_->set_mode_note("GPS ONLY - receiver diagnosis, not timing"); break; @@ -1527,6 +1555,8 @@ class ScreenRouter { return speed_only_root_; case ui::DeviceMode::compass: return compass_root_; + case ui::DeviceMode::acceleration: + return acceleration_root_; case ui::DeviceMode::gps_only: return gps_only_root_; case ui::DeviceMode::clock: @@ -1559,6 +1589,15 @@ class ScreenRouter { view.date[0] != '\0' ? view.date.data() : "no date"); } + [[nodiscard]] ui::AccelerationSnapshot acceleration_snapshot() const noexcept + { + ui::AccelerationSnapshot snapshot{}; + snapshot.gnss = gps_only_snapshot_; + snapshot.target_mph = settings_.acceleration_target_mph; + snapshot.now_monotonic_us = esp_timer_get_time(); + return snapshot; + } + // Rebuilt on every render rather than cached, because two of its four fields are about // *now*: how old the reading is decides both which digits are shown and whether the // clock still calls itself live. @@ -2136,6 +2175,20 @@ class ScreenRouter { return; } + // A tap on the 0-X panel resets the run, and must not reach the shell - which at + // the ready level ignores a press anyway, but would start interpreting it the + // moment anything else changed there. Press is "select, enter, commit" everywhere + // on this device, and resetting is the only thing there is to commit here. + if (action == ui::InputAction::press && acceleration_root_ != nullptr && + lv_screen_active() == acceleration_root_) { + acceleration_run_.reset(); + if (acceleration_ != nullptr) { + acceleration_->update(acceleration_run_.view_model(acceleration_snapshot())); + } + ESP_LOGI("track_timer", "0-x: reset"); + return; + } + const auto result = shell_.dispatch(action); // The shell is otherwise silent, which is why a gesture that goes nowhere is // invisible from the log: every outcome below either redraws something or does @@ -2331,6 +2384,11 @@ class ScreenRouter { lv_obj_t* speed_only_root_{nullptr}; ui::CompassScreen* compass_{nullptr}; lv_obj_t* compass_root_{nullptr}; + ui::AccelerationScreen* acceleration_{nullptr}; + lv_obj_t* acceleration_root_{nullptr}; + // Holds the run across fixes: the start instant has to be remembered, and the finish + // computed from the pair that straddles the target rather than whichever is current. + ui::AccelerationRun acceleration_run_{}; ui::GateCaptureScreen* gate_capture_screen_{nullptr}; lv_obj_t* gate_capture_root_{nullptr}; // Holds a TrackDefinition draft of its own, which is 3.6 KB - the same reason the @@ -2424,6 +2482,8 @@ class ScreenRouter { alignas(ui::SpeedOnlyScreen) std::byte speed_only_storage_[sizeof(ui::SpeedOnlyScreen)]{}; alignas(ui::CompassScreen) std::byte compass_storage_[sizeof(ui::CompassScreen)]{}; + alignas(ui::AccelerationScreen) std::byte + acceleration_storage_[sizeof(ui::AccelerationScreen)]{}; alignas(ui::GateCaptureScreen) std::byte gate_capture_storage_[sizeof(ui::GateCaptureScreen)]{}; alignas(ui::TrackdayScreen) std::byte trackday_storage_[sizeof(ui::TrackdayScreen)]{}; diff --git a/tests/cpp/test_acceleration.cpp b/tests/cpp/test_acceleration.cpp new file mode 100644 index 0000000..6c115f1 --- /dev/null +++ b/tests/cpp/test_acceleration.cpp @@ -0,0 +1,305 @@ +#include "track_timer/ui/acceleration.hpp" +#include "track_timer/ui/foundation.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace track_timer; + +constexpr std::int64_t kMs = 1'000'000; // one millisecond in nanoseconds +constexpr std::int64_t kEpoch = 40 * kMs; // one fix at 25 Hz + +ui::AccelerationSnapshot sample(const float speed_mps, const std::int64_t measurement_ns, + const std::int64_t arrival_us = 0) +{ + ui::AccelerationSnapshot snapshot{}; + snapshot.target_mph = 60; + snapshot.now_monotonic_us = arrival_us; + snapshot.gnss.receiver_present = true; + snapshot.gnss.fix_valid = true; + snapshot.gnss.fix.fix_type = domain::FixType::fix_3d; + snapshot.gnss.fix.num_satellites = 14; + snapshot.gnss.fix.speed_mps = speed_mps; + snapshot.gnss.fix.speed_accuracy_mps = 0.05F; + snapshot.gnss.fix.measurement_time_ns = measurement_ns; + snapshot.gnss.fix.arrival_monotonic_us = arrival_us; + return snapshot; +} + +// Extrapolating back to zero is the half of the measurement with no second opinion: a +// finish can be checked against a stopwatch, a start cannot. +void the_start_is_extrapolated_back_to_zero_speed() +{ + // 2 m/s at t=1000 ms, 6 m/s at t=1040 ms. 4 m/s gained in 40 ms, so zero was 20 ms + // before the first sample. + const auto zero = ui::extrapolated_zero_ns(1000 * kMs, 2.0F, 1040 * kMs, 6.0F); + assert(zero == 980 * kMs); + + // Already moving with little gain extrapolates a long way back - correct arithmetic, + // and the reason the first sample has to be near rest for the result to mean anything. + // + // Allowed a microsecond of slack rather than asserted exactly: 1.1F - 1.0F is not + // 0.1 in binary floating point, and a test that demanded the exact nanosecond would be + // asserting the representation rather than the behaviour. + const auto shallow = ui::extrapolated_zero_ns(1000 * kMs, 1.0F, 1040 * kMs, 1.1F); + assert(std::llabs(shallow - 600 * kMs) < 1000); + + // No acceleration between the pair: nothing to extrapolate along, so the first sample + // stands rather than dividing by zero. + assert(ui::extrapolated_zero_ns(1000 * kMs, 3.0F, 1040 * kMs, 3.0F) == 1000 * kMs); + assert(ui::extrapolated_zero_ns(1000 * kMs, 3.0F, 1040 * kMs, 2.0F) == 1000 * kMs); +} + +void the_finish_is_interpolated_between_the_straddling_fixes() +{ + // 25 m/s at t=5000 ms, 29 m/s at t=5040 ms, target 26 m/s - a quarter of the way. + const auto crossing = + ui::interpolated_crossing_ns(5000 * kMs, 25.0F, 5040 * kMs, 29.0F, 26.0F); + assert(crossing == 5010 * kMs); + + // Landing exactly on the later sample is the boundary case. + assert(ui::interpolated_crossing_ns(5000 * kMs, 25.0F, 5040 * kMs, 29.0F, 29.0F) == + 5040 * kMs); + assert(ui::interpolated_crossing_ns(5000 * kMs, 25.0F, 5040 * kMs, 29.0F, 25.0F) == + 5000 * kMs); +} + +void an_absent_receiver_shows_no_time() +{ + ui::AccelerationRun run; + run.update({}); + assert(run.state() == ui::AccelerationState::no_receiver); + const auto view = run.view_model({}); + assert(!view.time_valid); + assert(std::strcmp(view.time.data(), "--.--") == 0); + assert(std::strcmp(view.status.data(), "NO RECEIVER") == 0); +} + +// A fix can be perfect while the speed it carries is not worth measuring against. +void a_receiver_unsure_of_its_speed_will_not_time_a_run() +{ + ui::AccelerationRun run; + auto snapshot = sample(0.0F, 0); + snapshot.gnss.fix.speed_accuracy_mps = 2.0F; + run.update(snapshot); + assert(run.state() == ui::AccelerationState::no_speed); + const auto view = run.view_model(snapshot); + // Its own words: the fix is fine, and "NO FIX" would send somebody to the wrong + // problem entirely. + assert(std::strcmp(view.status.data(), "NO SPEED") == 0); + assert(!view.time_valid); +} + +void standing_still_is_armed_and_reads_zero() +{ + ui::AccelerationRun run; + const auto snapshot = sample(0.1F, 1000 * kMs); + run.update(snapshot); + assert(run.state() == ui::AccelerationState::armed); + const auto view = run.view_model(snapshot); + // A literal 0.00, which is true: the car has not moved. This is the one place a zero + // is the right answer rather than a placeholder. + assert(view.time_valid); + assert(std::strcmp(view.time.data(), "0.00") == 0); + assert(std::strcmp(view.status.data(), "READY") == 0); + assert(std::strcmp(view.target.data(), "0-60 MPH") == 0); +} + +// The whole run, at 25 Hz, accelerating steadily to past 60 mph. +void a_run_times_from_rest_to_the_target_and_stops() +{ + ui::AccelerationRun run; + std::int64_t t = 1000 * kMs; + std::int64_t arrival = 1'000'000; + run.update(sample(0.0F, t, arrival)); + assert(run.state() == ui::AccelerationState::armed); + + // 4 m/s^2, so 60 mph (26.8224 m/s) arrives about 6.7 s after a true standing start. + constexpr float kAccel = 4.0F; + float speed = 0.0F; + for (int step = 1; step <= 250; ++step) { + t += kEpoch; + arrival += 40'000; + speed = kAccel * static_cast(step) * 0.04F; + run.update(sample(speed, t, arrival)); + if (run.state() == ui::AccelerationState::complete) { + break; + } + // Armed until the car is genuinely moving, then running. At 4 m/s^2 that is three + // epochs below the rest threshold, which is the threshold doing its job rather + // than a late start: the extrapolation recovers the true zero from the pair that + // straddles it. + assert(run.state() == ui::AccelerationState::armed || + run.state() == ui::AccelerationState::running); + } + assert(run.state() == ui::AccelerationState::complete); + + // 26.8224 / 4 = 6.7056 s. Interpolated at both ends, so this lands far closer than the + // 40 ms between fixes - which is the entire reason both ends are interpolated. + const auto elapsed = run.elapsed_seconds(); + assert(std::fabs(elapsed - 6.7056F) < 0.01F); + + const auto view = run.view_model(sample(speed, t, arrival)); + assert(view.state == ui::AccelerationState::complete); + assert(std::strcmp(view.status.data(), "DONE") == 0); + // Green is the signal that it finished, so it must belong to this state alone. + assert(view.time_rgb == ui::color::positive_bright); + assert(std::strcmp(view.time.data(), "6.71") == 0); +} + +void the_time_holds_once_the_target_is_passed() +{ + ui::AccelerationRun run; + std::int64_t t = 0; + std::int64_t arrival = 0; + run.update(sample(0.0F, t, arrival)); + for (int step = 1; step <= 300; ++step) { + t += kEpoch; + arrival += 40'000; + run.update(sample(4.0F * static_cast(step) * 0.04F, t, arrival)); + if (run.state() == ui::AccelerationState::complete) { + break; + } + } + const auto frozen = run.elapsed_seconds(); + // Keep accelerating well past the target; the number must not move. + for (int step = 0; step < 50; ++step) { + t += kEpoch; + arrival += 40'000; + run.update(sample(60.0F, t, arrival)); + } + assert(run.state() == ui::AccelerationState::complete); + assert(std::fabs(run.elapsed_seconds() - frozen) < 0.0001F); +} + +// Coming back to rest re-arms without being told, which is how this gets used between runs. +void returning_to_rest_re_arms() +{ + ui::AccelerationRun run; + std::int64_t t = 0; + std::int64_t arrival = 0; + run.update(sample(0.0F, t, arrival)); + for (int step = 1; step <= 300; ++step) { + t += kEpoch; + arrival += 40'000; + run.update(sample(4.0F * static_cast(step) * 0.04F, t, arrival)); + if (run.state() == ui::AccelerationState::complete) { + break; + } + } + assert(run.state() == ui::AccelerationState::complete); + + t += kEpoch; + arrival += 40'000; + run.update(sample(0.0F, t, arrival)); + assert(run.state() == ui::AccelerationState::armed); + assert(run.elapsed_seconds() == 0.0F); +} + +void a_tap_resets_at_any_time() +{ + ui::AccelerationRun run; + run.update(sample(0.0F, 0, 0)); + run.update(sample(8.0F, kEpoch, 40'000)); + assert(run.state() == ui::AccelerationState::running); + run.reset(); + assert(run.state() == ui::AccelerationState::armed); + assert(run.elapsed_seconds() == 0.0F); +} + +// The failure this mode is built around: a run that stopped being measured looks exactly +// like a run that finished, because in both cases the number stops moving. +void a_run_that_loses_its_fixes_is_never_mistaken_for_a_finished_one() +{ + ui::AccelerationRun run; + run.update(sample(0.0F, 0, 0)); + run.update(sample(8.0F, kEpoch, 40'000)); + assert(run.state() == ui::AccelerationState::running); + + // Same fix again, long after it arrived: the receiver has gone quiet. + auto stale = sample(8.0F, kEpoch, 40'000); + stale.now_monotonic_us = 40'000 + ui::kAccelerationStaleUs + 1; + run.update(stale); + assert(run.state() == ui::AccelerationState::abandoned); + + const auto view = run.view_model(stale); + assert(std::strcmp(view.status.data(), "LOST FIX") == 0); + // Never green. Green means finished, and this did not. + assert(view.time_rgb != ui::color::positive_bright); +} + +void a_slower_target_finishes_sooner() +{ + const auto run_to = [](const std::uint8_t mph) { + ui::AccelerationRun run; + std::int64_t t = 0; + std::int64_t arrival = 0; + auto first = sample(0.0F, t, arrival); + first.target_mph = mph; + run.update(first); + for (int step = 1; step <= 400; ++step) { + t += kEpoch; + arrival += 40'000; + auto next = sample(4.0F * static_cast(step) * 0.04F, t, arrival); + next.target_mph = mph; + run.update(next); + if (run.state() == ui::AccelerationState::complete) { + break; + } + } + assert(run.state() == ui::AccelerationState::complete); + return run.elapsed_seconds(); + }; + // 30 mph is 13.4112 m/s, so at 4 m/s^2 it arrives at 3.3528 s - half the 0-60. + assert(std::fabs(run_to(30) - 3.3528F) < 0.01F); + assert(run_to(30) < run_to(60)); + assert(run_to(60) < run_to(100)); +} + +void every_state_has_a_name_and_untruncated_text() +{ + const ui::AccelerationState states[] = { + ui::AccelerationState::no_receiver, ui::AccelerationState::no_fix, + ui::AccelerationState::no_speed, ui::AccelerationState::armed, + ui::AccelerationState::running, ui::AccelerationState::complete, + ui::AccelerationState::abandoned, + }; + for (const auto state : states) { + assert(std::strlen(ui::acceleration_state_name(state)) > 0); + } + ui::AccelerationRun run; + const auto snapshot = sample(0.0F, 0); + run.update(snapshot); + const auto view = run.view_model(snapshot); + assert(std::strlen(view.status.data()) < ui::kAccelerationStatusCapacity - 1); + assert(std::strlen(view.target.data()) < ui::kAccelerationTargetCapacity - 1); + assert(std::strlen(view.time.data()) < ui::kAccelerationTimeCapacity - 1); +} + +} // namespace + +int main() +{ + the_start_is_extrapolated_back_to_zero_speed(); + the_finish_is_interpolated_between_the_straddling_fixes(); + an_absent_receiver_shows_no_time(); + a_receiver_unsure_of_its_speed_will_not_time_a_run(); + standing_still_is_armed_and_reads_zero(); + a_run_times_from_rest_to_the_target_and_stops(); + the_time_holds_once_the_target_is_passed(); + returning_to_rest_re_arms(); + a_tap_resets_at_any_time(); + a_run_that_loses_its_fixes_is_never_mistaken_for_a_finished_one(); + a_slower_target_finishes_sooner(); + every_state_has_a_name_and_untruncated_text(); + + std::cout << "0-X acceleration: both ends of the run interpolated rather than " + "quantised to the fix rate, and a run that lost its fixes never renders " + "as a finished one\n"; + return 0; +} diff --git a/tests/cpp/test_settings.cpp b/tests/cpp/test_settings.cpp index b47628e..804efa4 100644 --- a/tests/cpp/test_settings.cpp +++ b/tests/cpp/test_settings.cpp @@ -179,7 +179,56 @@ void test_compass_operating_mode_round_trips() auto beyond = settings; beyond.operating_mode = static_cast( static_cast(OperatingMode::compass) + 1); + // Valid now: acceleration follows it. The bound moves with the enum every time. + assert(valid_settings(beyond)); +} + +// Acceleration is the first mode whose own setting changed the record's shape, so this +// covers the format bump as well as the mode. +void test_acceleration_mode_and_target_round_trip() +{ + auto settings = customized_settings(); + settings.operating_mode = OperatingMode::acceleration; + settings.acceleration_target_mph = 100; + assert(valid_settings(settings)); + + const auto blob = encode_settings(settings); + DeviceSettings decoded{}; + assert(decode_settings(blob, decoded) == DecodeResult::current); + assert(decoded.operating_mode == OperatingMode::acceleration); + assert(decoded.acceleration_target_mph == 100); + assert(settings_equal(settings, decoded)); + + auto beyond = settings; + beyond.operating_mode = static_cast( + static_cast(OperatingMode::acceleration) + 1); assert(!valid_settings(beyond)); + + // A target the picker cannot reach is a record this firmware did not write, whatever + // else in it looks reasonable. + auto odd = settings; + odd.acceleration_target_mph = 55; + assert(!valid_settings(odd)); + odd.acceleration_target_mph = 0; + assert(!valid_settings(odd)); +} + +// A device upgrading from v7 has never had an opinion about the target, so it gets the +// default rather than a zero - and everything else it did choose has to survive intact. +void test_v7_records_migrate_and_keep_their_settings() +{ + auto settings = customized_settings(); + settings.operating_mode = OperatingMode::compass; + const auto blob = encode_legacy_settings_v7(settings); + assert(blob.size > 0); + + DeviceSettings decoded{}; + assert(decode_settings(blob, decoded) == DecodeResult::migrated_v7); + assert(decoded.acceleration_target_mph == kDefaultAccelerationTargetMph); + assert(decoded.operating_mode == OperatingMode::compass); + assert(decoded.session_duration_seconds == settings.session_duration_seconds); + assert(decoded.selected_track_id == settings.selected_track_id); + assert(decoded.session_trigger == settings.session_trigger); } void test_launch_sensitivity_snapping() @@ -434,6 +483,8 @@ int main() test_clock_operating_mode_round_trips(); test_speed_only_operating_mode_round_trips(); test_compass_operating_mode_round_trips(); + test_acceleration_mode_and_target_round_trip(); + test_v7_records_migrate_and_keep_their_settings(); test_launch_sensitivity_snapping(); test_defaults_restart_and_deferred_apply(); test_migration_corruption_and_storage_errors();