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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-02 09:47:36 +02:00
co-authored by Claude Opus 5.5
parent ae0726b7f9
commit 987406fa7c
28 changed files with 1434 additions and 39 deletions
+4 -2
View File
@@ -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
};
+49
View File
@@ -0,0 +1,49 @@
#include "battery_estimator.h"
#include <algorithm>
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<int, kWindow> sorted = samples_;
auto middle = sorted.begin() + count_ / 2;
std::nth_element(sorted.begin(), middle, sorted.begin() + count_);
return *middle;
}
} // namespace roro
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <array>
#include <cstddef>
#include <cstdint>
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<int, kWindow> samples_{};
size_t next_ = 0;
size_t count_ = 0;
};
} // namespace roro
+49
View File
@@ -0,0 +1,49 @@
#include "clock_model.h"
#include <cstdio>
#include <cstdlib>
#include <ctime>
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<int64_t>(static_cast<uint32_t>(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<time_t>(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<int>(seconds / 60));
else if (seconds < 86400)
std::snprintf(buf, sizeof(buf), "%d h ago", static_cast<int>(seconds / 3600));
else
std::snprintf(buf, sizeof(buf), "%d d ago", static_cast<int>(seconds / 86400));
return buf;
}
} // namespace roro
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <cstdint>
#include <string>
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
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <cstdint>
#include <string>
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
+36
View File
@@ -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
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include <cstdint>
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
+118
View File
@@ -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<size_t>(Setting::Count),
"every Setting needs a definition");
const char* const kKnownRegions[] = {"EU868"};
const Definition& def(Setting s) { return kDefinitions[static_cast<size_t>(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<Setting>(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<size_t>(s)].i; }
bool Settings::getBool(Setting s) const { return getInt(s) != 0; }
const std::string& Settings::getString(Setting s) const { return values_[static_cast<size_t>(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<size_t>(d.min) || value.size() > static_cast<size_t>(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<size_t>(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<size_t>(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<size_t>(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<int32_t>(s)));
}
} // namespace roro
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include <array>
#include <string>
#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<Value, static_cast<size_t>(Setting::Count)> values_;
};
} // namespace roro
+32
View File
@@ -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<int>(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<int32_t>(NotificationLevel::Warning)));
}
}
} // namespace roro
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <cstdint>
#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
+116 -37
View File
@@ -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 <M5Cardputer.h>
#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();
}
}
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include <Preferences.h>
#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
+17
View File
@@ -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
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <SPI.h>
#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
+19
View File
@@ -0,0 +1,19 @@
#include "battery_service.h"
#include <Arduino.h>
#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
+26
View File
@@ -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
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <Arduino.h>
#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<int32_t>(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
+60
View File
@@ -0,0 +1,60 @@
#include "power_service.h"
#include <M5Cardputer.h>
#include <esp_sleep.h>
#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<gpio_num_t>(pins::kButtonG0), 0);
esp_deep_sleep_start();
}
} // namespace roro
+35
View File
@@ -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
+36
View File
@@ -0,0 +1,36 @@
#include "storage_service.h"
#include <SD.h>
#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
+25
View File
@@ -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
+64
View File
@@ -0,0 +1,64 @@
#include <unity.h>
#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();
}
+74
View File
@@ -0,0 +1,74 @@
#include <unity.h>
#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<int>(TimeSource::None), static_cast<int>(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<int>(TimeSource::Gnss), static_cast<int>(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();
}
+65
View File
@@ -0,0 +1,65 @@
#include <unity.h>
#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<int>(ScreenState::On), static_cast<int>(p.update(29999)));
TEST_ASSERT_EQUAL(static_cast<int>(ScreenState::Dimmed), static_cast<int>(p.update(30000)));
TEST_ASSERT_EQUAL(static_cast<int>(ScreenState::Off), static_cast<int>(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<int>(ScreenState::On), static_cast<int>(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<int>(ScreenState::On), static_cast<int>(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<int>(ScreenState::Off), static_cast<int>(p.update(20000)));
}
void test_short_press_reported_on_release() {
PowerButton b(2000);
TEST_ASSERT_EQUAL(static_cast<int>(ButtonAction::None), static_cast<int>(b.update(true, 0)));
TEST_ASSERT_EQUAL(static_cast<int>(ButtonAction::None), static_cast<int>(b.update(true, 500)));
TEST_ASSERT_EQUAL(static_cast<int>(ButtonAction::ShortPress), static_cast<int>(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<int>(ButtonAction::LongPress), static_cast<int>(b.update(true, 2000)));
TEST_ASSERT_EQUAL(static_cast<int>(ButtonAction::None), static_cast<int>(b.update(true, 3000)));
TEST_ASSERT_EQUAL(static_cast<int>(ButtonAction::None), static_cast<int>(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();
}
+184
View File
@@ -0,0 +1,184 @@
#include <unity.h>
#include <cstring>
#include <map>
#include <string>
#include "event_bus.h"
#include "settings.h"
using namespace roro;
struct MemoryStore : KeyValueStore {
std::map<std::string, int32_t> ints;
std::map<std::string, std::string> 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<int32_t>(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<int>(Setting::Count); i++)
TEST_ASSERT_TRUE(std::strlen(Settings::key(static_cast<Setting>(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();
}
+108
View File
@@ -0,0 +1,108 @@
#include <unity.h>
#include <vector>
#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<Event> notifications;
std::vector<Event> 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<int32_t>(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();
}