Public Access
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:
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user