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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ 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
- session records kept on the card, so a driver's sessions outlive a power cycle
- 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
Expand Down
19 changes: 17 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,16 @@ 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
SUMMARY_FILE_TEST_BINARY := build/host/summary_file_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 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
.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 summary-file-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 summary-store-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 summary-file-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 @@ -425,6 +426,20 @@ gate-session-automation-test:
-o $(GATE_SESSION_AUTOMATION_TEST_BINARY)
$(GATE_SESSION_AUTOMATION_TEST_BINARY)

summary-file-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 \
firmware/components/logger/summary_frame.cpp \
firmware/components/logger/file_summary_store.cpp \
tests/cpp/test_summary_file.cpp -o $(SUMMARY_FILE_TEST_BINARY)
$(SUMMARY_FILE_TEST_BINARY)

summary-store-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" "memory_summary_store.cpp" "async_logger.cpp" "task_esp.cpp"
SRCS "component.cpp" "formats.cpp" "memory_summary_store.cpp" "summary_frame.cpp" "file_summary_store.cpp" "async_logger.cpp" "task_esp.cpp"
INCLUDE_DIRS "include"
REQUIRES domain board settings esp_timer freertos
)
142 changes: 142 additions & 0 deletions firmware/components/logger/file_summary_store.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
#include "track_timer/logger/file_summary_store.hpp"

#include <array>
#include <algorithm>
#include <cstdio>

namespace track_timer::logger {
namespace {

// Only the newest sessions need to be readable, so a card that has been in the device all
// season is not read into RAM in its entirety.
constexpr std::size_t kFramesRead = MemorySummaryStore::kCapacity;

} // namespace

FileSummaryStore::OpenResult FileSummaryStore::open(const char* const path) noexcept
{
cache_.clear();
scan_ = {};
path_ = path;
persistent_ = false;
if (path == nullptr) {
return OpenResult::unreadable;
}

auto* file = std::fopen(path, "rb");
if (file == nullptr) {
// No file yet is the ordinary state of a new card, and is not a failure: the first
// append creates it. Whether the path is writable is answered then, not now.
persistent_ = true;
return OpenResult::no_file;
}

if (std::fseek(file, 0, SEEK_END) != 0) {
(void)std::fclose(file);
return OpenResult::unreadable;
}
const auto end = std::ftell(file);
if (end < 0) {
(void)std::fclose(file);
return OpenResult::unreadable;
}

const auto total = static_cast<std::size_t>(end);
const auto window = kFramesRead * kSummaryFrameSize;
// Frames are a fixed size and only ever appended, so any multiple of that size is a
// frame boundary. Seeking to one keeps the tail aligned however long the file is.
const auto start = total > window ? ((total - window) / kSummaryFrameSize) * kSummaryFrameSize
: 0U;
if (std::fseek(file, static_cast<long>(start), SEEK_SET) != 0) {
(void)std::fclose(file);
return OpenResult::unreadable;
}

// Fixed storage, like everywhere else here: no dynamic allocation, and the window is
// bounded by design so the buffer can be sized for the worst case up front. About 1.2 KB
// on the stack, against a 24 KB task.
std::array<std::uint8_t, kFramesRead * kSummaryFrameSize> buffer{};
const auto wanted = std::min(buffer.size(), total - start);
const auto read = wanted == 0 ? 0U : std::fread(buffer.data(), 1, wanted, file);
(void)std::fclose(file);

// Oldest first out of the file, so the newest ends up at the front of the cache.
scan_ = scan_summary_frames(buffer.data(), read,
[this](const SessionSummaryV1& summary) {
(void)cache_.record(summary);
});
persistent_ = true;
return OpenResult::ready;
}

bool FileSummaryStore::append(const SessionSummaryV1& summary) noexcept
{
// The cache first, so a session survives in Review even when the card refuses it.
const auto cached = cache_.record(summary);
if (!cached) {
return false;
}
if (path_ == nullptr) {
return false;
}

SummaryFrame frame{};
if (!encode_summary_frame(summary, frame)) {
++write_failures_;
return false;
}

auto* file = std::fopen(path_, "ab");
if (file == nullptr) {
++write_failures_;
persistent_ = false;
return false;
}
const auto written = std::fwrite(frame.bytes.data(), 1, frame.size, file);
// Flushed before the handle goes, so a card pulled a moment later has the record rather
// than whatever the buffer happened to hold.
const auto flushed = std::fflush(file) == 0;
const auto closed = std::fclose(file) == 0;
if (written != frame.size || !flushed || !closed) {
++write_failures_;
persistent_ = false;
return false;
}
persistent_ = true;
return true;
}

void FileSummaryStore::close() noexcept
{
cache_.clear();
scan_ = {};
path_ = nullptr;
persistent_ = false;
write_failures_ = 0;
}

SummaryReadResult FileSummaryStore::session_count(std::size_t& count) noexcept
{
return cache_.session_count(count);
}

SummaryReadResult FileSummaryStore::read_summary(const std::size_t history_index,
SessionSummaryV1& summary) noexcept
{
return cache_.read_summary(history_index, summary);
}

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

bool FileSummaryStore::persistent() const noexcept { return persistent_; }

const SummaryScanReport& FileSummaryStore::scan_report() const noexcept { return scan_; }

std::size_t FileSummaryStore::write_failure_count() const noexcept { return write_failures_; }

} // namespace track_timer::logger
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#pragma once

