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
|
||||
+116
-37
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user