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,11 @@ The project follows Semantic Versioning once the first firmware release is tagge

### Fixed

- G peaks were never scoped to a session, so they described everything since boot; the meter
was still being told no session was ever running
- the vertical axis was computed and then dropped before it reached the meter
- Review rendered its summary rows at 0.82 mm, which on this panel reads as an empty screen

- the launch sensitivity ladder ran to 4 g, which a car cannot reach as forward
acceleration, so six of its ten choices could never fire and nothing was offered below
0.5 g where a deliberate pit exit sits; stored values are snapped onto the new ladder
Expand Down Expand Up @@ -68,6 +73,9 @@ 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
- a finished session leaves a record: duration, overrun and peak G on every axis, shown in
Review
- vertical G recorded alongside the horizontal pair, with kerbs and compressions kept apart
- the gated menu opens on REVIEW, which is wanted the moment a session ends
- top-level TRIGGER selection: MANUAL starts on the button, IMU on a launch, GPS at the line
- pending phase on the running screen for a session armed and waiting for its trigger
Expand Down
17 changes: 15 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,16 @@ IMU_CALIBRATION_TEST_BINARY := build/host/imu_calibration_test
SESSION_URGENCY_TEST_BINARY := build/host/session_urgency_test
TIME_ROLLER_TEST_BINARY := build/host/time_roller_test
SESSION_TRIGGER_TEST_BINARY := build/host/session_trigger_test
SUMMARY_STORE_TEST_BINARY := build/host/summary_store_test
GATE_CAPTURE_TEST_BINARY := build/host/gate_capture_test
GATE_SESSION_AUTOMATION_TEST_BINARY := build/host/gate_session_automation_test
SIMULATOR_BUILD_DIR ?= build/simulator
SIMULATOR_IMAGE ?= track-session-timer-simulator:lvgl-9.5.0
CMAKE ?= cmake

.PHONY: check test track-validate uk-track-pack track-pack-test simulator-track-catalog-test simulator-fixture-validate repo-check host-test simulator-model-test session-state-test settings-test settings-editor-test projection-test intersection-test crossing-validation-test crossing-time-test lap-state-machine-test timing-engine-test gate-event-engine-test track-definition-test track-capture-test track-matching-test track-selection-test log-format-test async-logger-test session-review-test diagnostics-test active-session-test display-policy-test imu-meter-test rest-session-test track-catalog-test shell-navigation-test device-mode-test imu-calibration-test session-urgency-test time-roller-test session-trigger-test gate-capture-test gate-session-automation-test ui-foundation-test navigation-test simulator-configure simulator-build simulator-test simulator-run simulator-container-image simulator-container-test simulator-clean firmware-build firmware-container-build firmware-container-flash firmware-container-monitor firmware-container-flash-monitor firmware-container-erase firmware-device-info firmware-clean issue-preview label-preview
.PHONY: check test track-validate uk-track-pack track-pack-test simulator-track-catalog-test simulator-fixture-validate repo-check host-test simulator-model-test session-state-test settings-test settings-editor-test projection-test intersection-test crossing-validation-test crossing-time-test lap-state-machine-test timing-engine-test gate-event-engine-test track-definition-test track-capture-test track-matching-test track-selection-test log-format-test async-logger-test session-review-test diagnostics-test active-session-test display-policy-test imu-meter-test rest-session-test track-catalog-test shell-navigation-test device-mode-test imu-calibration-test session-urgency-test time-roller-test session-trigger-test summary-store-test gate-capture-test gate-session-automation-test ui-foundation-test navigation-test simulator-configure simulator-build simulator-test simulator-run simulator-container-image simulator-container-test simulator-clean firmware-build firmware-container-build firmware-container-flash firmware-container-monitor firmware-container-flash-monitor firmware-container-erase firmware-device-info firmware-clean issue-preview label-preview