#include "track_timer/logger/memory_summary_store.hpp"
#include "track_timer/logger/summary_frame.hpp"

#include <cstddef>

namespace track_timer::logger {

// Session summaries kept on the card, so a driver's sessions outlive a power cycle.
//
// The card is not assumed to be there. Every failure - absent, full, pulled mid-write -
// leaves the in-memory history intact and reports itself, because a session that has just
// been driven matters more than the record of it, and losing the timer because a card
// misbehaved would be the worse failure by far.
class FileSummaryStore final : public SessionSummaryProvider {
public:
enum class OpenResult : std::uint8_t {
ready, // a file was read
no_file, // nothing there yet, which is simply a card with no sessions on it
unreadable, // a path that cannot be opened, usually no card
};

// Reads what is on the card into memory. The cache is the newest kCapacity sessions,
// so a long-lived card does not have to be held in RAM to be reviewed.
OpenResult open(const char* path) noexcept;
// Appends to the card and updates the cache. Returns false when the record could not be
// written; the cache still holds it, so Review shows the session either way.
bool append(const SessionSummaryV1& summary) noexcept;
void close() noexcept;

[[nodiscard]] SummaryReadResult session_count(std::size_t& count) noexcept override;
[[nodiscard]] SummaryReadResult read_summary(std::size_t history_index,
SessionSummaryV1& summary) noexcept override;
[[nodiscard]] SummaryReadResult read_lap_page(
const std::array<char, kSessionIdentifierCapacity>& session_id, std::size_t offset,
SummaryLapPage& page) noexcept override;

[[nodiscard]] bool persistent() const noexcept;
[[nodiscard]] const SummaryScanReport& scan_report() const noexcept;
[[nodiscard]] std::size_t write_failure_count() const noexcept;

private:
MemorySummaryStore cache_{};
const char* path_{nullptr};
SummaryScanReport scan_{};
std::size_t write_failures_{0};
bool persistent_{false};
};

} // namespace track_timer::logger
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
#pragma once

#include "track_timer/logger/formats.hpp"

#include <array>
#include <cstddef>
#include <cstdint>
#include <type_traits>

namespace track_timer::logger {

// Session summaries are appended to one file as framed records, so a session that ends
// badly can only ever damage the record it was writing.
//
// Each frame carries its own magic, version, length and checksum. A reader that meets a
// torn or corrupt frame stops trusting that frame and keeps everything before it, which is
// the behaviour that matters when a card is pulled mid-write: the sessions already on the
// card survive.
//
// The payload is the record's bytes. That makes this file device-internal rather than a
// portable export - a host tool cannot assume the same layout - which is deliberate:
// human-readable session output belongs with the per-session log files in #38, and paying
// for a field-by-field codec here would buy portability nothing yet needs.
inline constexpr std::array<std::uint8_t, 4> kSummaryFrameMagic{'T', 'S', 'S', 'F'};
inline constexpr std::size_t kSummaryFrameHeaderSize = 12;
inline constexpr std::size_t kSummaryFrameSize =
kSummaryFrameHeaderSize + sizeof(SessionSummaryV1);

static_assert(std::is_trivially_copyable_v<SessionSummaryV1>,
"the frame payload is the record's bytes");

struct SummaryFrame {
std::array<std::uint8_t, kSummaryFrameSize> bytes{};
std::size_t size{0};
};

enum class FrameResult : std::uint8_t {
ready,
too_short, // fewer bytes than a frame needs; a torn tail looks like this
bad_magic,
unsupported_version,
bad_checksum,
invalid_record, // framed correctly but the record fails its own validator
};

// Refuses to frame a record that would not survive its own validator, so nothing invalid
// reaches the card in the first place.
[[nodiscard]] bool encode_summary_frame(const SessionSummaryV1& summary,
SummaryFrame& frame) noexcept;

[[nodiscard]] FrameResult decode_summary_frame(const std::uint8_t* bytes, std::size_t size,
SessionSummaryV1& summary) noexcept;

struct SummaryScanReport {
std::size_t accepted{0};
// Frames that were intact enough to skip past but not to trust. Counted rather than
// hidden, because a card quietly dropping records should be visible in diagnostics.
std::size_t skipped{0};
// A final frame shorter than a whole one, which is what a pull mid-write leaves behind.
bool truncated_tail{false};
};

// Walks a buffer of appended frames. Frames are fixed size, so a corrupt one in the middle
// can be stepped over and the records after it still recovered - stopping at the first bad
// frame would throw away good sessions to protect a bad one.
template <typename Sink>
SummaryScanReport scan_summary_frames(const std::uint8_t* bytes, std::size_t size,
Sink&& sink) noexcept
{
SummaryScanReport report{};
std::size_t offset = 0;
while (offset < size) {
const auto remaining = size - offset;
SessionSummaryV1 summary{};
const auto result = decode_summary_frame(bytes + offset, remaining, summary);
if (result == FrameResult::too_short) {
report.truncated_tail = true;
break;
}
if (result == FrameResult::ready) {
sink(summary);
++report.accepted;
}
else {
++report.skipped;
}
offset += kSummaryFrameSize;
}
return report;
}

} // namespace track_timer::logger
Loading