From 774537952bcdfd90cc1b4042f1e4a2b418dfd46a Mon Sep 17 00:00:00 2001 From: mrsqr Date: Thu, 20 Aug 2026 23:42:40 +0100 Subject: [PATCH] feat(logger): keep session records on the card Stage A left the record in RAM, so a driver's sessions were gone the moment the device lost power. They are appended to the card now, and read back at boot, so Review opens on the last session driven rather than on nothing. One file of fixed-size framed records rather than a directory per session: this is the Review index, and the per-session logs #38 will write are a separate thing. Each frame carries its own magic, version, length and checksum, which is what makes a card pulled mid-write cost only the record being written. A corrupt frame in the middle is stepped over rather than stopping the read. Stopping at the first bad frame is the obvious implementation and it throws away good sessions to protect a bad one; because frames are a fixed size, the reader can step to the next boundary and recover everything after the damage. The card is never assumed to be there. A failed write costs the durability of one record and nothing else - the cache takes it first, so Review shows the session either way. Losing the timer because a card misbehaved would be far the worse failure, and this is the subsystem that has never run on this device while a receiver is about to land on top of it at 25 Hz. Because the card mounts through VFS, the store uses plain stdio and the host tests exercise the real write path rather than a mock: a reopen, a frame truncated mid-record as an interrupted write leaves it, a byte flipped inside a middle frame, a path that cannot be opened at all, and a file longer than memory where the window has to seek to a frame boundary to avoid starting halfway through a record. The payload is the record's bytes, which makes the file device-internal rather than a portable export. Human-readable session output belongs with the per-session logs, and a field-by-field codec would buy portability nothing needs yet. Part of #137 and #38 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + Makefile | 19 +- firmware/components/logger/CMakeLists.txt | 2 +- .../components/logger/file_summary_store.cpp | 142 +++++++++++ .../track_timer/logger/file_summary_store.hpp | 51 ++++ .../track_timer/logger/summary_frame.hpp | 92 +++++++ firmware/components/logger/summary_frame.cpp | 104 ++++++++ firmware/main/screen_router.cpp | 28 ++- tests/cpp/test_summary_file.cpp | 233 ++++++++++++++++++ 9 files changed, 665 insertions(+), 7 deletions(-) create mode 100644 firmware/components/logger/file_summary_store.cpp create mode 100644 firmware/components/logger/include/track_timer/logger/file_summary_store.hpp create mode 100644 firmware/components/logger/include/track_timer/logger/summary_frame.hpp create mode 100644 firmware/components/logger/summary_frame.cpp create mode 100644 tests/cpp/test_summary_file.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 02269be..994c049 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Makefile b/Makefile index 117983f..a0dca81 100644 --- a/Makefile +++ b/Makefile @@ -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' @@ -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 \ diff --git a/firmware/components/logger/CMakeLists.txt b/firmware/components/logger/CMakeLists.txt index 2571ad0..31b42fa 100644 --- a/firmware/components/logger/CMakeLists.txt +++ b/firmware/components/logger/CMakeLists.txt @@ -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 ) diff --git a/firmware/components/logger/file_summary_store.cpp b/firmware/components/logger/file_summary_store.cpp new file mode 100644 index 0000000..d91d7f1 --- /dev/null +++ b/firmware/components/logger/file_summary_store.cpp @@ -0,0 +1,142 @@ +#include "track_timer/logger/file_summary_store.hpp" + +#include +#include +#include + +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(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(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 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& 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 diff --git a/firmware/components/logger/include/track_timer/logger/file_summary_store.hpp b/firmware/components/logger/include/track_timer/logger/file_summary_store.hpp new file mode 100644 index 0000000..faf4ef4 --- /dev/null +++ b/firmware/components/logger/include/track_timer/logger/file_summary_store.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include "track_timer/logger/memory_summary_store.hpp" +#include "track_timer/logger/summary_frame.hpp" + +#include + +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& 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 diff --git a/firmware/components/logger/include/track_timer/logger/summary_frame.hpp b/firmware/components/logger/include/track_timer/logger/summary_frame.hpp new file mode 100644 index 0000000..c91544c --- /dev/null +++ b/firmware/components/logger/include/track_timer/logger/summary_frame.hpp @@ -0,0 +1,92 @@ +#pragma once + +#include "track_timer/logger/formats.hpp" + +#include +#include +#include +#include + +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 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, + "the frame payload is the record's bytes"); + +struct SummaryFrame { + std::array 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 +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 diff --git a/firmware/components/logger/summary_frame.cpp b/firmware/components/logger/summary_frame.cpp new file mode 100644 index 0000000..49c3a7c --- /dev/null +++ b/firmware/components/logger/summary_frame.cpp @@ -0,0 +1,104 @@ +#include "track_timer/logger/summary_frame.hpp" + +#include +#include + +namespace track_timer::logger { +namespace { + +// FNV-1a, the same shape the settings blob uses. Enough to catch a torn write or a flipped +// bit, which is what this guards against; it is not a security check. +[[nodiscard]] std::uint32_t checksum(const std::uint8_t* bytes, const std::size_t size) noexcept +{ + std::uint32_t hash = 2'166'136'261U; + for (std::size_t index = 0; index < size; ++index) { + hash ^= bytes[index]; + hash *= 16'777'619U; + } + return hash; +} + +void put_u16(std::uint8_t* output, const std::uint16_t value) noexcept +{ + output[0] = static_cast(value & 0xFFU); + output[1] = static_cast((value >> 8U) & 0xFFU); +} + +void put_u32(std::uint8_t* output, const std::uint32_t value) noexcept +{ + output[0] = static_cast(value & 0xFFU); + output[1] = static_cast((value >> 8U) & 0xFFU); + output[2] = static_cast((value >> 16U) & 0xFFU); + output[3] = static_cast((value >> 24U) & 0xFFU); +} + +[[nodiscard]] std::uint16_t get_u16(const std::uint8_t* input) noexcept +{ + return static_cast(static_cast(input[0]) | + static_cast(input[1]) << 8U); +} + +[[nodiscard]] std::uint32_t get_u32(const std::uint8_t* input) noexcept +{ + return static_cast(input[0]) | + static_cast(input[1]) << 8U | + static_cast(input[2]) << 16U | + static_cast(input[3]) << 24U; +} + +} // namespace + +bool encode_summary_frame(const SessionSummaryV1& summary, SummaryFrame& frame) noexcept +{ + frame = {}; + if (!valid_summary(summary)) { + return false; + } + + std::copy(kSummaryFrameMagic.begin(), kSummaryFrameMagic.end(), frame.bytes.begin()); + put_u16(frame.bytes.data() + 4, kLogFormatVersion); + put_u16(frame.bytes.data() + 6, static_cast(sizeof(SessionSummaryV1))); + std::memcpy(frame.bytes.data() + kSummaryFrameHeaderSize, &summary, + sizeof(SessionSummaryV1)); + put_u32(frame.bytes.data() + 8, + checksum(frame.bytes.data() + kSummaryFrameHeaderSize, sizeof(SessionSummaryV1))); + frame.size = kSummaryFrameSize; + return true; +} + +FrameResult decode_summary_frame(const std::uint8_t* const bytes, const std::size_t size, + SessionSummaryV1& summary) noexcept +{ + // A torn tail is short rather than corrupt, and the caller treats the two the same way: + // keep what came before, stop reading here. + if (bytes == nullptr || size < kSummaryFrameSize) { + return FrameResult::too_short; + } + if (!std::equal(kSummaryFrameMagic.begin(), kSummaryFrameMagic.end(), bytes)) { + return FrameResult::bad_magic; + } + if (get_u16(bytes + 4) != kLogFormatVersion) { + return FrameResult::unsupported_version; + } + if (get_u16(bytes + 6) != sizeof(SessionSummaryV1)) { + // The record grew or shrank under a version that did not move, which means the + // file was written by a build this one cannot read. + return FrameResult::unsupported_version; + } + if (get_u32(bytes + 8) != + checksum(bytes + kSummaryFrameHeaderSize, sizeof(SessionSummaryV1))) { + return FrameResult::bad_checksum; + } + + SessionSummaryV1 candidate{}; + std::memcpy(&candidate, bytes + kSummaryFrameHeaderSize, sizeof(SessionSummaryV1)); + if (!valid_summary(candidate)) { + // Intact on the wire and nonsense as a record: a checksum cannot tell you a session + // lasted a negative length of time. + return FrameResult::invalid_record; + } + summary = candidate; + return FrameResult::ready; +} + +} // namespace track_timer::logger diff --git a/firmware/main/screen_router.cpp b/firmware/main/screen_router.cpp index cf3b6c7..2f96bec 100644 --- a/firmware/main/screen_router.cpp +++ b/firmware/main/screen_router.cpp @@ -23,7 +23,7 @@ #include "track_timer/ui/navigation.hpp" #include "track_timer/ui/presenter.hpp" #include "track_timer/ui/ready_screen.hpp" -#include "track_timer/logger/memory_summary_store.hpp" +#include "track_timer/logger/file_summary_store.hpp" #include "track_timer/ui/rest_session.hpp" #include "track_timer/ui/session_trigger.hpp" #include "track_timer/ui/session_review.hpp" @@ -151,6 +151,11 @@ constexpr std::uint32_t kRuby = 0xFF8FA3; } // Must stay in MenuItem order: the shell casts the carousel index straight to the enum. +// At the card's root beside the track packs, so a driver can find and copy it. One file of +// appended records rather than a directory per session: this is the Review index, and the +// per-session logs that #38 will write are a separate thing. +constexpr const char* kSummaryPath = "/sdcard/sessions.bin"; + constexpr ui::CarouselEntry kMenuEntries[] = { {LV_SYMBOL_LIST, "REVIEW", kAzure}, {LV_SYMBOL_POWER, "MODE", kRuby}, @@ -453,7 +458,14 @@ class ScreenRouter { // Restore the track that was selected before the last reboot. restore_selected_track(); refresh_ready(); - // In memory for now: the card is where these belong, and that is the next step. + // Whatever is already on the card, so Review opens on the last session driven + // rather than on nothing after a power cycle. + const auto opened = summaries_.open(kSummaryPath); + const auto& scan = summaries_.scan_report(); + ESP_LOGI("track_timer", + "session summaries: open=%u recovered=%u skipped=%u truncated=%d", + static_cast(opened), static_cast(scan.accepted), + static_cast(scan.skipped), scan.truncated_tail ? 1 : 0); review_controller_.begin(&summaries_); review_->update(review_controller_.view_model()); diagnostics_controller_.begin(device_snapshot()); @@ -1004,7 +1016,15 @@ class ScreenRouter { summary.peaks.down_g = peaks.down_g; summary.peaks.total_g = peaks.total_g; - if (!summaries_.record(summary)) { + // The card is not assumed to be there. A failed write costs the durability of this + // record and nothing else: the session stays in memory and Review shows it either + // way, because a session that has just been driven matters more than the record. + const auto stored = summaries_.append(summary); + if (!stored && !summaries_.persistent()) { + ESP_LOGW("track_timer", "session summary not written to card (%u failures)", + static_cast(summaries_.write_failure_count())); + } + else if (!stored) { ESP_LOGW("track_timer", "session summary refused by its own validator"); return; } @@ -1270,7 +1290,7 @@ class ScreenRouter { session::SessionController session_{}; ui::ActiveSessionController active_session_{}; bool session_was_active_{false}; - logger::MemorySummaryStore summaries_{}; + logger::FileSummaryStore summaries_{}; std::uint32_t session_ordinal_{0}; bool armed_{false}; bool armed_ready_{false}; diff --git a/tests/cpp/test_summary_file.cpp b/tests/cpp/test_summary_file.cpp new file mode 100644 index 0000000..9ef0edf --- /dev/null +++ b/tests/cpp/test_summary_file.cpp @@ -0,0 +1,233 @@ +#include "track_timer/logger/file_summary_store.hpp" + +#include +#include +#include +#include +#include + +#include // truncate(), for simulating a card pulled mid-write + +namespace { + +using namespace track_timer; + +std::string temp_path(const char* const name) +{ + const auto* base = std::getenv("TMPDIR"); + std::string path = base != nullptr ? base : "/tmp"; + path += "/track_timer_"; + path += name; + std::remove(path.c_str()); + return path; +} + +logger::SessionSummaryV1 summary_of(const char* const id, const std::int64_t duration_ms) +{ + logger::SessionSummaryV1 summary{}; + summary.record_size_bytes = static_cast(sizeof(summary)); + std::snprintf(summary.session_id.data(), summary.session_id.size(), "%s", id); + summary.session_duration_ms = duration_ms; + summary.completion_reason = logger::SessionCompletionReason::driver_stop; + summary.integrity = logger::SummaryIntegrity::complete; + summary.degraded_subsystems = logger::degraded_gnss; + summary.peaks.total_g = 1.15F; + summary.peaks.up_g = 0.4F; + return summary; +} + +// The point of the card: a driver's sessions outlive the power cycle. +void sessions_survive_a_reopen() +{ + const auto path = temp_path("roundtrip.bin"); + { + logger::FileSummaryStore store; + assert(store.open(path.c_str()) == logger::FileSummaryStore::OpenResult::no_file); + assert(store.append(summary_of("S001", 60'000))); + assert(store.append(summary_of("S002", 120'000))); + assert(store.append(summary_of("S003", 180'000))); + assert(store.persistent()); + } + + logger::FileSummaryStore reopened; + assert(reopened.open(path.c_str()) == logger::FileSummaryStore::OpenResult::ready); + std::size_t count = 0; + assert(reopened.session_count(count) == logger::SummaryReadResult::ready); + assert(count == 3); + + logger::SessionSummaryV1 read{}; + assert(reopened.read_summary(0, read) == logger::SummaryReadResult::ready); + assert(std::strcmp(read.session_id.data(), "S003") == 0); // newest first + assert(read.session_duration_ms == 180'000); + assert(read.peaks.total_g == 1.15F); + assert(read.peaks.up_g == 0.4F); + assert(reopened.scan_report().accepted == 3); + assert(reopened.scan_report().skipped == 0); + assert(!reopened.scan_report().truncated_tail); + std::remove(path.c_str()); +} + +// A card pulled mid-write leaves a partial frame. The sessions already written must not go +// with it - that is the whole reason for framing each record. +void a_torn_final_frame_costs_only_that_record() +{ + const auto path = temp_path("torn.bin"); + { + logger::FileSummaryStore store; + (void)store.open(path.c_str()); + assert(store.append(summary_of("S001", 60'000))); + assert(store.append(summary_of("S002", 120'000))); + } + // Chop the last frame in half, as an interrupted write would. + auto* file = std::fopen(path.c_str(), "rb"); + std::fseek(file, 0, SEEK_END); + const auto full = std::ftell(file); + std::fclose(file); + assert(truncate(path.c_str(), full - static_cast(logger::kSummaryFrameSize / 2)) == 0); + + logger::FileSummaryStore reopened; + assert(reopened.open(path.c_str()) == logger::FileSummaryStore::OpenResult::ready); + std::size_t count = 0; + assert(reopened.session_count(count) == logger::SummaryReadResult::ready); + assert(count == 1); + assert(reopened.scan_report().truncated_tail); + + logger::SessionSummaryV1 read{}; + assert(reopened.read_summary(0, read) == logger::SummaryReadResult::ready); + assert(std::strcmp(read.session_id.data(), "S001") == 0); + std::remove(path.c_str()); +} + +// Stopping at the first bad frame would throw away good sessions to protect a bad one. +void a_corrupt_frame_does_not_hide_the_ones_after_it() +{ + const auto path = temp_path("corrupt.bin"); + { + logger::FileSummaryStore store; + (void)store.open(path.c_str()); + assert(store.append(summary_of("S001", 60'000))); + assert(store.append(summary_of("S002", 120'000))); + assert(store.append(summary_of("S003", 180'000))); + } + // Flip a byte inside the middle frame's payload. + auto* file = std::fopen(path.c_str(), "r+b"); + assert(file != nullptr); + std::fseek(file, static_cast(logger::kSummaryFrameSize + 40), SEEK_SET); + int byte = std::fgetc(file); + std::fseek(file, static_cast(logger::kSummaryFrameSize + 40), SEEK_SET); + std::fputc(byte ^ 0x5A, file); + std::fclose(file); + + logger::FileSummaryStore reopened; + assert(reopened.open(path.c_str()) == logger::FileSummaryStore::OpenResult::ready); + assert(reopened.scan_report().skipped == 1); + assert(reopened.scan_report().accepted == 2); + + std::size_t count = 0; + (void)reopened.session_count(count); + assert(count == 2); + logger::SessionSummaryV1 read{}; + assert(reopened.read_summary(0, read) == logger::SummaryReadResult::ready); + assert(std::strcmp(read.session_id.data(), "S003") == 0); // the one after the damage + std::remove(path.c_str()); +} + +// No card must never cost the driver the session they just drove. +void a_missing_card_still_keeps_the_session_in_review() +{ + logger::FileSummaryStore store; + assert(store.open("/nonexistent-directory-for-tests/summaries.bin") == + logger::FileSummaryStore::OpenResult::no_file); + + // The write fails, and the session is still there to review. + const auto appended = store.append(summary_of("S001", 60'000)); + assert(!appended); + assert(!store.persistent()); + assert(store.write_failure_count() == 1); + + std::size_t count = 0; + assert(store.session_count(count) == logger::SummaryReadResult::ready); + assert(count == 1); + logger::SessionSummaryV1 read{}; + assert(store.read_summary(0, read) == logger::SummaryReadResult::ready); + assert(std::strcmp(read.session_id.data(), "S001") == 0); +} + +// A card that has been in the device all season must not have to be held in RAM to be read. +void only_the_newest_sessions_are_kept_in_memory() +{ + const auto path = temp_path("long.bin"); + constexpr std::size_t kWritten = logger::MemorySummaryStore::kCapacity + 5; + { + logger::FileSummaryStore store; + (void)store.open(path.c_str()); + for (std::size_t index = 0; index < kWritten; ++index) { + char id[8]{}; + std::snprintf(id, sizeof(id), "S%03u", static_cast(index)); + assert(store.append(summary_of(id, 60'000))); + } + } + + logger::FileSummaryStore reopened; + assert(reopened.open(path.c_str()) == logger::FileSummaryStore::OpenResult::ready); + std::size_t count = 0; + (void)reopened.session_count(count); + assert(count == logger::MemorySummaryStore::kCapacity); + + // The window seeks to a frame boundary, so the newest record is intact rather than + // starting halfway through one. + logger::SessionSummaryV1 read{}; + assert(reopened.read_summary(0, read) == logger::SummaryReadResult::ready); + char newest[8]{}; + std::snprintf(newest, sizeof(newest), "S%03u", static_cast(kWritten - 1)); + assert(std::strcmp(read.session_id.data(), newest) == 0); + std::remove(path.c_str()); +} + +// A record that is intact on the wire can still be nonsense; a checksum cannot tell you a +// session lasted a negative length of time. +void framing_refuses_a_record_that_fails_its_own_validator() +{ + logger::SummaryFrame frame{}; + auto invalid = summary_of("S001", 60'000); + invalid.session_overrun_ms = 120'000; // longer than the session itself + assert(!logger::encode_summary_frame(invalid, frame)); + + auto valid = summary_of("S001", 60'000); + assert(logger::encode_summary_frame(valid, frame)); + assert(frame.size == logger::kSummaryFrameSize); + + logger::SessionSummaryV1 decoded{}; + assert(logger::decode_summary_frame(frame.bytes.data(), frame.size, decoded) == + logger::FrameResult::ready); + + auto wrong_magic = frame; + wrong_magic.bytes[0] = 'X'; + assert(logger::decode_summary_frame(wrong_magic.bytes.data(), wrong_magic.size, decoded) == + logger::FrameResult::bad_magic); + + auto wrong_version = frame; + wrong_version.bytes[4] = static_cast(logger::kLogFormatVersion + 1); + assert(logger::decode_summary_frame(wrong_version.bytes.data(), wrong_version.size, + decoded) == logger::FrameResult::unsupported_version); + + assert(logger::decode_summary_frame(frame.bytes.data(), logger::kSummaryFrameSize - 1, + decoded) == logger::FrameResult::too_short); +} + +} // namespace + +int main() +{ + sessions_survive_a_reopen(); + a_torn_final_frame_costs_only_that_record(); + a_corrupt_frame_does_not_hide_the_ones_after_it(); + a_missing_card_still_keeps_the_session_in_review(); + only_the_newest_sessions_are_kept_in_memory(); + framing_refuses_a_record_that_fails_its_own_validator(); + + std::cout << "Session summaries on card: survive a reopen, a torn tail costs one record, " + "a corrupt frame does not hide later ones, and no card still leaves the " + "session reviewable passed\n"; + return 0; +}