From 467c46e3664a72bfe4b86d8739f975ca2ef0de23 Mon Sep 17 00:00:00 2001 From: PurpleSentinel <199351535+PurpleSentinel@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:33:51 +0100 Subject: [PATCH] feat(ui): add a 0-X mode timing a standing start to a target speed Counts up from 0.00, stops the instant the target is reached, turns green. Coming back to rest re-arms it; a tap resets it sooner. The target is chosen in SETUP -> 0-X TARGET. This is an instrument rather than a readout, and two things follow from that. **Zero is not zero.** A stationary receiver reports a few centimetres per second of noise, so the instant the car began moving cannot be read off a sample. 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 two fixes that straddle the target. At 25 Hz a fix lands every 40 ms, so without both ends interpolated every reading would be quantised to 40 ms and the second decimal would be decoration. The test drives a 4 m/s^2 run and measures 6.71 s against a true 6.7056. **A run that stopped being measured must not look like one that finished.** When the fixes dry up the number stops climbing, which is exactly what arriving at the target looks like. So that is its own state: LOST FIX, never green. Green belongs to a completed run alone, and is applied to the decimal point as well, because a green number with a white point reads as a glitch rather than a result. The armed state shows a literal 0.00. That is the one place on this device where a zero is the right answer rather than a placeholder - the car genuinely has not moved - and it is what the request asked for. X is configurable, and that cost more than a mode does. Appending an OperatingMode changes no stored value's meaning; adding a field changes the length of a fixed-width record, so the settings format goes to version 8 with a migration. A v7 device keeps every setting it had and takes the default target, because it never had an opinion about one and a migrated record should not be read as a device that chose zero. Setup's entry opens the target's choices directly rather than descending through twelve fields to reach the one it is named after. Its index into the picker array is static_assert ed rather than trusted: a hand-written count that stopped matching its array is what made DIAGNOSTICS unreachable in #158. Closes #207. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 17 + Makefile | 14 +- README.md | 3 +- docs/SETTINGS.md | 5 +- firmware/components/settings/component.cpp | 53 ++- .../include/track_timer/settings/settings.hpp | 18 +- firmware/components/ui/CMakeLists.txt | 2 +- firmware/components/ui/acceleration.cpp | 237 ++++++++++++++ .../components/ui/acceleration_screen.cpp | 152 +++++++++ firmware/components/ui/device_mode.cpp | 16 + .../include/track_timer/ui/acceleration.hpp | 127 ++++++++ .../track_timer/ui/acceleration_screen.hpp | 47 +++ .../ui/include/track_timer/ui/device_mode.hpp | 4 +- .../track_timer/ui/settings_editor.hpp | 1 + .../track_timer/ui/shell_navigation.hpp | 4 + .../include/track_timer/ui/value_picker.hpp | 14 +- firmware/components/ui/settings_editor.cpp | 8 + firmware/components/ui/shell_navigation.cpp | 11 + firmware/components/ui/value_picker.cpp | 14 + firmware/main/screen_router.cpp | 60 ++++ tests/cpp/test_acceleration.cpp | 305 ++++++++++++++++++ tests/cpp/test_settings.cpp | 51 +++ 22 files changed, 1147 insertions(+), 16 deletions(-) create mode 100644 firmware/components/ui/acceleration.cpp create mode 100644 firmware/components/ui/acceleration_screen.cpp create mode 100644 firmware/components/ui/include/track_timer/ui/acceleration.hpp create mode 100644 firmware/components/ui/include/track_timer/ui/acceleration_screen.hpp create mode 100644 tests/cpp/test_acceleration.cpp 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();