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
+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