check: test track-validate track-pack-test simulator-track-catalog-test simulator-fixture-validate repo-check host-test simulator-model-test session-state-test settings-test settings-editor-test projection-test intersection-test crossing-validation-test crossing-time-test lap-state-machine-test timing-engine-test gate-event-engine-test track-definition-test track-capture-test track-matching-test track-selection-test log-format-test async-logger-test session-review-test diagnostics-test active-session-test display-policy-test imu-meter-test rest-session-test track-catalog-test shell-navigation-test device-mode-test imu-calibration-test session-urgency-test time-roller-test session-trigger-test gate-capture-test gate-session-automation-test ui-foundation-test navigation-test
check: test track-validate track-pack-test simulator-track-catalog-test simulator-fixture-validate repo-check host-test simulator-model-test session-state-test settings-test settings-editor-test projection-test intersection-test crossing-validation-test crossing-time-test lap-state-machine-test timing-engine-test gate-event-engine-test track-definition-test track-capture-test track-matching-test track-selection-test log-format-test async-logger-test session-review-test diagnostics-test active-session-test display-policy-test imu-meter-test rest-session-test track-catalog-test shell-navigation-test device-mode-test imu-calibration-test session-urgency-test time-roller-test session-trigger-test summary-store-test gate-capture-test gate-session-automation-test ui-foundation-test navigation-test

test:
$(PYTHON) -B -m unittest discover -s tests -p 'test_*.py'
Expand Down Expand Up @@ -424,6 +425,18 @@ gate-session-automation-test:
-o $(GATE_SESSION_AUTOMATION_TEST_BINARY)
$(GATE_SESSION_AUTOMATION_TEST_BINARY)

summary-store-test:
mkdir -p build/host
$(CXX) -std=c++17 -Wall -Wextra -Werror -pedantic \
-Ifirmware/components/domain/include \
-Ifirmware/components/settings/include \
-Ifirmware/components/logger/include \
firmware/components/settings/component.cpp \
firmware/components/logger/formats.cpp \
firmware/components/logger/memory_summary_store.cpp \
tests/cpp/test_summary_store.cpp -o $(SUMMARY_STORE_TEST_BINARY)
$(SUMMARY_STORE_TEST_BINARY)

session-trigger-test:
mkdir -p build/host
$(CXX) -std=c++17 -Wall -Wextra -Werror -pedantic \
Expand Down
2 changes: 1 addition & 1 deletion firmware/components/logger/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
idf_component_register(
SRCS "component.cpp" "formats.cpp" "async_logger.cpp" "task_esp.cpp"
SRCS "component.cpp" "formats.cpp" "memory_summary_store.cpp" "async_logger.cpp" "task_esp.cpp"
INCLUDE_DIRS "include"
REQUIRES domain board settings esp_timer freertos
)
13 changes: 13 additions & 0 deletions firmware/components/logger/formats.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -290,8 +290,21 @@ bool valid_event_record(const EventRecordV1& record) noexcept
record.session_overrun_ms <= record.session_elapsed_ms;
}

// A peak is a magnitude, so it cannot be negative, and anything beyond what a car can
// physically pull says the reading is wrong rather than remarkable.
bool valid_peak(const float value) noexcept
{
return value >= 0.0F && value <= kMaximumCrediblePeakG;
}

