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; +}