diff --git a/CHANGELOG.md b/CHANGELOG.md index 66adab3..2b4fe0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ The project follows Semantic Versioning once the first firmware release is tagge ### Fixed +- a session that ran out simply stopped: the rest period was modelled and tested but never + routed to a screen, and overtime rendered identically to a session still running + - time settings could not reach most of their own range: the average lap picker stopped at 3:00 against a field holding 59:59, and the durations at 60 minutes against 24 hours - saving on the roller used LVGL's 400 ms long press, which committed a value while it was @@ -50,6 +53,11 @@ The project follows Semantic Versioning once the first firmware release is tagge - vertical G alongside the lateral and longitudinal pair, for kerbs and compressions - gyroscope zero-rate offset measured at rest and removed, 4.4 dps on this board - device settings persisted in NVS, so Mode survives a reboot +- overrun timer counting up in deep purple once a session reaches 00:00, with the lap + estimate replaced by OVER RUN +- rest period shown on the running screen, counting down on the same ramp as a session +- double tap to end a session or its overrun for the rest period, and rest for the + dashboard - two-column minutes-and-seconds roller for average lap, session and rest duration, with drag, flick momentum, and a deliberate hold to save - session and rest durations stored as seconds rather than whole minutes diff --git a/firmware/components/ui/active_session.cpp b/firmware/components/ui/active_session.cpp index ef42d60..c355439 100644 --- a/firmware/components/ui/active_session.cpp +++ b/firmware/components/ui/active_session.cpp @@ -34,6 +34,20 @@ void format_delta(std::array& output, const std::int64_t delta_ms, static_cast(absolute_ms % 1'000), suffix); } +void format_running_clock(std::array& output, const std::int64_t value_ms) noexcept +{ + if (value_ms == domain::kUnavailableTime) { + std::snprintf(output.data(), output.size(), "--:--"); + return; + } + const auto absolute_ms = static_cast(std::max(0, value_ms)); + // No sign: the field is five fixed cells wide and a sixth would not fit the panel, and + // the deep purple plus the OVER RUN caption already say the clock is running up. + std::snprintf(output.data(), output.size(), "%02llu:%02llu", + static_cast(absolute_ms / 60'000), + static_cast((absolute_ms / 1'000) % 60)); +} + void format_estimated_laps(TrackdayModeViewModel& model, const std::int64_t remaining_ms, const std::uint16_t average_lap_seconds) noexcept @@ -139,6 +153,24 @@ std::uint32_t urgency_rgb(const SessionUrgency urgency) noexcept return 0xFFFFFF; } +const char* running_phase_caption(const RunningPhase phase) noexcept +{ + switch (phase) { + case RunningPhase::overrun: + return "OVER RUN"; + case RunningPhase::rest: + return "REST"; + case RunningPhase::session: + break; + } + return nullptr; +} + +std::uint32_t trackday_rgb(const RunningPhase phase, const SessionUrgency urgency) noexcept +{ + return phase == RunningPhase::overrun ? kOverrunRgb : urgency_rgb(urgency); +} + const char* session_urgency_name(const SessionUrgency urgency) noexcept { switch (urgency) { @@ -182,15 +214,44 @@ void ActiveSessionController::update(const domain::UiSnapshot& snapshot, if (display.trackday_mode_enabled) { view_.feedback = {}; view_.trackday.visible = true; - view_.trackday.countdown = view_.timing.session_remaining; - format_estimated_laps(view_.trackday, snapshot.session_remaining_ms, - display.average_lap_seconds); - const auto total_ms = - static_cast(display.session_duration_seconds) * 1'000; - view_.trackday.remaining_ratio = - session_remaining_ratio(snapshot.session_remaining_ms, total_ms); - view_.trackday.urgency = - session_urgency(snapshot.session_remaining_ms, total_ms); + view_.trackday.phase = display.phase; + switch (display.phase) { + case RunningPhase::overrun: { + // Counting up, so there is no proportion left to show and the bar is spent. + format_running_clock(view_.trackday.countdown, display.overrun_ms); + format_estimated_laps(view_.trackday, domain::kUnavailableTime, + display.average_lap_seconds); + view_.trackday.remaining_ratio = 0.0F; + view_.trackday.urgency = SessionUrgency::overtime; + break; + } + case RunningPhase::rest: { + // Rest counts down exactly as the session does, against its own duration. + const auto rest_total_ms = + static_cast(display.rest_duration_seconds) * 1'000; + format_running_clock(view_.trackday.countdown, display.rest_remaining_ms); + format_estimated_laps(view_.trackday, domain::kUnavailableTime, + display.average_lap_seconds); + view_.trackday.remaining_ratio = + session_remaining_ratio(display.rest_remaining_ms, rest_total_ms); + view_.trackday.urgency = + session_urgency(display.rest_remaining_ms, rest_total_ms); + break; + } + case RunningPhase::session: + default: { + const auto total_ms = + static_cast(display.session_duration_seconds) * 1'000; + format_running_clock(view_.trackday.countdown, snapshot.session_remaining_ms); + format_estimated_laps(view_.trackday, snapshot.session_remaining_ms, + display.average_lap_seconds); + view_.trackday.remaining_ratio = + session_remaining_ratio(snapshot.session_remaining_ms, total_ms); + view_.trackday.urgency = + session_urgency(snapshot.session_remaining_ms, total_ms); + break; + } + } view_.timing.lap_label.fill('\0'); view_.timing.current_lap.fill('\0'); view_.timing.previous_lap.fill('\0'); diff --git a/firmware/components/ui/gesture_input.cpp b/firmware/components/ui/gesture_input.cpp index c50448c..4395732 100644 --- a/firmware/components/ui/gesture_input.cpp +++ b/firmware/components/ui/gesture_input.cpp @@ -11,6 +11,10 @@ struct Binding { // before LV_EVENT_SHORT_CLICKED, so clearing on release let the click through and // every swipe also fired a spurious press. bool handled_this_touch{false}; + // When the previous short click landed, so a second one inside the window can be + // reported as a double tap. + std::uint32_t last_click_ms{0}; + bool has_last_click{false}; }; // One binding per attached screen. Fixed storage keeps the no-dynamic-allocation rule. @@ -68,8 +72,19 @@ void handle(lv_event_t* event) noexcept ++counters.suppressed; return; } - ++counters.dispatched; - binding->callback(InputAction::press, binding->context); + { + const auto now_ms = lv_tick_get(); + const auto doubled = binding->has_last_click && + now_ms - binding->last_click_ms <= kDoubleTapWindowMs; + // The first tap is still reported. Suppressing it pending a possible second + // would delay every press by the window, and the screens that act on a double + // tap ignore single presses anyway. + binding->has_last_click = !doubled; + binding->last_click_ms = now_ms; + ++counters.dispatched; + binding->callback(doubled ? InputAction::double_tap : InputAction::press, + binding->context); + } break; case LV_EVENT_PRESSED: ++counters.pressed; diff --git a/firmware/components/ui/include/track_timer/ui/active_session.hpp b/firmware/components/ui/include/track_timer/ui/active_session.hpp index b73ac65..cba0543 100644 --- a/firmware/components/ui/include/track_timer/ui/active_session.hpp +++ b/firmware/components/ui/include/track_timer/ui/active_session.hpp @@ -45,6 +45,14 @@ struct StopControlViewModel { bool confirmation_visible{false}; }; +// Which clock the running screen is showing. The session counts down, the overrun counts +// up past zero, and rest counts down again through the configured break. +enum class RunningPhase : std::uint8_t { + session, + overrun, + rest, +}; + struct ActiveSessionDisplayConfig { std::uint16_t average_lap_seconds{0}; bool trackday_mode_enabled{false}; @@ -53,6 +61,13 @@ struct ActiveSessionDisplayConfig { // Needed for the decaying bar and the proportional part of the colour ramp, since // remaining time alone cannot say what fraction of the session is left. std::uint32_t session_duration_seconds{0}; + // Appended for the same reason as the field above. The session snapshot carries these + // separately from the remaining time, and the overrun is a count up rather than a + // negative count down. + RunningPhase phase{RunningPhase::session}; + std::int64_t overrun_ms{0}; + std::int64_t rest_remaining_ms{0}; + std::uint32_t rest_duration_seconds{0}; }; // Urgency bands for the countdown. Named rather than raw colours so the thresholds are @@ -68,6 +83,7 @@ enum class SessionUrgency : std::uint8_t { struct TrackdayModeViewModel { std::array countdown{}; + RunningPhase phase{RunningPhase::session}; std::array estimated_laps{}; // 1.0 at the start of a session falling to 0.0 at its end, for the decaying bar. float remaining_ratio{0.0F}; @@ -84,6 +100,19 @@ struct TrackdayModeViewModel { [[nodiscard]] float session_remaining_ratio(std::int64_t remaining_ms, std::int64_t total_ms) noexcept; [[nodiscard]] std::uint32_t urgency_rgb(SessionUrgency urgency) noexcept; + +// The line under the counter: the lap estimate is meaningless once a session is over, so +// the overrun and rest phases say what the timer is doing instead. Null while running, so +// the caller falls back to the estimate. +[[nodiscard]] const char* running_phase_caption(RunningPhase phase) noexcept; + +// What colour the numerals take. Deep purple for the overrun, unmistakably apart from the +// green-through-red ramp that preceded it; the ramp itself for the two counting-down +// phases. Chosen on measured contrast against the black panel rather than by name: the +// literal "deep purple" #6A0DAD manages 2.27:1, where every other state here sits between +// 5.9 and 10.5, and a 10 mm numeral at 2.27:1 is hard work in daylight. +inline constexpr std::uint32_t kOverrunRgb = 0x9A4DFF; +[[nodiscard]] std::uint32_t trackday_rgb(RunningPhase phase, SessionUrgency urgency) noexcept; [[nodiscard]] const char* session_urgency_name(SessionUrgency urgency) noexcept; struct ActiveSessionViewModel { diff --git a/firmware/components/ui/include/track_timer/ui/input.hpp b/firmware/components/ui/include/track_timer/ui/input.hpp index 0ca93b8..8a49d63 100644 --- a/firmware/components/ui/include/track_timer/ui/input.hpp +++ b/firmware/components/ui/include/track_timer/ui/input.hpp @@ -18,8 +18,13 @@ enum class InputAction : std::uint8_t { swipe_right, // move to the previous item in a carousel swipe_down, // back one level swipe_up, // only meaningful where a screen drives a value with vertical gestures + double_tap, // ends a running session; deliberately not a single touch }; +// Two taps inside this window are one double tap. Long enough to be comfortable with +// gloves, short enough that two unrelated taps do not merge into one. +inline constexpr std::uint32_t kDoubleTapWindowMs = 400; + using InputCallback = void (*)(InputAction action, void* context) noexcept; [[nodiscard]] const char* input_action_name(InputAction action) noexcept; diff --git a/firmware/components/ui/include/track_timer/ui/trackday_screen.hpp b/firmware/components/ui/include/track_timer/ui/trackday_screen.hpp index f9ef01f..e3f4ddf 100644 --- a/firmware/components/ui/include/track_timer/ui/trackday_screen.hpp +++ b/firmware/components/ui/include/track_timer/ui/trackday_screen.hpp @@ -53,7 +53,7 @@ class TrackdayScreen { // which a single auto-sizing label cannot honour: a 1 is narrower than an 8. std::array cells_{}; std::array shown_countdown_{}; - std::array shown_laps_{}; + std::array shown_laps_{}; std::uint32_t shown_colour_{0}; std::int32_t shown_bar_width_{-1}; }; diff --git a/firmware/components/ui/input.cpp b/firmware/components/ui/input.cpp index 7fc430a..4cd473f 100644 --- a/firmware/components/ui/input.cpp +++ b/firmware/components/ui/input.cpp @@ -19,6 +19,8 @@ const char* input_action_name(const InputAction action) noexcept return "swipe-down"; case InputAction::swipe_up: return "swipe-up"; + case InputAction::double_tap: + return "double-tap"; } return "unknown"; } diff --git a/firmware/components/ui/shell_navigation.cpp b/firmware/components/ui/shell_navigation.cpp index a113833..3c444dd 100644 --- a/firmware/components/ui/shell_navigation.cpp +++ b/firmware/components/ui/shell_navigation.cpp @@ -195,6 +195,8 @@ ShellResult ShellNavigation::dispatch(const InputAction action) noexcept case InputAction::swipe_up: // Vertical gestures belong to the screen that draws a value, not to navigation. // The shell has nothing above the current level to move to. + case InputAction::double_tap: + // Owned by the running screen, which ends a session with it. case InputAction::none: break; } diff --git a/firmware/components/ui/trackday_screen.cpp b/firmware/components/ui/trackday_screen.cpp index b42df77..7a2c287 100644 --- a/firmware/components/ui/trackday_screen.cpp +++ b/firmware/components/ui/trackday_screen.cpp @@ -117,7 +117,7 @@ void TrackdayScreen::update(const TrackdayModeViewModel& model) noexcept } } - const auto colour = urgency_rgb(model.urgency); + const auto colour = trackday_rgb(model.phase, model.urgency); if (colour != shown_colour_) { shown_colour_ = colour; for (auto* cell : cells_) { @@ -139,24 +139,29 @@ void TrackdayScreen::update(const TrackdayModeViewModel& model) noexcept } } - if (std::strncmp(shown_laps_.data(), model.estimated_laps.data(), - shown_laps_.size()) == 0 && model.estimate_available) { - return; + // Once the session is over a lap estimate says nothing, so the line under the counter + // reports what the timer is doing instead. + const auto* caption = running_phase_caption(model.phase); + const auto* line = caption != nullptr ? caption + : model.estimate_available ? model.estimated_laps.data() + : "SET AVERAGE LAP FOR ESTIMATE"; + if (std::strncmp(shown_laps_.data(), line, shown_laps_.size()) != 0) { + std::snprintf(shown_laps_.data(), shown_laps_.size(), "%s", line); + lv_label_set_text(laps_label_, line); + lv_obj_set_style_text_color( + laps_label_, + lv_color_hex(caption != nullptr ? colour + : model.estimate_available ? 0xFFFFFF + : kMuted), + 0); } - std::snprintf(shown_laps_.data(), shown_laps_.size(), "%s", - model.estimated_laps.data()); - lv_label_set_text(laps_label_, model.estimate_available - ? model.estimated_laps.data() - : "SET AVERAGE LAP FOR ESTIMATE"); - lv_obj_set_style_text_color( - laps_label_, lv_color_hex(model.estimate_available ? 0xFFFFFF : kMuted), 0); - lv_label_set_text(status_label_, model.urgency == SessionUrgency::overtime - ? "OVERTIME" - : mode_note_); + lv_label_set_text(status_label_, + model.phase == RunningPhase::session ? mode_note_ + : "DOUBLE TAP TO CONTINUE"); lv_obj_set_style_text_color( status_label_, - lv_color_hex(model.urgency == SessionUrgency::overtime ? colour : kMuted), 0); + lv_color_hex(model.phase == RunningPhase::session ? kMuted : colour), 0); } void TrackdayScreen::set_mode_note(const char* const note) noexcept diff --git a/firmware/main/screen_router.cpp b/firmware/main/screen_router.cpp index 85bd48f..4c3824c 100644 --- a/firmware/main/screen_router.cpp +++ b/firmware/main/screen_router.cpp @@ -23,6 +23,7 @@ #include "track_timer/ui/navigation.hpp" #include "track_timer/ui/presenter.hpp" #include "track_timer/ui/ready_screen.hpp" +#include "track_timer/ui/rest_session.hpp" #include "track_timer/ui/session_review.hpp" #include "track_timer/ui/session_review_screen.hpp" #include "track_timer/ui/setup_menu_screen.hpp" @@ -208,8 +209,11 @@ class ScreenRouter { const auto now_ms = static_cast(esp_timer_get_time() / 1000); (void)session_.advance(now_ms); const auto snapshot = session_.snapshot(); + // Rest belongs on the running screen too: it is a clock the driver is watching, + // and dropping to the dashboard for it was why the rest period was invisible. const auto active = snapshot.state == session::SessionState::running || - snapshot.state == session::SessionState::overtime; + snapshot.state == session::SessionState::overtime || + snapshot.state == session::SessionState::rest; shell_.synchronize_session(active); if (active != session_was_active_) { @@ -228,6 +232,14 @@ class ScreenRouter { display.average_lap_seconds = settings_.average_lap_seconds; display.trackday_mode_enabled = true; display.session_duration_seconds = settings_.session_duration_seconds; + display.phase = snapshot.state == session::SessionState::overtime + ? ui::RunningPhase::overrun + : snapshot.state == session::SessionState::rest + ? ui::RunningPhase::rest + : ui::RunningPhase::session; + display.overrun_ms = snapshot.session_overrun_ms; + display.rest_remaining_ms = snapshot.rest_remaining_ms; + display.rest_duration_seconds = settings_.rest_duration_seconds; active_session_.update(ui_snapshot, static_cast(now_ms), display); trackday_->update(active_session_.view_model().trackday); @@ -769,6 +781,33 @@ class ScreenRouter { lv_screen_load(home_screen()); } + // A double tap ends whatever clock is running: the session or its overrun give way to + // the rest period, and rest gives way to the dashboard. Two taps rather than one + // because this ends a session, and a stray touch on a moving car must not. + void advance_session_phase() noexcept + { + const auto now_ms = static_cast(esp_timer_get_time() / 1000); + const auto state = session_.snapshot().state; + if (session_.request_stop(now_ms) != session::TransitionResult::accepted) { + return; + } + if (session_.confirm_stop(now_ms) != session::TransitionResult::accepted) { + return; + } + if (state == session::SessionState::running || + state == session::SessionState::overtime) { + // confirm_stop lands in review, which has no screen yet (#142). Carrying + // straight on keeps the tested path intact while the double tap does what it + // says; a review screen later inserts itself here rather than rearranging the + // state machine. + (void)session_.complete_review(now_ms); + } + ESP_LOGI("track_timer", "session phase: %s -> %s", + ui::session_state_name(state), + ui::session_state_name(session_.snapshot().state)); + service_session(); + } + void handle_input(const ui::InputAction action) noexcept { if (roller_active_ && roller_ != nullptr) { @@ -795,6 +834,13 @@ class ScreenRouter { return; } } + // While a clock is running the double tap belongs to the session, not the shell, + // which refuses to open a menu then anyway. + if (session_was_active_ && action == ui::InputAction::double_tap) { + advance_session_phase(); + return; + } + const auto result = shell_.dispatch(action); switch (result.outcome) { case ui::ShellOutcome::menu_opened: diff --git a/simulator/CMakeLists.txt b/simulator/CMakeLists.txt index 325df10..44a2ded 100644 --- a/simulator/CMakeLists.txt +++ b/simulator/CMakeLists.txt @@ -324,7 +324,7 @@ if(BUILD_TESTING) set_tests_properties( simulator-active-session-model PROPERTIES - PASS_REGULAR_EXPRESSION "Deterministic lap feedback and hold-confirm stop controls passed" + PASS_REGULAR_EXPRESSION "Deterministic lap feedback, hold-confirm stop controls, and the overrun and rest phases passed" TIMEOUT 20 ) diff --git a/tests/cpp/test_active_session.cpp b/tests/cpp/test_active_session.cpp index 1357e13..5ff73c4 100644 --- a/tests/cpp/test_active_session.cpp +++ b/tests/cpp/test_active_session.cpp @@ -164,6 +164,76 @@ int main() controls.confirm_stop(20'001); assert(!controls.consume_stop_request()); - std::cout << "Deterministic lap feedback and hold-confirm stop controls passed\n"; + // A session that runs out has to say so. The countdown field turns round and counts + // up, and the line that carried a lap estimate reports the state instead, because an + // estimate is meaningless once the session is over. + ui::ActiveSessionController phases; + auto running = active_snapshot(); + running.session_remaining_ms = 4 * 60'000 + 30'000; + ui::ActiveSessionDisplayConfig config{}; + config.average_lap_seconds = 90; + config.trackday_mode_enabled = true; + config.session_duration_seconds = 20 * 60; + config.rest_duration_seconds = 10 * 60; + + phases.update(running, 0, config); + { + const auto& view = phases.view_model().trackday; + assert(view.phase == ui::RunningPhase::session); + assert(std::strcmp(view.countdown.data(), "04:30") == 0); + assert(ui::running_phase_caption(view.phase) == nullptr); // the estimate shows + assert(view.estimate_available); + assert(ui::trackday_rgb(view.phase, view.urgency) == ui::urgency_rgb(view.urgency)); + } + + config.phase = ui::RunningPhase::overrun; + config.overrun_ms = 95'000; + phases.update(running, 100, config); + { + const auto& view = phases.view_model().trackday; + assert(view.phase == ui::RunningPhase::overrun); + // Counting up, and without a sign: the field is five fixed cells wide. + assert(std::strcmp(view.countdown.data(), "01:35") == 0); + assert(std::strcmp(ui::running_phase_caption(view.phase), "OVER RUN") == 0); + assert(!view.estimate_available); + assert(view.remaining_ratio == 0.0F); + assert(ui::trackday_rgb(view.phase, view.urgency) == ui::kOverrunRgb); + } + + config.overrun_ms = 61 * 60'000 + 7'000; // past an hour, still minutes and seconds + phases.update(running, 200, config); + assert(std::strcmp(phases.view_model().trackday.countdown.data(), "61:07") == 0); + + // Rest counts down exactly as the session does, against its own duration. + config.phase = ui::RunningPhase::rest; + config.rest_remaining_ms = 10 * 60'000; + phases.update(running, 300, config); + { + const auto& view = phases.view_model().trackday; + assert(std::strcmp(view.countdown.data(), "10:00") == 0); + assert(std::strcmp(ui::running_phase_caption(view.phase), "REST") == 0); + assert(view.remaining_ratio > 0.99F); + assert(view.urgency == ui::SessionUrgency::ample); + assert(ui::trackday_rgb(view.phase, view.urgency) == ui::urgency_rgb(view.urgency)); + } + + config.rest_remaining_ms = 30'000; + phases.update(running, 400, config); + { + const auto& view = phases.view_model().trackday; + assert(std::strcmp(view.countdown.data(), "00:30") == 0); + assert(view.remaining_ratio < 0.1F); + assert(view.urgency == ui::SessionUrgency::critical); + } + + // The overrun colour must not be mistakable for any point on the ramp. + for (const auto urgency : {ui::SessionUrgency::ample, ui::SessionUrgency::easing, + ui::SessionUrgency::closing, ui::SessionUrgency::urgent, + ui::SessionUrgency::critical}) { + assert(ui::urgency_rgb(urgency) != ui::kOverrunRgb); + } + + std::cout << "Deterministic lap feedback, hold-confirm stop controls, and the " + "overrun and rest phases passed\n"; return 0; }