Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
79 changes: 70 additions & 9 deletions firmware/components/ui/active_session.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,20 @@ void format_delta(std::array<char, 32>& output, const std::int64_t delta_ms,
static_cast<unsigned long long>(absolute_ms % 1'000), suffix);
}

void format_running_clock(std::array<char, 32>& 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::uint64_t>(std::max<std::int64_t>(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<unsigned long long>(absolute_ms / 60'000),
static_cast<unsigned long long>((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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<std::int64_t>(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<std::int64_t>(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<std::int64_t>(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');
Expand Down
19 changes: 17 additions & 2 deletions firmware/components/ui/gesture_input.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
29 changes: 29 additions & 0 deletions firmware/components/ui/include/track_timer/ui/active_session.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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
Expand All @@ -68,6 +83,7 @@ enum class SessionUrgency : std::uint8_t {

struct TrackdayModeViewModel {
std::array<char, 32> countdown{};
RunningPhase phase{RunningPhase::session};
std::array<char, 24> 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};
Expand All @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions firmware/components/ui/include/track_timer/ui/input.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ class TrackdayScreen {
// which a single auto-sizing label cannot honour: a 1 is narrower than an 8.
std::array<lv_obj_t*, 5> cells_{};
std::array<char, 32> shown_countdown_{};
std::array<char, 24> shown_laps_{};
std::array<char, 32> shown_laps_{};
std::uint32_t shown_colour_{0};
std::int32_t shown_bar_width_{-1};
};
Expand Down
2 changes: 2 additions & 0 deletions firmware/components/ui/input.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down
2 changes: 2 additions & 0 deletions firmware/components/ui/shell_navigation.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
35 changes: 20 additions & 15 deletions firmware/components/ui/trackday_screen.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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_) {
Expand All @@ -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
Expand Down
48 changes: 47 additions & 1 deletion firmware/main/screen_router.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -208,8 +209,11 @@ class ScreenRouter {
const auto now_ms = static_cast<std::int64_t>(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_) {
Expand All @@ -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<std::uint64_t>(now_ms), display);
trackday_->update(active_session_.view_model().trackday);
Expand Down Expand Up @@ -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<std::int64_t>(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) {
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion simulator/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
Loading