bool valid_summary(const SessionSummaryV1& record) noexcept
{
if (!valid_peak(record.peaks.acceleration_g) || !valid_peak(record.peaks.braking_g) ||
!valid_peak(record.peaks.left_g) || !valid_peak(record.peaks.right_g) ||
!valid_peak(record.peaks.up_g) || !valid_peak(record.peaks.down_g) ||
!valid_peak(record.peaks.total_g)) {
return false;
}
if (record.schema_version != kLogFormatVersion ||
record.record_size_bytes != sizeof(SessionSummaryV1) || !non_empty(record.session_id) ||
record.session_duration_ms < 0 || record.session_overrun_ms < 0 ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,14 @@ namespace track_timer::logger {
// 2: session and rest durations widened from uint16 minutes to uint32 seconds, which
// changes both the meaning and the size of every meta record. Logs written before this
// are rejected by their version rather than silently misread as very short sessions.
inline constexpr std::uint16_t kLogFormatVersion = 2;
// 3: peak G on the session summary. Peaks recorded before the gravity reference was
// tracked with the gyroscope latched tilt as acceleration and had left and right swapped,
// so older summaries are refused by version rather than migrated - they are wrong, not old.
inline constexpr std::uint16_t kLogFormatVersion = 3;
inline constexpr std::int64_t kUnavailableUtcNs = -1;
inline constexpr std::size_t kSessionIdentifierCapacity = 32;
// Well beyond a road car on a circuit, and far short of anything a working sensor reports.
inline constexpr float kMaximumCrediblePeakG = 10.0F;
inline constexpr std::size_t kFirmwareCommitCapacity = 41;
inline constexpr std::size_t kProfileNameCapacity = 32;
inline constexpr std::size_t kTrackFingerprintCapacity = 17;
Expand Down Expand Up @@ -155,6 +160,18 @@ struct EventRecordV1 {
std::int64_t session_overrun_ms{domain::kUnavailableTime};
};

// What the car pulled during a session. Vertical is kept apart from the horizontal pair,
// and up from down, because a kerb strike and a compression are different events.
struct SummaryPeakG {
float acceleration_g{0.0F};
float braking_g{0.0F};
float left_g{0.0F};
float right_g{0.0F};
float up_g{0.0F};
float down_g{0.0F};
float total_g{0.0F};
};

struct SessionSummaryV1 {
std::uint16_t schema_version{kLogFormatVersion};
std::uint16_t record_size_bytes{0};
Expand All @@ -175,6 +192,7 @@ struct SessionSummaryV1 {
std::uint32_t source_event_record_count{0};
std::uint32_t logger_dropped_record_count{0};
std::uint32_t logger_write_failure_count{0};
SummaryPeakG peaks{};
};

struct SummaryLapRecordV1 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#pragma once

#include "track_timer/logger/summary_provider.hpp"

#include <array>
#include <cstddef>

namespace track_timer::logger {

// Holds the last few session summaries in RAM so Review has something to show the moment a
// session ends.
//
// Deliberately not the durable answer: nothing here survives a power cycle, and the card is
// where these belong. It exists so the record's shape is settled and visible before the
// write path is built on top of it, and so Review stops being wired to nullptr.
class MemorySummaryStore final : public SessionSummaryProvider {
public:
static constexpr std::size_t kCapacity = 8;

// Newest first. Returns false if the summary would not survive its own validator, so a
// malformed record cannot reach Review and be rendered as though it were real.
bool record(const SessionSummaryV1& summary) noexcept;
void clear() noexcept;

[[nodiscard]] SummaryReadResult session_count(std::size_t& count) noexcept override;
[[nodiscard]] SummaryReadResult read_summary(std::size_t history_index,
SessionSummaryV1& summary) noexcept override;
// No laps without a receiver. Reported as an empty page rather than an error, because
// a session genuinely having no laps is not a fault.
[[nodiscard]] SummaryReadResult read_lap_page(
const std::array<char, kSessionIdentifierCapacity>& session_id, std::size_t offset,
SummaryLapPage& page) noexcept override;

[[nodiscard]] std::size_t rejected_count() const noexcept;

private:
std::array<SessionSummaryV1, kCapacity> summaries_{};
std::size_t count_{0};
std::size_t rejected_{0};
};

} // namespace track_timer::logger
63 changes: 63 additions & 0 deletions firmware/components/logger/memory_summary_store.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#include "track_timer/logger/memory_summary_store.hpp"

#include <algorithm>

namespace track_timer::logger {

bool MemorySummaryStore::record(const SessionSummaryV1& summary) noexcept
{
if (!valid_summary(summary)) {
++rejected_;
return false;
}
// Newest first, so history_index 0 is the session that just finished. The oldest falls
// off the end rather than the newest being refused: a driver wants the last session far
// more than the eighth one back.
const auto keep = std::min(count_, kCapacity - 1);
for (std::size_t index = keep; index > 0; --index) {
summaries_[index] = summaries_[index - 1];
}
summaries_[0] = summary;
count_ = std::min(count_ + 1, kCapacity);
return true;
}

void MemorySummaryStore::clear() noexcept
{
summaries_.fill({});
count_ = 0;
rejected_ = 0;
}

SummaryReadResult MemorySummaryStore::session_count(std::size_t& count) noexcept
{
count = count_;
return count_ == 0 ? SummaryReadResult::empty : SummaryReadResult::ready;
}

SummaryReadResult MemorySummaryStore::read_summary(const std::size_t history_index,
SessionSummaryV1& summary) noexcept
{
if (count_ == 0) {
return SummaryReadResult::empty;
}
if (history_index >= count_) {
return SummaryReadResult::corrupt;
}
summary = summaries_[history_index];
return SummaryReadResult::ready;
}

SummaryReadResult MemorySummaryStore::read_lap_page(
const std::array<char, kSessionIdentifierCapacity>& session_id, const std::size_t offset,
SummaryLapPage& page) noexcept
{
(void)session_id;
page = {};
page.offset = offset;
return SummaryReadResult::ready;
}

std::size_t MemorySummaryStore::rejected_count() const noexcept { return rejected_; }

} // namespace track_timer::logger
13 changes: 13 additions & 0 deletions firmware/components/ui/imu_meter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@ const ImuMeterSnapshot& ImuMeterController::update(const ImuMeterInput& input,
const auto point = rotate_sample(input);
snapshot_.current = point;
append(point);
// Vertical does not rotate with the display: up is up however the unit is mounted.
snapshot_.vertical_valid = input.sample_available && input.z_axis_valid;
snapshot_.vertical_g =
snapshot_.vertical_valid
? clamp_g(input.sample.acceleration_z_mps2 / kStandardGravityMps2)
: 0.0F;

update_peaks(point);
increment_saturated(snapshot_.accepted_samples);

Expand Down Expand Up @@ -130,6 +137,8 @@ PlanarAcceleration ImuMeterController::trail_point(
void ImuMeterController::clear_measurements() noexcept
{
snapshot_.current = {};
snapshot_.vertical_g = 0.0F;
snapshot_.vertical_valid = false;
snapshot_.peaks = {};
snapshot_.trail.fill({});
snapshot_.trail_count = 0;
Expand All @@ -147,6 +156,10 @@ void ImuMeterController::append(const PlanarAcceleration point) noexcept

void ImuMeterController::update_peaks(const PlanarAcceleration& point) noexcept
{
if (snapshot_.vertical_valid) {
snapshot_.peaks.up_g = std::max(snapshot_.peaks.up_g, snapshot_.vertical_g);
snapshot_.peaks.down_g = std::max(snapshot_.peaks.down_g, -snapshot_.vertical_g);
}
if (point.longitudinal_valid) {
snapshot_.peaks.acceleration_g =
std::max(snapshot_.peaks.acceleration_g, point.longitudinal_g);
Expand Down
14 changes: 13 additions & 1 deletion firmware/components/ui/include/track_timer/ui/imu_meter.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@

namespace track_timer::ui {

inline constexpr std::uint32_t kImuMeterSchemaVersion = 1;
// 2: the vertical axis. Peaks recorded before the gravity reference was tracked with the
// gyroscope latched tilt as acceleration and had left and right swapped, so they are wrong
// rather than merely old and are not migrated.
inline constexpr std::uint32_t kImuMeterSchemaVersion = 2;
inline constexpr std::size_t kImuTrailCapacity = 24;
inline constexpr float kStandardGravityMps2 = 9.80665F;
inline constexpr float kImuDisplayLimitG = 2.0F;
Expand All @@ -31,6 +34,8 @@ struct ImuMeterInput {
bool x_axis_valid{false};
bool y_axis_valid{false};
bool calibrating{false};
// Appended: the vertical axis arrives on the sample's z component.
bool z_axis_valid{false};
};

struct PlanarAcceleration {
Expand All @@ -47,13 +52,20 @@ struct ImuPeakSummary {
float right_g{0.0F};
float total_g{0.0F};
PlanarAcceleration total_position{};
// Kept apart from the horizontal pair and from each other: a kerb strike throws the car
// up and a compression loads it down, and they are different events to a driver.
float up_g{0.0F};
float down_g{0.0F};
};

struct ImuMeterSnapshot {
std::uint32_t schema_version{kImuMeterSchemaVersion};
ImuMeterState state{ImuMeterState::unavailable};
board::DisplayOrientation orientation{board::DisplayOrientation::degrees_0};
PlanarAcceleration current{};
// Unaffected by the display rotation: up is up however the unit is mounted.
float vertical_g{0.0F};
bool vertical_valid{false};
ImuPeakSummary peaks{};
std::array<PlanarAcceleration, kImuTrailCapacity> trail{};
std::size_t trail_count{0};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ struct SessionReviewViewModel {
std::array<char, 32> completion{};
std::array<char, 64> integrity{};
std::array<char, 96> message{};
// What the car pulled. Recorded on the summary since the peaks landed there, and shown
// here because a session without lap times still has this much to say about itself.
std::array<char, 16> peak_total{};
std::array<char, 32> peak_longitudinal{};
std::array<char, 32> peak_lateral{};
std::array<char, 32> peak_vertical{};
std::array<SessionReviewLapRow, logger::kSummaryLapPageCapacity> laps{};
bool newer_session_enabled{false};
bool older_session_enabled{false};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ class SessionReviewScreen {
lv_obj_t* completion_{nullptr};
lv_obj_t* integrity_{nullptr};
lv_obj_t* message_{nullptr};
lv_obj_t* peak_caption_{nullptr};
lv_obj_t* peak_total_{nullptr};
lv_obj_t* peak_longitudinal_{nullptr};
lv_obj_t* peak_lateral_{nullptr};
lv_obj_t* peak_vertical_{nullptr};
std::array<lv_obj_t*, logger::kSummaryLapPageCapacity> lap_panels_{};
std::array<lv_obj_t*, logger::kSummaryLapPageCapacity> lap_labels_{};
std::array<lv_obj_t*, logger::kSummaryLapPageCapacity> lap_durations_{};
Expand Down
20 changes: 20 additions & 0 deletions firmware/components/ui/session_review.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ void SessionReviewController::update_view() noexcept
view_.completion.fill('\0');
view_.integrity.fill('\0');
view_.message.fill('\0');
view_.peak_total.fill('\0');
view_.peak_longitudinal.fill('\0');
view_.peak_lateral.fill('\0');
view_.peak_vertical.fill('\0');
for (auto& row : view_.laps) {
row = {};
}
Expand Down Expand Up @@ -289,6 +293,22 @@ void SessionReviewController::update_view() noexcept
std::snprintf(view_.completion.data(), view_.completion.size(), "ENDED %s",
completion_name(summary_.completion_reason));

// Two decimals, because the difference between 0.94 and 1.02 g is the difference
// between a good corner and a very good one.
const auto& peaks = summary_.peaks;
std::snprintf(view_.peak_total.data(), view_.peak_total.size(), "%.2f",
static_cast<double>(peaks.total_g));
std::snprintf(view_.peak_longitudinal.data(), view_.peak_longitudinal.size(),
"ACC %.2f BRK %.2f", static_cast<double>(peaks.acceleration_g),
static_cast<double>(peaks.braking_g));
std::snprintf(view_.peak_lateral.data(), view_.peak_lateral.size(),
"LEFT %.2f RIGHT %.2f", static_cast<double>(peaks.left_g),
static_cast<double>(peaks.right_g));
// Up and down kept apart: a kerb and a compression are different events.
std::snprintf(view_.peak_vertical.data(), view_.peak_vertical.size(),
"UP %.2f DOWN %.2f", static_cast<double>(peaks.up_g),
static_cast<double>(peaks.down_g));

if (view_.status == SessionReviewStatus::partial_log) {
set_text(view_.integrity, "PARTIAL LOG - RESULTS MAY BE INCOMPLETE");
}
Expand Down
Loading