From 987406fa7c618e9e3de0898ffdd7bfb52a32a759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Fri, 2 Oct 2026 09:47:36 +0200 Subject: [PATCH] Add M0 services: settings, battery, clock, storage, power lib/services holds the host-tested logic: - Settings: typed, validated, persisted, SettingChanged events, transmit gated on a confirmed Region - BatteryEstimator: LiPo curve, median smoothing against TX sags - ClockModel: source priority GNSS > NTP > mesh, relative ages, Europe/Brussels local time via POSIX TZ - StorageMonitor: warning once per boot at 80%, Logs stop at 90%, Captures stop with < 2 MiB left - PowerPolicy / PowerButton: dim/off timeouts, wake key swallowed only when the screen was off, G0 long press src/services wires them to the hardware (NVS, battery ADC, SD on the shared SPI bus with the LoRa CS held high, backlight, deep sleep), and main.cpp is a temporary diagnostics screen for the hardware checks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- lib/core/src/event.h | 6 +- lib/services/src/battery_estimator.cpp | 49 +++++++ lib/services/src/battery_estimator.h | 27 ++++ lib/services/src/clock_model.cpp | 49 +++++++ lib/services/src/clock_model.h | 32 +++++ lib/services/src/key_value_store.h | 18 +++ lib/services/src/power_policy.cpp | 36 +++++ lib/services/src/power_policy.h | 47 +++++++ lib/services/src/settings.cpp | 118 ++++++++++++++++ lib/services/src/settings.h | 62 +++++++++ lib/services/src/storage_monitor.cpp | 32 +++++ lib/services/src/storage_monitor.h | 36 +++++ src/main.cpp | 153 +++++++++++++++----- src/platform/nvs_store.h | 33 +++++ src/platform/pins.h | 17 +++ src/platform/shared_spi.h | 24 ++++ src/services/battery_service.cpp | 19 +++ src/services/battery_service.h | 26 ++++ src/services/clock_service.h | 38 +++++ src/services/power_service.cpp | 60 ++++++++ src/services/power_service.h | 35 +++++ src/services/storage_service.cpp | 36 +++++ src/services/storage_service.h | 25 ++++ test/test_battery/test_battery.cpp | 64 +++++++++ test/test_clock/test_clock.cpp | 74 ++++++++++ test/test_power/test_power.cpp | 65 +++++++++ test/test_settings/test_settings.cpp | 184 +++++++++++++++++++++++++ test/test_storage/test_storage.cpp | 108 +++++++++++++++ 28 files changed, 1434 insertions(+), 39 deletions(-) create mode 100644 lib/services/src/battery_estimator.cpp create mode 100644 lib/services/src/battery_estimator.h create mode 100644 lib/services/src/clock_model.cpp create mode 100644 lib/services/src/clock_model.h create mode 100644 lib/services/src/key_value_store.h create mode 100644 lib/services/src/power_policy.cpp create mode 100644 lib/services/src/power_policy.h create mode 100644 lib/services/src/settings.cpp create mode 100644 lib/services/src/settings.h create mode 100644 lib/services/src/storage_monitor.cpp create mode 100644 lib/services/src/storage_monitor.h create mode 100644 src/platform/nvs_store.h create mode 100644 src/platform/pins.h create mode 100644 src/platform/shared_spi.h create mode 100644 src/services/battery_service.cpp create mode 100644 src/services/battery_service.h create mode 100644 src/services/clock_service.h create mode 100644 src/services/power_service.cpp create mode 100644 src/services/power_service.h create mode 100644 src/services/storage_service.cpp create mode 100644 src/services/storage_service.h create mode 100644 test/test_battery/test_battery.cpp create mode 100644 test/test_clock/test_clock.cpp create mode 100644 test/test_power/test_power.cpp create mode 100644 test/test_settings/test_settings.cpp create mode 100644 test/test_storage/test_storage.cpp diff --git a/lib/core/src/event.h b/lib/core/src/event.h index 1c2648f..e59d51c 100644 --- a/lib/core/src/event.h +++ b/lib/core/src/event.h @@ -5,12 +5,14 @@ namespace roro { +enum class NotificationLevel : int32_t { Info, Warning, Message }; + // Everything Services announce to the UI. Add new kinds before Count. enum class EventType : uint8_t { BatteryChanged, // a = percent, b = millivolts Notification, // text = message, a = NotificationLevel - StorageThreshold, // a = usage percent, b = threshold crossed (80 / 90 / 100) - SettingChanged, // text = setting key + StorageThreshold, // a = usage percent, b = level now in effect (0 / 80 / 90 / 100) + SettingChanged, // a = Setting, text = storage key Count }; diff --git a/lib/services/src/battery_estimator.cpp b/lib/services/src/battery_estimator.cpp new file mode 100644 index 0000000..9ba11b8 --- /dev/null +++ b/lib/services/src/battery_estimator.cpp @@ -0,0 +1,49 @@ +#include "battery_estimator.h" + +#include + +namespace roro { + +namespace { +struct Point { + int millivolts; + int percent; +}; +const Point kCurve[] = { + {3270, 0}, {3610, 5}, {3690, 10}, {3710, 15}, {3730, 20}, {3750, 25}, {3770, 30}, + {3790, 35}, {3800, 40}, {3820, 45}, {3840, 50}, {3850, 55}, {3870, 60}, {3910, 65}, + {3950, 70}, {3980, 75}, {4020, 80}, {4080, 85}, {4110, 90}, {4150, 95}, {4200, 100}, +}; +const size_t kPoints = sizeof(kCurve) / sizeof(kCurve[0]); +} // namespace + +int BatteryEstimator::percentFromMillivolts(int mv) { + if (mv <= kCurve[0].millivolts) return 0; + if (mv >= kCurve[kPoints - 1].millivolts) return 100; + for (size_t i = 1; i < kPoints; i++) { + if (mv <= kCurve[i].millivolts) { + const auto& lo = kCurve[i - 1]; + const auto& hi = kCurve[i]; + return lo.percent + (mv - lo.millivolts) * (hi.percent - lo.percent) / + (hi.millivolts - lo.millivolts); + } + } + return 100; +} + +void BatteryEstimator::addSample(int mv) { + samples_[next_] = mv; + next_ = (next_ + 1) % kWindow; + if (count_ < kWindow) count_++; +} + +int BatteryEstimator::millivolts() const { + if (count_ == 0) return 0; + // Median: ignores short sags (radio transmit bursts) that would drag an average down. + std::array sorted = samples_; + auto middle = sorted.begin() + count_ / 2; + std::nth_element(sorted.begin(), middle, sorted.begin() + count_); + return *middle; +} + +} // namespace roro diff --git a/lib/services/src/battery_estimator.h b/lib/services/src/battery_estimator.h new file mode 100644 index 0000000..6a8b7f8 --- /dev/null +++ b/lib/services/src/battery_estimator.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include + +namespace roro { + +// Smooths battery voltage samples and turns them into a charge percentage. +class BatteryEstimator { + public: + // Typical single-cell LiPo discharge curve under light load. + static int percentFromMillivolts(int millivolts); + + void addSample(int millivolts); + bool hasReading() const { return count_ > 0; } + int millivolts() const; // median of the recent samples + int percent() const { return percentFromMillivolts(millivolts()); } + + private: + static constexpr size_t kWindow = 8; + std::array samples_{}; + size_t next_ = 0; + size_t count_ = 0; +}; + +} // namespace roro diff --git a/lib/services/src/clock_model.cpp b/lib/services/src/clock_model.cpp new file mode 100644 index 0000000..4ef4544 --- /dev/null +++ b/lib/services/src/clock_model.cpp @@ -0,0 +1,49 @@ +#include "clock_model.h" + +#include +#include +#include + +namespace roro { + +bool ClockModel::set(int64_t utcSeconds, TimeSource source, uint32_t nowMs) { + if (source < source_) return false; + source_ = source; + utcAtSet_ = utcSeconds; + msAtSet_ = nowMs; + return true; +} + +int64_t ClockModel::utcNow(uint32_t nowMs) const { + // Unsigned subtraction handles the millis() wraparound. Uptime beyond 49 days between two + // sets would lose 49 days; sources refresh far more often than that. + return utcAtSet_ + static_cast(static_cast(nowMs - msAtSet_) / 1000); +} + +void ClockModel::applyTimezone(const char* posixTz) { + setenv("TZ", posixTz, 1); + tzset(); +} + +std::string ClockModel::formatLocalTime(int64_t utcSeconds) { + time_t t = static_cast(utcSeconds); + struct tm local; + localtime_r(&t, &local); + char buf[8]; + std::snprintf(buf, sizeof(buf), "%02d:%02d", local.tm_hour, local.tm_min); + return buf; +} + +std::string ClockModel::formatAge(int64_t seconds) { + char buf[24]; + if (seconds < 60) return "now"; + if (seconds < 3600) + std::snprintf(buf, sizeof(buf), "%d min ago", static_cast(seconds / 60)); + else if (seconds < 86400) + std::snprintf(buf, sizeof(buf), "%d h ago", static_cast(seconds / 3600)); + else + std::snprintf(buf, sizeof(buf), "%d d ago", static_cast(seconds / 86400)); + return buf; +} + +} // namespace roro diff --git a/lib/services/src/clock_model.h b/lib/services/src/clock_model.h new file mode 100644 index 0000000..adb4cbb --- /dev/null +++ b/lib/services/src/clock_model.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +namespace roro { + +// Ordered by trust: a source can only replace the time set by an equal or less trusted one. +enum class TimeSource : uint8_t { None, Mesh, Ntp, Gnss }; + +// Wall-clock time on a device with no battery-backed clock: unset until a TimeSource provides +// it, then advanced from uptime. Stored in UTC; local display uses the POSIX TZ applied globally. +class ClockModel { + public: + // Returns false if a more trusted source already set the time. + bool set(int64_t utcSeconds, TimeSource source, uint32_t nowMs); + + bool isSet() const { return source_ != TimeSource::None; } + TimeSource source() const { return source_; } + int64_t utcNow(uint32_t nowMs) const; + + static void applyTimezone(const char* posixTz); + static std::string formatLocalTime(int64_t utcSeconds); // "HH:MM" + static std::string formatAge(int64_t seconds); // "now", "5 min ago", "3 h ago", "2 d ago" + + private: + TimeSource source_ = TimeSource::None; + int64_t utcAtSet_ = 0; + uint32_t msAtSet_ = 0; +}; + +} // namespace roro diff --git a/lib/services/src/key_value_store.h b/lib/services/src/key_value_store.h new file mode 100644 index 0000000..bbfb646 --- /dev/null +++ b/lib/services/src/key_value_store.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +namespace roro { + +// Persistent key/value storage (NVS on the device, a map in tests). Keys are at most 15 chars. +class KeyValueStore { + public: + virtual ~KeyValueStore() = default; + virtual bool getInt(const char* key, int32_t& out) = 0; + virtual bool getString(const char* key, std::string& out) = 0; + virtual void putInt(const char* key, int32_t value) = 0; + virtual void putString(const char* key, const std::string& value) = 0; +}; + +} // namespace roro diff --git a/lib/services/src/power_policy.cpp b/lib/services/src/power_policy.cpp new file mode 100644 index 0000000..7317959 --- /dev/null +++ b/lib/services/src/power_policy.cpp @@ -0,0 +1,36 @@ +#include "power_policy.h" + +namespace roro { + +bool PowerPolicy::activity(uint32_t nowMs) { + bool swallow = state_ == ScreenState::Off; + lastActivityMs_ = nowMs; + state_ = ScreenState::On; + return swallow; +} + +ScreenState PowerPolicy::update(uint32_t nowMs) { + uint32_t idle = nowMs - lastActivityMs_; + state_ = idle >= offMs_ ? ScreenState::Off : idle >= dimMs_ ? ScreenState::Dimmed : ScreenState::On; + return state_; +} + +ButtonAction PowerButton::update(bool pressed, uint32_t nowMs) { + if (pressed && !pressed_) { + pressed_ = true; + longFired_ = false; + pressedAtMs_ = nowMs; + return ButtonAction::None; + } + if (pressed && !longFired_ && nowMs - pressedAtMs_ >= longMs_) { + longFired_ = true; + return ButtonAction::LongPress; + } + if (!pressed && pressed_) { + pressed_ = false; + return longFired_ ? ButtonAction::None : ButtonAction::ShortPress; + } + return ButtonAction::None; +} + +} // namespace roro diff --git a/lib/services/src/power_policy.h b/lib/services/src/power_policy.h new file mode 100644 index 0000000..8313e66 --- /dev/null +++ b/lib/services/src/power_policy.h @@ -0,0 +1,47 @@ +#pragma once + +#include + +namespace roro { + +enum class ScreenState : uint8_t { On, Dimmed, Off }; + +// Screen power from user activity. Services keep running whatever the screen does. +class PowerPolicy { + public: + PowerPolicy(uint32_t dimAfterMs, uint32_t offAfterMs) : dimMs_(dimAfterMs), offMs_(offAfterMs) {} + + void setTimeouts(uint32_t dimAfterMs, uint32_t offAfterMs) { + dimMs_ = dimAfterMs; + offMs_ = offAfterMs; + } + + // Records a key press. Returns true if the key only woke an Off screen and must not reach the App. + bool activity(uint32_t nowMs); + + ScreenState update(uint32_t nowMs); + ScreenState state() const { return state_; } + + private: + uint32_t dimMs_; + uint32_t offMs_; + uint32_t lastActivityMs_ = 0; + ScreenState state_ = ScreenState::On; +}; + +enum class ButtonAction : uint8_t { None, ShortPress, LongPress }; + +// G0 button: a long press fires as soon as the hold time is reached (power off); a short press on release. +class PowerButton { + public: + explicit PowerButton(uint32_t longPressMs) : longMs_(longPressMs) {} + ButtonAction update(bool pressed, uint32_t nowMs); + + private: + uint32_t longMs_; + bool pressed_ = false; + bool longFired_ = false; + uint32_t pressedAtMs_ = 0; +}; + +} // namespace roro diff --git a/lib/services/src/settings.cpp b/lib/services/src/settings.cpp new file mode 100644 index 0000000..9de2e93 --- /dev/null +++ b/lib/services/src/settings.cpp @@ -0,0 +1,118 @@ +#include "settings.h" + +namespace roro { + +namespace { + +enum class Kind : uint8_t { Bool, Int, String }; + +struct Definition { + const char* key; + Kind kind; + int32_t defaultInt; + const char* defaultString; + int32_t min; + int32_t max; // for strings: max length in bytes +}; + +// Order must match the Setting enum. +const Definition kDefinitions[] = { + {"setup_done", Kind::Bool, 0, nullptr, 0, 1}, + {"long_name", Kind::String, 0, "", 1, 39}, + {"short_name", Kind::String, 0, "", 1, 4}, + {"region", Kind::String, 0, "EU868", 1, 8}, + {"region_ok", Kind::Bool, 0, nullptr, 0, 1}, + {"timezone", Kind::String, 0, "CET-1CEST,M3.5.0,M10.5.0/3", 1, 63}, + {"brightness", Kind::Int, 60, nullptr, 10, 100}, + {"dim_s", Kind::Int, 30, nullptr, 5, 600}, + {"off_s", Kind::Int, 60, nullptr, 10, 3600}, + {"sound", Kind::Bool, 1, nullptr, 0, 1}, + {"probe_mac_raw", Kind::Bool, 1, nullptr, 0, 1}, +}; +static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast(Setting::Count), + "every Setting needs a definition"); + +const char* const kKnownRegions[] = {"EU868"}; + +const Definition& def(Setting s) { return kDefinitions[static_cast(s)]; } + +} // namespace + +Settings::Settings(KeyValueStore& store, EventBus& bus) : store_(store), bus_(bus) {} + +const char* Settings::key(Setting s) { return def(s).key; } + +void Settings::load() { + for (size_t i = 0; i < values_.size(); i++) { + auto s = static_cast(i); + const auto& d = def(s); + auto& v = values_[i]; + if (d.kind == Kind::String) { + v.str = d.defaultString; + std::string stored; + if (store_.getString(d.key, stored) && validString(s, stored)) v.str = stored; + } else { + v.i = d.defaultInt; + int32_t stored; + if (store_.getInt(d.key, stored) && validInt(s, stored)) v.i = stored; + } + } +} + +int32_t Settings::getInt(Setting s) const { return values_[static_cast(s)].i; } +bool Settings::getBool(Setting s) const { return getInt(s) != 0; } +const std::string& Settings::getString(Setting s) const { return values_[static_cast(s)].str; } + +bool Settings::validInt(Setting s, int32_t value) const { + const auto& d = def(s); + if (d.kind == Kind::String || value < d.min || value > d.max) return false; + if (s == Setting::DimTimeoutS) return value < getInt(Setting::OffTimeoutS); + if (s == Setting::OffTimeoutS) return value > getInt(Setting::DimTimeoutS); + return true; +} + +bool Settings::validString(Setting s, const std::string& value) const { + const auto& d = def(s); + if (d.kind != Kind::String) return false; + if (value.size() < static_cast(d.min) || value.size() > static_cast(d.max)) return false; + if (s == Setting::Region) { + for (auto region : kKnownRegions) + if (value == region) return true; + return false; + } + return true; +} + +bool Settings::setInt(Setting s, int32_t value) { + if (def(s).kind == Kind::Bool) return false; + if (!validInt(s, value)) return false; + if (getInt(s) == value) return true; + values_[static_cast(s)].i = value; + store_.putInt(key(s), value); + changed(s); + return true; +} + +bool Settings::setBool(Setting s, bool value) { + if (def(s).kind != Kind::Bool) return false; + if (getBool(s) == value) return true; + values_[static_cast(s)].i = value ? 1 : 0; + store_.putInt(key(s), value ? 1 : 0); + changed(s); + return true; +} + +bool Settings::setString(Setting s, const std::string& value) { + if (!validString(s, value)) return false; + if (getString(s) == value) return true; + values_[static_cast(s)].str = value; + store_.putString(key(s), value); + changed(s); + return true; +} + +void Settings::changed(Setting s) { + bus_.publish(Event::withText(EventType::SettingChanged, key(s), static_cast(s))); +} + +} // namespace roro diff --git a/lib/services/src/settings.h b/lib/services/src/settings.h new file mode 100644 index 0000000..d709cb8 --- /dev/null +++ b/lib/services/src/settings.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include + +#include "event_bus.h" +#include "key_value_store.h" + +namespace roro { + +enum class Setting : uint8_t { + SetupDone, // bool: first-boot wizard completed + LongName, // string, 1..39 bytes + ShortName, // string, 1..4 bytes + Region, // string, a known Region ("EU868") + RegionConfirmed, // bool: nothing transmits until true + Timezone, // string, POSIX TZ + Brightness, // int, 10..100 % + DimTimeoutS, // int, 5..600, below OffTimeoutS + OffTimeoutS, // int, 10..3600, above DimTimeoutS + Sound, // bool + ProbeMacRaw, // bool: probe-request Logs keep raw MAC addresses + Count +}; + +// Typed, validated settings in internal flash. Every accepted change is persisted immediately and +// announced with a SettingChanged event; rejected values leave the current value untouched. +class Settings { + public: + Settings(KeyValueStore& store, EventBus& bus); + + // Reads every setting; missing or invalid stored values fall back to defaults. + void load(); + + int32_t getInt(Setting s) const; + bool getBool(Setting s) const; + const std::string& getString(Setting s) const; + + bool setInt(Setting s, int32_t value); + bool setBool(Setting s, bool value); + bool setString(Setting s, const std::string& value); + + bool canTransmit() const { return getBool(Setting::RegionConfirmed); } + + static const char* key(Setting s); + + private: + struct Value { + int32_t i = 0; + std::string str; + }; + + bool validInt(Setting s, int32_t value) const; + bool validString(Setting s, const std::string& value) const; + void changed(Setting s); + + KeyValueStore& store_; + EventBus& bus_; + std::array(Setting::Count)> values_; +}; + +} // namespace roro diff --git a/lib/services/src/storage_monitor.cpp b/lib/services/src/storage_monitor.cpp new file mode 100644 index 0000000..e09350f --- /dev/null +++ b/lib/services/src/storage_monitor.cpp @@ -0,0 +1,32 @@ +#include "storage_monitor.h" + +namespace roro { + +void StorageMonitor::update(bool present, uint64_t totalBytes, uint64_t usedBytes) { + StorageState next; + next.present = present && totalBytes > 0; + if (next.present) { + next.totalBytes = totalBytes; + next.usedBytes = usedBytes; + next.usedPercent = static_cast(usedBytes * 100 / totalBytes); + bool full = totalBytes - usedBytes < kFullReserveBytes || usedBytes >= totalBytes; + next.level = full ? 100 : next.usedPercent >= 90 ? 90 : next.usedPercent >= 80 ? 80 : 0; + next.logsAllowed = next.level < 90; + next.capturesAllowed = next.level < 100; + } + + bool levelChanged = next.present && (next.level != state_.level || !state_.present); + bool firstReading = !state_.present; + state_ = next; + + if (levelChanged && !(firstReading && next.level == 0)) + bus_.publish(Event::withValues(EventType::StorageThreshold, next.usedPercent, next.level)); + + if (next.present && next.level >= 80 && !warned_) { + warned_ = true; + bus_.publish(Event::withText(EventType::Notification, "SD card over 80% full", + static_cast(NotificationLevel::Warning))); + } +} + +} // namespace roro diff --git a/lib/services/src/storage_monitor.h b/lib/services/src/storage_monitor.h new file mode 100644 index 0000000..decef6b --- /dev/null +++ b/lib/services/src/storage_monitor.h @@ -0,0 +1,36 @@ +#pragma once + +#include + +#include "event_bus.h" + +namespace roro { + +struct StorageState { + bool present = false; + uint64_t totalBytes = 0; + uint64_t usedBytes = 0; + int usedPercent = 0; + int level = 0; // 0, 80, 90 or 100 (full) + bool logsAllowed = false; + bool capturesAllowed = false; +}; + +// Applies the SD card rules (see CONTEXT.md): Storage Warning once per boot past 80 %, Logs stop +// past 90 % to keep room for Captures, Captures stop when the card is full. +class StorageMonitor { + public: + static constexpr uint64_t kFullReserveBytes = 2ull * 1024 * 1024; + + explicit StorageMonitor(EventBus& bus) : bus_(bus) {} + + void update(bool present, uint64_t totalBytes, uint64_t usedBytes); + const StorageState& state() const { return state_; } + + private: + EventBus& bus_; + StorageState state_; + bool warned_ = false; +}; + +} // namespace roro diff --git a/src/main.cpp b/src/main.cpp index b347189..7d4af22 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,58 +1,137 @@ -// roro9stack — boot stub: proves toolchain, display and keyboard. +// roro9stack — temporary diagnostics screen for M0 hardware checks (replaced by the Launcher in step 7). #include #include "event_bus.h" +#include "platform/nvs_store.h" #include "service_manager.h" +#include "services/battery_service.h" +#include "services/clock_service.h" +#include "services/power_service.h" +#include "services/storage_service.h" +#include "settings.h" #include "version.h" -static roro::EventBus bus; -static roro::ServiceManager services; +using namespace roro; + +static EventBus bus; +static NvsStore nvs; +static Settings settings(nvs, bus); +static ServiceManager services; +static BatteryService* battery; +static StorageService* storage; +static PowerService* power; +static ClockService* clockService; + +static String lastKey = "-"; +static String lastEvent = "-"; +static bool dirty = true; + +static void draw() { + auto& d = M5Cardputer.Display; + d.fillScreen(TFT_BLACK); + d.setTextSize(1); + d.setTextColor(TFT_GREEN); + d.drawString(String(kProductName) + " " + versionString(), 4, 2); + d.setTextColor(TFT_WHITE); + + const auto& b = battery->estimator(); + const auto& s = storage->state(); + int y = 16; + auto line = [&](const String& text) { + d.drawString(text, 4, y); + y += 11; + }; + line("Battery: " + String(b.percent()) + "% " + String(b.millivolts()) + " mV (raw " + + String(battery->lastRawMillivolts()) + ")"); + if (s.present) + line("SD: " + String(s.usedPercent) + "% of " + String((uint32_t)(s.totalBytes >> 20)) + + " MB logs:" + (s.logsAllowed ? "y" : "n") + " capt:" + (s.capturesAllowed ? "y" : "n")); + else + line("SD: no card"); + line("Clock: " + String(clockService->displayTime().c_str()) + " uptime " + String(millis() / 1000) + " s"); + line("Heap: " + String(ESP.getFreeHeap() / 1024) + " KB free, min " + String(ESP.getMinFreeHeap() / 1024) + " KB"); + line("Bright: " + String(settings.getInt(Setting::Brightness)) + "% dim " + + String(settings.getInt(Setting::DimTimeoutS)) + "s off " + String(settings.getInt(Setting::OffTimeoutS)) + "s"); + line("Key: " + lastKey); + line("Event: " + lastEvent); + d.setTextColor(TFT_DARKGREY); + line("+/- brightness, d: dim test (5s/10s)"); +} + +static void logEvent(const String& text) { + lastEvent = text; + dirty = true; + Serial.println("event: " + text); +} void setup() { auto cfg = M5.config(); - M5Cardputer.begin(cfg, true); // true = enable keyboard - - auto& d = M5Cardputer.Display; - d.setRotation(1); - d.fillScreen(TFT_BLACK); - d.setTextColor(TFT_GREEN); - d.setTextSize(2); - d.drawString(roro::kProductName, 8, 8); - d.setTextSize(1); - d.setTextColor(TFT_WHITE); - d.drawString(roro::versionString(), 8, 32); - d.drawString("Press keys...", 8, 56); - + M5Cardputer.begin(cfg, true); + M5Cardputer.Display.setRotation(1); Serial.begin(115200); + + nvs.begin(); + settings.load(); + + battery = new BatteryService(bus); + storage = new StorageService(bus); + power = new PowerService(settings); + clockService = new ClockService(settings, bus); + services.add(*power); + services.add(*clockService); + services.add(*battery); + services.add(*storage); + + bus.subscribe(EventType::BatteryChanged, + [](const Event& e) { logEvent("battery " + String(e.a) + "% " + String(e.b) + "mV"); }); + bus.subscribe(EventType::StorageThreshold, + [](const Event& e) { logEvent("storage level " + String(e.b) + " (" + String(e.a) + "%)"); }); + bus.subscribe(EventType::Notification, [](const Event& e) { logEvent("notify: " + String(e.text)); }); + bus.subscribe(EventType::SettingChanged, [](const Event& e) { logEvent("setting " + String(e.text)); }); + services.startAll(millis()); } -static void printStatus() { - Serial.printf("%s %s, free heap %u, min free heap %u, psram %u\n", roro::kProductName, - roro::versionString(), ESP.getFreeHeap(), ESP.getMinFreeHeap(), - ESP.getPsramSize()); +static void handleKeys() { + if (!M5Cardputer.Keyboard.isChange() || !M5Cardputer.Keyboard.isPressed()) return; + if (power->onKey(millis())) return; // only woke the screen + + auto state = M5Cardputer.Keyboard.keysState(); + String word; + for (auto c : state.word) word += c; + lastKey = word + (state.fn ? " +fn" : "") + (state.opt ? " +opt" : "") + (state.ctrl ? " +ctrl" : "") + + (state.enter ? " enter" : "") + (state.del ? " del" : ""); + dirty = true; + + int brightness = settings.getInt(Setting::Brightness); + if (word == "=" || word == "+") settings.setInt(Setting::Brightness, brightness + 10); + if (word == "-") settings.setInt(Setting::Brightness, brightness - 10); + if (word == "d") { + // Short timeouts to test dimming quickly; persisted, so they survive a reboot. + settings.setInt(Setting::DimTimeoutS, 5); + settings.setInt(Setting::OffTimeoutS, 10); + } } void loop() { - static uint32_t lastStatus = 0; - if (millis() - lastStatus > 3000) { - lastStatus = millis(); - printStatus(); - } - + static uint32_t lastDraw = 0, lastLog = 0; + M5Cardputer.update(); + handleKeys(); services.tick(millis()); bus.dispatch(); - M5Cardputer.update(); - if (M5Cardputer.Keyboard.isChange() && M5Cardputer.Keyboard.isPressed()) { - auto state = M5Cardputer.Keyboard.keysState(); - String s; - for (auto c : state.word) s += c; - auto& d = M5Cardputer.Display; - d.fillRect(0, 80, d.width(), 20, TFT_BLACK); - d.drawString("key: " + s, 8, 80); - Serial.printf("keys: word='%s' fn=%d opt=%d ctrl=%d alt=%d shift=%d enter=%d del=%d tab=%d\n", - s.c_str(), state.fn, state.opt, state.ctrl, state.alt, state.shift, - state.enter, state.del, state.tab); + if (millis() - lastLog > 5000) { + lastLog = millis(); + const auto& s = storage->state(); + Serial.printf("status: batt %d%% %dmV raw %d | sd %s %d%% total %lluMB | heap %u min %u | screen %d\n", + battery->estimator().percent(), battery->estimator().millivolts(), + battery->lastRawMillivolts(), s.present ? "yes" : "no", s.usedPercent, + (unsigned long long)(s.totalBytes >> 20), ESP.getFreeHeap(), ESP.getMinFreeHeap(), + (int)power->screen()); + } + if (power->screen() != ScreenState::Off && (dirty || millis() - lastDraw > 1000)) { + lastDraw = millis(); + dirty = false; + draw(); } } diff --git a/src/platform/nvs_store.h b/src/platform/nvs_store.h new file mode 100644 index 0000000..14cc4df --- /dev/null +++ b/src/platform/nvs_store.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +#include "key_value_store.h" + +namespace roro { + +// Settings persistence in the ESP32's internal flash (NVS). +class NvsStore : public KeyValueStore { + public: + void begin() { prefs_.begin("roro", false); } + + bool getInt(const char* key, int32_t& out) override { + if (!prefs_.isKey(key)) return false; + out = prefs_.getInt(key); + return true; + } + bool getString(const char* key, std::string& out) override { + if (!prefs_.isKey(key)) return false; + out = prefs_.getString(key).c_str(); + return true; + } + void putInt(const char* key, int32_t value) override { prefs_.putInt(key, value); } + void putString(const char* key, const std::string& value) override { + prefs_.putString(key, value.c_str()); + } + + private: + Preferences prefs_; +}; + +} // namespace roro diff --git a/src/platform/pins.h b/src/platform/pins.h new file mode 100644 index 0000000..9468003 --- /dev/null +++ b/src/platform/pins.h @@ -0,0 +1,17 @@ +#pragma once + +// Cardputer ADV + Cap LoRa-1262 pin map (see docs.m5stack.com). +namespace roro::pins { + +constexpr int kBatteryAdc = 10; // through a 1:2 divider + +constexpr int kButtonG0 = 0; + +// SPI bus shared by the microSD card and the LoRa radio. +constexpr int kSpiSck = 40; +constexpr int kSpiMiso = 39; +constexpr int kSpiMosi = 14; +constexpr int kSdCs = 12; +constexpr int kLoraCs = 5; + +} // namespace roro::pins diff --git a/src/platform/shared_spi.h b/src/platform/shared_spi.h new file mode 100644 index 0000000..6db3a04 --- /dev/null +++ b/src/platform/shared_spi.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +#include "pins.h" + +namespace roro { + +// The SPI bus used by the microSD card and (from M3) the LoRa radio. The display has its own bus. +// Every user must wrap transfers in beginTransaction/endTransaction, which also locks the bus. +inline SPIClass& sharedSpi() { + static SPIClass spi(HSPI); + static bool begun = false; + if (!begun) { + // Keep the radio deselected so it never answers SD card traffic. + pinMode(pins::kLoraCs, OUTPUT); + digitalWrite(pins::kLoraCs, HIGH); + spi.begin(pins::kSpiSck, pins::kSpiMiso, pins::kSpiMosi, -1); + begun = true; + } + return spi; +} + +} // namespace roro diff --git a/src/services/battery_service.cpp b/src/services/battery_service.cpp new file mode 100644 index 0000000..84ba67a --- /dev/null +++ b/src/services/battery_service.cpp @@ -0,0 +1,19 @@ +#include "battery_service.h" + +#include + +#include "platform/pins.h" + +namespace roro { + +void BatteryService::tick(uint32_t) { + lastRaw_ = analogReadMilliVolts(pins::kBatteryAdc) * 2; + estimator_.addSample(lastRaw_); + int percent = estimator_.percent(); + if (percent != reportedPercent_) { + reportedPercent_ = percent; + bus_.publish(Event::withValues(EventType::BatteryChanged, percent, estimator_.millivolts())); + } +} + +} // namespace roro diff --git a/src/services/battery_service.h b/src/services/battery_service.h new file mode 100644 index 0000000..7867d62 --- /dev/null +++ b/src/services/battery_service.h @@ -0,0 +1,26 @@ +#pragma once + +#include "battery_estimator.h" +#include "event_bus.h" +#include "service.h" + +namespace roro { + +class BatteryService : public Service { + public: + explicit BatteryService(EventBus& bus) : bus_(bus) {} + const char* name() const override { return "battery"; } + uint32_t tickIntervalMs() const override { return 2000; } + void tick(uint32_t nowMs) override; + + const BatteryEstimator& estimator() const { return estimator_; } + int lastRawMillivolts() const { return lastRaw_; } + + private: + EventBus& bus_; + BatteryEstimator estimator_; + int lastRaw_ = 0; + int reportedPercent_ = -1; +}; + +} // namespace roro diff --git a/src/services/clock_service.h b/src/services/clock_service.h new file mode 100644 index 0000000..a9d6222 --- /dev/null +++ b/src/services/clock_service.h @@ -0,0 +1,38 @@ +#pragma once + +#include + +#include "clock_model.h" +#include "event_bus.h" +#include "service.h" +#include "settings.h" + +namespace roro { + +// Holds wall-clock time. Sources (GNSS in M2, NTP in M1, mesh in M4) call set(). +class ClockService : public Service { + public: + ClockService(const Settings& settings, EventBus& bus) : settings_(settings) { + bus.subscribe(EventType::SettingChanged, [this](const Event& e) { + if (e.a == static_cast(Setting::Timezone)) applyTimezone(); + }); + } + const char* name() const override { return "clock"; } + void start() override { applyTimezone(); } + + bool set(int64_t utcSeconds, TimeSource source) { return model_.set(utcSeconds, source, millis()); } + const ClockModel& model() const { return model_; } + + // "14:05" once set; otherwise "--:--". + std::string displayTime() const { + return model_.isSet() ? ClockModel::formatLocalTime(model_.utcNow(millis())) : "--:--"; + } + + private: + void applyTimezone() { ClockModel::applyTimezone(settings_.getString(Setting::Timezone).c_str()); } + + const Settings& settings_; + ClockModel model_; +}; + +} // namespace roro diff --git a/src/services/power_service.cpp b/src/services/power_service.cpp new file mode 100644 index 0000000..bf969db --- /dev/null +++ b/src/services/power_service.cpp @@ -0,0 +1,60 @@ +#include "power_service.h" + +#include +#include + +#include "platform/pins.h" + +namespace roro { + +PowerService::PowerService(const Settings& settings) + : settings_(settings), + policy_(settings.getInt(Setting::DimTimeoutS) * 1000, settings.getInt(Setting::OffTimeoutS) * 1000) {} + +void PowerService::start() { + pinMode(pins::kButtonG0, INPUT_PULLUP); + policy_.activity(millis()); + applyScreen(ScreenState::On); +} + +void PowerService::tick(uint32_t nowMs) { + policy_.setTimeouts(settings_.getInt(Setting::DimTimeoutS) * 1000, + settings_.getInt(Setting::OffTimeoutS) * 1000); + applyScreen(policy_.update(nowMs)); + + bool pressed = digitalRead(pins::kButtonG0) == LOW; + if (button_.update(pressed, nowMs) == ButtonAction::LongPress) powerOff(); +} + +void PowerService::applyScreen(ScreenState state) { + auto& display = M5Cardputer.Display; + int percent = settings_.getInt(Setting::Brightness); + int target = state == ScreenState::On ? percent : state == ScreenState::Dimmed ? percent / 4 : 0; + if (state == applied_ && target == appliedBrightness_) return; + + if (state == ScreenState::Off) + display.sleep(); + else if (applied_ == ScreenState::Off) + display.wakeup(); + display.setBrightness(target * 255 / 100); + applied_ = state; + appliedBrightness_ = target; +} + +void PowerService::powerOff() { + auto& display = M5Cardputer.Display; + display.wakeup(); + display.setBrightness(settings_.getInt(Setting::Brightness) * 255 / 100); + display.fillScreen(TFT_BLACK); + display.setTextColor(TFT_WHITE); + display.drawCenterString("Powering off", display.width() / 2, display.height() / 2 - 8); + // G0 wakes the device on a low level, so wait for release or it would wake immediately. + while (digitalRead(pins::kButtonG0) == LOW) delay(10); + delay(300); + display.sleep(); + display.setBrightness(0); + esp_sleep_enable_ext0_wakeup(static_cast(pins::kButtonG0), 0); + esp_deep_sleep_start(); +} + +} // namespace roro diff --git a/src/services/power_service.h b/src/services/power_service.h new file mode 100644 index 0000000..3730983 --- /dev/null +++ b/src/services/power_service.h @@ -0,0 +1,35 @@ +#pragma once + +#include "power_policy.h" +#include "service.h" +#include "settings.h" + +namespace roro { + +// Screen dimming/off from user activity and G0 handling. A long G0 press powers off (deep sleep, +// woken by G0); Services stop with it. +class PowerService : public Service { + public: + explicit PowerService(const Settings& settings); + const char* name() const override { return "power"; } + uint32_t tickIntervalMs() const override { return 50; } + void start() override; + void tick(uint32_t nowMs) override; + + // Call for every key press before routing it. Returns true if the key must not reach the App. + bool onKey(uint32_t nowMs) { return policy_.activity(nowMs); } + + ScreenState screen() const { return applied_; } + + private: + void applyScreen(ScreenState state); + void powerOff(); + + const Settings& settings_; + PowerPolicy policy_; + PowerButton button_{2000}; + ScreenState applied_ = ScreenState::On; + int appliedBrightness_ = -1; +}; + +} // namespace roro diff --git a/src/services/storage_service.cpp b/src/services/storage_service.cpp new file mode 100644 index 0000000..f694e45 --- /dev/null +++ b/src/services/storage_service.cpp @@ -0,0 +1,36 @@ +#include "storage_service.h" + +#include + +#include "platform/pins.h" +#include "platform/shared_spi.h" + +namespace roro { + +bool StorageService::mount() { + return SD.begin(pins::kSdCs, sharedSpi(), 20000000, "/sd", 5, false); +} + +void StorageService::tick(uint32_t) { + if (mounted_) { + // Detect removal: the root can no longer be opened. + File root = SD.open("/"); + if (!root) { + SD.end(); + mounted_ = false; + } + } + if (!mounted_) mounted_ = mount(); + + if (mounted_) + monitor_.update(true, SD.totalBytes(), SD.usedBytes()); + else + monitor_.update(false, 0, 0); +} + +void StorageService::stop() { + if (mounted_) SD.end(); + mounted_ = false; +} + +} // namespace roro diff --git a/src/services/storage_service.h b/src/services/storage_service.h new file mode 100644 index 0000000..90f5ca1 --- /dev/null +++ b/src/services/storage_service.h @@ -0,0 +1,25 @@ +#pragma once + +#include "service.h" +#include "storage_monitor.h" + +namespace roro { + +// Mounts the microSD card (retrying while absent) and feeds its usage to the StorageMonitor. +class StorageService : public Service { + public: + explicit StorageService(EventBus& bus) : monitor_(bus) {} + const char* name() const override { return "storage"; } + uint32_t tickIntervalMs() const override { return 15000; } + void tick(uint32_t nowMs) override; + void stop() override; + + const StorageState& state() const { return monitor_.state(); } + + private: + bool mount(); + StorageMonitor monitor_; + bool mounted_ = false; +}; + +} // namespace roro diff --git a/test/test_battery/test_battery.cpp b/test/test_battery/test_battery.cpp new file mode 100644 index 0000000..7fe4c01 --- /dev/null +++ b/test/test_battery/test_battery.cpp @@ -0,0 +1,64 @@ +#include + +#include "battery_estimator.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +void test_curve_end_points_and_clamping() { + TEST_ASSERT_EQUAL(100, BatteryEstimator::percentFromMillivolts(4200)); + TEST_ASSERT_EQUAL(100, BatteryEstimator::percentFromMillivolts(4350)); + TEST_ASSERT_EQUAL(0, BatteryEstimator::percentFromMillivolts(3270)); + TEST_ASSERT_EQUAL(0, BatteryEstimator::percentFromMillivolts(2900)); +} + +void test_curve_is_monotonic() { + int previous = 0; + for (int mv = 3200; mv <= 4250; mv += 5) { + int p = BatteryEstimator::percentFromMillivolts(mv); + TEST_ASSERT_TRUE(p >= previous); + previous = p; + } +} + +void test_curve_interpolates_between_points() { + // 3840 mV = 50 %, 3850 mV = 55 % + TEST_ASSERT_EQUAL(50, BatteryEstimator::percentFromMillivolts(3840)); + TEST_ASSERT_INT_WITHIN(1, 52, BatteryEstimator::percentFromMillivolts(3845)); +} + +void test_first_sample_is_reported_immediately() { + BatteryEstimator b; + TEST_ASSERT_FALSE(b.hasReading()); + b.addSample(4000); + TEST_ASSERT_TRUE(b.hasReading()); + TEST_ASSERT_EQUAL(4000, b.millivolts()); +} + +void test_single_outlier_barely_moves_the_estimate() { + BatteryEstimator b; + for (int i = 0; i < 8; i++) b.addSample(3900); + int before = b.percent(); + b.addSample(3500); // e.g. a transmit burst sagging the voltage + TEST_ASSERT_INT_WITHIN(6, before, b.percent()); +} + +void test_estimate_follows_a_sustained_change() { + BatteryEstimator b; + for (int i = 0; i < 8; i++) b.addSample(4100); + for (int i = 0; i < 8; i++) b.addSample(3800); + TEST_ASSERT_EQUAL(3800, b.millivolts()); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_curve_end_points_and_clamping); + RUN_TEST(test_curve_is_monotonic); + RUN_TEST(test_curve_interpolates_between_points); + RUN_TEST(test_first_sample_is_reported_immediately); + RUN_TEST(test_single_outlier_barely_moves_the_estimate); + RUN_TEST(test_estimate_follows_a_sustained_change); + return UNITY_END(); +} diff --git a/test/test_clock/test_clock.cpp b/test/test_clock/test_clock.cpp new file mode 100644 index 0000000..197f391 --- /dev/null +++ b/test/test_clock/test_clock.cpp @@ -0,0 +1,74 @@ +#include + +#include "clock_model.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +static const char* kBrussels = "CET-1CEST,M3.5.0,M10.5.0/3"; +static const int64_t k2026_07_01_1200Z = 1782907200; +static const int64_t k2026_01_15_1200Z = 1768478400; + +void test_clock_is_unset_until_a_source_sets_it() { + ClockModel c; + TEST_ASSERT_FALSE(c.isSet()); + TEST_ASSERT_EQUAL(static_cast(TimeSource::None), static_cast(c.source())); +} + +void test_time_advances_with_uptime_after_being_set() { + ClockModel c; + TEST_ASSERT_TRUE(c.set(k2026_07_01_1200Z, TimeSource::Ntp, 5000)); + TEST_ASSERT_TRUE(c.isSet()); + TEST_ASSERT_EQUAL_INT64(k2026_07_01_1200Z + 10, c.utcNow(15000)); +} + +void test_lower_priority_source_does_not_override() { + ClockModel c; + c.set(k2026_07_01_1200Z, TimeSource::Ntp, 0); + TEST_ASSERT_FALSE(c.set(k2026_07_01_1200Z + 500, TimeSource::Mesh, 0)); + TEST_ASSERT_EQUAL_INT64(k2026_07_01_1200Z, c.utcNow(0)); +} + +void test_equal_or_higher_priority_source_overrides() { + ClockModel c; + c.set(k2026_07_01_1200Z, TimeSource::Mesh, 0); + TEST_ASSERT_TRUE(c.set(k2026_07_01_1200Z + 1, TimeSource::Mesh, 0)); + TEST_ASSERT_TRUE(c.set(k2026_07_01_1200Z + 2, TimeSource::Gnss, 0)); + TEST_ASSERT_EQUAL(static_cast(TimeSource::Gnss), static_cast(c.source())); +} + +void test_utc_now_survives_millis_wraparound() { + ClockModel c; + c.set(k2026_07_01_1200Z, TimeSource::Ntp, 0xFFFFFFFFu - 999); + TEST_ASSERT_EQUAL_INT64(k2026_07_01_1200Z + 2, c.utcNow(1000)); +} + +void test_relative_age_formatting() { + TEST_ASSERT_EQUAL_STRING("now", ClockModel::formatAge(0).c_str()); + TEST_ASSERT_EQUAL_STRING("now", ClockModel::formatAge(59).c_str()); + TEST_ASSERT_EQUAL_STRING("1 min ago", ClockModel::formatAge(60).c_str()); + TEST_ASSERT_EQUAL_STRING("59 min ago", ClockModel::formatAge(3599).c_str()); + TEST_ASSERT_EQUAL_STRING("1 h ago", ClockModel::formatAge(3600).c_str()); + TEST_ASSERT_EQUAL_STRING("23 h ago", ClockModel::formatAge(86399).c_str()); + TEST_ASSERT_EQUAL_STRING("2 d ago", ClockModel::formatAge(2 * 86400).c_str()); +} + +void test_local_time_in_brussels_summer_and_winter() { + ClockModel::applyTimezone(kBrussels); + TEST_ASSERT_EQUAL_STRING("14:00", ClockModel::formatLocalTime(k2026_07_01_1200Z).c_str()); + TEST_ASSERT_EQUAL_STRING("13:00", ClockModel::formatLocalTime(k2026_01_15_1200Z).c_str()); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_clock_is_unset_until_a_source_sets_it); + RUN_TEST(test_time_advances_with_uptime_after_being_set); + RUN_TEST(test_lower_priority_source_does_not_override); + RUN_TEST(test_equal_or_higher_priority_source_overrides); + RUN_TEST(test_utc_now_survives_millis_wraparound); + RUN_TEST(test_relative_age_formatting); + RUN_TEST(test_local_time_in_brussels_summer_and_winter); + return UNITY_END(); +} diff --git a/test/test_power/test_power.cpp b/test/test_power/test_power.cpp new file mode 100644 index 0000000..0751398 --- /dev/null +++ b/test/test_power/test_power.cpp @@ -0,0 +1,65 @@ +#include + +#include "power_policy.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +void test_screen_dims_then_turns_off_after_inactivity() { + PowerPolicy p(30000, 60000); + p.activity(0); + TEST_ASSERT_EQUAL(static_cast(ScreenState::On), static_cast(p.update(29999))); + TEST_ASSERT_EQUAL(static_cast(ScreenState::Dimmed), static_cast(p.update(30000))); + TEST_ASSERT_EQUAL(static_cast(ScreenState::Off), static_cast(p.update(60000))); +} + +void test_key_on_dimmed_screen_wakes_and_still_acts() { + PowerPolicy p(30000, 60000); + p.activity(0); + p.update(40000); + TEST_ASSERT_FALSE(p.activity(40000)); // not swallowed + TEST_ASSERT_EQUAL(static_cast(ScreenState::On), static_cast(p.update(40001))); +} + +void test_key_on_off_screen_only_wakes() { + PowerPolicy p(30000, 60000); + p.activity(0); + p.update(70000); + TEST_ASSERT_TRUE(p.activity(70000)); // swallowed + TEST_ASSERT_EQUAL(static_cast(ScreenState::On), static_cast(p.update(70001))); +} + +void test_new_timeouts_apply_immediately() { + PowerPolicy p(30000, 60000); + p.activity(0); + p.setTimeouts(10000, 20000); + TEST_ASSERT_EQUAL(static_cast(ScreenState::Off), static_cast(p.update(20000))); +} + +void test_short_press_reported_on_release() { + PowerButton b(2000); + TEST_ASSERT_EQUAL(static_cast(ButtonAction::None), static_cast(b.update(true, 0))); + TEST_ASSERT_EQUAL(static_cast(ButtonAction::None), static_cast(b.update(true, 500))); + TEST_ASSERT_EQUAL(static_cast(ButtonAction::ShortPress), static_cast(b.update(false, 600))); +} + +void test_long_press_fires_once_while_still_held() { + PowerButton b(2000); + b.update(true, 0); + TEST_ASSERT_EQUAL(static_cast(ButtonAction::LongPress), static_cast(b.update(true, 2000))); + TEST_ASSERT_EQUAL(static_cast(ButtonAction::None), static_cast(b.update(true, 3000))); + TEST_ASSERT_EQUAL(static_cast(ButtonAction::None), static_cast(b.update(false, 3100))); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_screen_dims_then_turns_off_after_inactivity); + RUN_TEST(test_key_on_dimmed_screen_wakes_and_still_acts); + RUN_TEST(test_key_on_off_screen_only_wakes); + RUN_TEST(test_new_timeouts_apply_immediately); + RUN_TEST(test_short_press_reported_on_release); + RUN_TEST(test_long_press_fires_once_while_still_held); + return UNITY_END(); +} diff --git a/test/test_settings/test_settings.cpp b/test/test_settings/test_settings.cpp new file mode 100644 index 0000000..49010c1 --- /dev/null +++ b/test/test_settings/test_settings.cpp @@ -0,0 +1,184 @@ +#include + +#include +#include +#include + +#include "event_bus.h" +#include "settings.h" + +using namespace roro; + +struct MemoryStore : KeyValueStore { + std::map ints; + std::map strings; + bool getInt(const char* key, int32_t& out) override { + auto it = ints.find(key); + if (it == ints.end()) return false; + out = it->second; + return true; + } + bool getString(const char* key, std::string& out) override { + auto it = strings.find(key); + if (it == strings.end()) return false; + out = it->second; + return true; + } + void putInt(const char* key, int32_t v) override { ints[key] = v; } + void putString(const char* key, const std::string& v) override { strings[key] = v; } +}; + +void setUp() {} +void tearDown() {} + +void test_defaults_when_store_is_empty() { + MemoryStore store; + EventBus bus; + Settings s(store, bus); + s.load(); + TEST_ASSERT_FALSE(s.getBool(Setting::SetupDone)); + TEST_ASSERT_EQUAL(60, s.getInt(Setting::Brightness)); + TEST_ASSERT_EQUAL(30, s.getInt(Setting::DimTimeoutS)); + TEST_ASSERT_EQUAL(60, s.getInt(Setting::OffTimeoutS)); + TEST_ASSERT_TRUE(s.getBool(Setting::Sound)); + TEST_ASSERT_TRUE(s.getBool(Setting::ProbeMacRaw)); + TEST_ASSERT_EQUAL_STRING("EU868", s.getString(Setting::Region).c_str()); + TEST_ASSERT_EQUAL_STRING("CET-1CEST,M3.5.0,M10.5.0/3", s.getString(Setting::Timezone).c_str()); +} + +void test_set_persists_and_survives_reload() { + MemoryStore store; + EventBus bus; + { + Settings s(store, bus); + s.load(); + TEST_ASSERT_TRUE(s.setInt(Setting::Brightness, 80)); + TEST_ASSERT_TRUE(s.setString(Setting::LongName, "Clément")); + } + Settings again(store, bus); + again.load(); + TEST_ASSERT_EQUAL(80, again.getInt(Setting::Brightness)); + TEST_ASSERT_EQUAL_STRING("Clément", again.getString(Setting::LongName).c_str()); +} + +void test_out_of_range_values_are_rejected_and_not_stored() { + MemoryStore store; + EventBus bus; + Settings s(store, bus); + s.load(); + TEST_ASSERT_FALSE(s.setInt(Setting::Brightness, 5)); // would make the screen unreadable + TEST_ASSERT_FALSE(s.setInt(Setting::Brightness, 101)); + TEST_ASSERT_EQUAL(60, s.getInt(Setting::Brightness)); + TEST_ASSERT_EQUAL(0, store.ints.count(Settings::key(Setting::Brightness))); +} + +void test_corrupt_stored_value_falls_back_to_default() { + MemoryStore store; + EventBus bus; + store.ints[Settings::key(Setting::Brightness)] = 9999; + Settings s(store, bus); + s.load(); + TEST_ASSERT_EQUAL(60, s.getInt(Setting::Brightness)); +} + +void test_dim_timeout_must_stay_below_off_timeout() { + MemoryStore store; + EventBus bus; + Settings s(store, bus); + s.load(); + TEST_ASSERT_FALSE(s.setInt(Setting::DimTimeoutS, 60)); // off is 60 + TEST_ASSERT_FALSE(s.setInt(Setting::OffTimeoutS, 30)); // dim is 30 + TEST_ASSERT_TRUE(s.setInt(Setting::OffTimeoutS, 120)); + TEST_ASSERT_TRUE(s.setInt(Setting::DimTimeoutS, 90)); +} + +void test_short_name_is_one_to_four_bytes() { + MemoryStore store; + EventBus bus; + Settings s(store, bus); + s.load(); + TEST_ASSERT_FALSE(s.setString(Setting::ShortName, "")); + TEST_ASSERT_FALSE(s.setString(Setting::ShortName, "ROROX")); + TEST_ASSERT_TRUE(s.setString(Setting::ShortName, "RORO")); +} + +void test_long_name_is_limited_to_39_bytes() { + MemoryStore store; + EventBus bus; + Settings s(store, bus); + s.load(); + TEST_ASSERT_TRUE(s.setString(Setting::LongName, std::string(39, 'a'))); + TEST_ASSERT_FALSE(s.setString(Setting::LongName, std::string(40, 'a'))); + TEST_ASSERT_FALSE(s.setString(Setting::LongName, "")); +} + +void test_only_known_regions_are_accepted() { + MemoryStore store; + EventBus bus; + Settings s(store, bus); + s.load(); + TEST_ASSERT_FALSE(s.setString(Setting::Region, "US915")); + TEST_ASSERT_TRUE(s.setString(Setting::Region, "EU868")); +} + +void test_type_mismatch_is_rejected() { + MemoryStore store; + EventBus bus; + Settings s(store, bus); + s.load(); + TEST_ASSERT_FALSE(s.setString(Setting::Brightness, "80")); + TEST_ASSERT_FALSE(s.setInt(Setting::LongName, 3)); +} + +void test_change_publishes_setting_changed_once() { + MemoryStore store; + EventBus bus; + Settings s(store, bus); + s.load(); + int changes = 0; + int32_t which = -1; + bus.subscribe(EventType::SettingChanged, [&](const Event& e) { + changes++; + which = e.a; + }); + + s.setInt(Setting::Brightness, 70); + s.setInt(Setting::Brightness, 70); // same value: no event + s.setInt(Setting::Brightness, 1); // invalid: no event + bus.dispatch(); + + TEST_ASSERT_EQUAL(1, changes); + TEST_ASSERT_EQUAL(static_cast(Setting::Brightness), which); +} + +void test_transmit_requires_confirmed_region() { + MemoryStore store; + EventBus bus; + Settings s(store, bus); + s.load(); + TEST_ASSERT_FALSE(s.canTransmit()); + s.setBool(Setting::RegionConfirmed, true); + TEST_ASSERT_TRUE(s.canTransmit()); +} + +void test_storage_keys_fit_nvs_limit() { + for (int i = 0; i < static_cast(Setting::Count); i++) + TEST_ASSERT_TRUE(std::strlen(Settings::key(static_cast(i))) <= 15); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_defaults_when_store_is_empty); + RUN_TEST(test_set_persists_and_survives_reload); + RUN_TEST(test_out_of_range_values_are_rejected_and_not_stored); + RUN_TEST(test_corrupt_stored_value_falls_back_to_default); + RUN_TEST(test_dim_timeout_must_stay_below_off_timeout); + RUN_TEST(test_short_name_is_one_to_four_bytes); + RUN_TEST(test_long_name_is_limited_to_39_bytes); + RUN_TEST(test_only_known_regions_are_accepted); + RUN_TEST(test_type_mismatch_is_rejected); + RUN_TEST(test_change_publishes_setting_changed_once); + RUN_TEST(test_transmit_requires_confirmed_region); + RUN_TEST(test_storage_keys_fit_nvs_limit); + return UNITY_END(); +} diff --git a/test/test_storage/test_storage.cpp b/test/test_storage/test_storage.cpp new file mode 100644 index 0000000..159ccce --- /dev/null +++ b/test/test_storage/test_storage.cpp @@ -0,0 +1,108 @@ +#include + +#include + +#include "event_bus.h" +#include "storage_monitor.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +static const uint64_t GB = 1024ull * 1024 * 1024; +static const uint64_t MB = 1024ull * 1024; + +struct Fixture { + EventBus bus{16}; + StorageMonitor monitor{bus}; + std::vector notifications; + std::vector thresholds; + Fixture() { + bus.subscribe(EventType::Notification, [&](const Event& e) { notifications.push_back(e); }); + bus.subscribe(EventType::StorageThreshold, [&](const Event& e) { thresholds.push_back(e); }); + } + void update(uint64_t used, uint64_t total = 32 * GB) { + monitor.update(true, total, used); + bus.dispatch(); + } +}; + +void test_no_card_means_nothing_can_be_written() { + Fixture f; + f.monitor.update(false, 0, 0); + TEST_ASSERT_FALSE(f.monitor.state().present); + TEST_ASSERT_FALSE(f.monitor.state().logsAllowed); + TEST_ASSERT_FALSE(f.monitor.state().capturesAllowed); +} + +void test_below_80_percent_everything_is_allowed_quietly() { + Fixture f; + f.update(16 * GB); + TEST_ASSERT_EQUAL(50, f.monitor.state().usedPercent); + TEST_ASSERT_TRUE(f.monitor.state().logsAllowed); + TEST_ASSERT_TRUE(f.monitor.state().capturesAllowed); + TEST_ASSERT_EQUAL(0, f.notifications.size()); +} + +void test_crossing_80_percent_warns_once_per_boot() { + Fixture f; + f.update(26 * GB); // 81 % + TEST_ASSERT_EQUAL(1, f.notifications.size()); + f.update(26 * GB); + f.update(10 * GB); // cleaned up + f.update(27 * GB); // over again, same boot + TEST_ASSERT_EQUAL(1, f.notifications.size()); +} + +void test_warning_is_a_warning_level_notification() { + Fixture f; + f.update(26 * GB); + TEST_ASSERT_EQUAL(static_cast(NotificationLevel::Warning), f.notifications[0].a); +} + +void test_at_90_percent_logs_stop_but_captures_continue() { + Fixture f; + f.update(29 * GB); // 90.6 % + TEST_ASSERT_FALSE(f.monitor.state().logsAllowed); + TEST_ASSERT_TRUE(f.monitor.state().capturesAllowed); +} + +void test_logs_resume_after_clean_up_below_90() { + Fixture f; + f.update(29 * GB); + f.update(20 * GB); + TEST_ASSERT_TRUE(f.monitor.state().logsAllowed); +} + +void test_captures_stop_when_card_is_full() { + Fixture f; + f.update(32 * GB - MB); // less than the 2 MiB reserve left + TEST_ASSERT_FALSE(f.monitor.state().capturesAllowed); +} + +void test_threshold_event_only_when_level_changes() { + Fixture f; + f.update(10 * GB); // level 0, initial + f.update(26 * GB); // 80 + f.update(26 * GB); // still 80 + f.update(29 * GB); // 90 + f.update(32 * GB); // full + TEST_ASSERT_EQUAL(3, f.thresholds.size()); + TEST_ASSERT_EQUAL(80, f.thresholds[0].b); + TEST_ASSERT_EQUAL(90, f.thresholds[1].b); + TEST_ASSERT_EQUAL(100, f.thresholds[2].b); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_no_card_means_nothing_can_be_written); + RUN_TEST(test_below_80_percent_everything_is_allowed_quietly); + RUN_TEST(test_crossing_80_percent_warns_once_per_boot); + RUN_TEST(test_warning_is_a_warning_level_notification); + RUN_TEST(test_at_90_percent_logs_stop_but_captures_continue); + RUN_TEST(test_logs_resume_after_clean_up_below_90); + RUN_TEST(test_captures_stop_when_card_is_full); + RUN_TEST(test_threshold_event_only_when_level_changes); + return UNITY_END(); +}