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