Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions include/RuntimeData.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once

#include <TaskSchedulerDeclarations.h>
#include <mutex>
#include <atomic>


class RuntimeClass {
public:
RuntimeClass() = default;
~RuntimeClass() = default;
RuntimeClass(const RuntimeClass&) = delete;
RuntimeClass& operator=(const RuntimeClass&) = delete;
RuntimeClass(RuntimeClass&&) = delete;
RuntimeClass& operator=(RuntimeClass&&) = delete;

void init(Scheduler& scheduler);
enum class ReadMode : uint8_t { START_UP, ON_DEMAND };
bool read(ReadMode const mode = ReadMode::START_UP); // read runtime data
bool write(uint16_t const freezeMinutes = 10); // do not write if last write operation was less than freezeMinutes ago
void requestWriteOnNextTaskLoop(void) { _writeNow.store(true); }; // use this member function to store data on demand
void requestReadOnNextTaskLoop(void) { _readNow.store(true); }; // use this member function to read data on demand

uint16_t getWriteCount(void) const;
time_t getWriteEpochTime(void) const;
bool getReadState(void) const { return _readOK.load(); }
bool getWriteState(void) const { return _writeOK.load(); }
String getWriteCountAndTimeString(void) const;

private:
void loop(void);
bool getWriteTrigger(void);

Task _loopTask;
std::atomic<bool> _readOK = false; // true if the last read operation was successful
std::atomic<bool> _writeOK = false; // true if the last write operation was successful
std::atomic<bool> _readNow = false; // if true, the data is read in the next task loop()
std::atomic<bool> _writeNow = false; // if true, the data is stored in the next task loop()
mutable std::mutex _mutex; // to protect the shared data below
bool _lastTrigger = false; // auxiliary value to prevent multiple triggering on the same day
uint16_t _fileVersion = 0; // shared data: version of the runtime data file, prepared for future migration support
uint16_t _writeCount = 0; // shared data: number of write operations
time_t _writeEpoch = 0; // shared data: epoch time when the data was written
};

extern RuntimeClass RuntimeData;
264 changes: 264 additions & 0 deletions src/RuntimeData.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
// SPDX-License-Identifier: GPL-2.0-or-later

/* Runtime Data Management
*
* Read and write runtime data persistent on LittleFS
* - The data is stored in JSON format
* - The data is written during WebApp 'OTA firmware upgrade' and during Webapp 'Reboot'
* - For security reasons such as 'unexpected power cycles' or 'physical resets', data is also written once a day at 00:05
* - The data will not be written if the last write operation was less than one hour ago. ('OTA firmware upgrade' and 'Reboot')
* - Threadsave access to the data is provided by a mutex.
*
* How to use:
* - Runtime data must be added in the read() and write() methods.
* - To avoid reenter deadlocks, do not call write() or read() from a locally locked mutex to save locally data on demand!
* - Use requestWriteOnNextTaskLoop() and requestReadOnNextTaskLoop() to avoid deadlocks if you want to handle locally data on demand.
*
* 2025.09.11 - 1.0 - first version
* 2025.12.01 - 1.1 - added read mode ON_DEMAND and START_UP
*/

#include <Utils.h>
#include <LittleFS.h>
#include <esp_log.h>
#include <ArduinoJson.h>
#include "RuntimeData.h"


#undef TAG
static const char* TAG = "runtime";


constexpr const char* RUNTIME_FILENAME = "/runtime.json"; // filename of the runtime data file
constexpr uint16_t RUNTIME_VERSION = 1; // version prepared for future migration support


RuntimeClass RuntimeData; // singleton instance


/*
* Init the runtime data loop task
*/
void RuntimeClass::init(Scheduler& scheduler)
{
scheduler.addTask(_loopTask);
_loopTask.setCallback(std::bind(&RuntimeClass::loop, this));
_loopTask.setIterations(TASK_FOREVER);
_loopTask.setInterval(60 * 1000); // every minute
_loopTask.enable();
}


/*
* The runtime data loop is called every minute
*/
void RuntimeClass::loop(void)
{

// check if we need to write the runtime data, either it is 00:05 or on request
if (_writeNow.exchange(false) || getWriteTrigger()) {
write(0); // no freeze time.
}

// check if we need to read the runtime data on request
// for example, if some data is not available during startup
if (_readNow.exchange(false)) {
read(ReadMode::ON_DEMAND); // read data that can be read on demand
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.


/*
* Writes the runtime data to LittleFS file
* freezeMinutes: Minimum necessary time [minutes] between now and last write operation
*/
bool RuntimeClass::write(uint16_t const freezeMinutes)
{
auto cleanExit = [this](const bool writeOk, const char* text) -> bool {
if (writeOk) {
ESP_LOGI(TAG,"%s", text);
} else {
ESP_LOGE(TAG,"%s", text);
}
_writeOK.store(writeOk);
return writeOk;
};

// we need a valid epoch time before we can write the runtime data
time_t nextEpoch;
if (!Utils::getEpoch(&nextEpoch, 1)) { return cleanExit(false, "Local time not available, skipping write"); }
uint16_t nextCount;

{
std::lock_guard<std::mutex> lock(_mutex);

// check minimum interval between writes (enforced only when freezeMinutes > 0)
if ((freezeMinutes > 0) && (_writeEpoch != 0) && (difftime(nextEpoch, _writeEpoch) < 60 * freezeMinutes)) {
return cleanExit(false, "Time interval too short, skipping write");
}

// prepare the next write count
nextCount = _writeCount + 1;

} // mutex is automatically released when lock goes out of this scope

// prepare the JSON document and store the runtime data in it is done outside the
// mutex protection to minimize the time the mutex is locked.
JsonDocument doc;
JsonObject info = doc["info"].to<JsonObject>();
info["version"] = RUNTIME_VERSION;
info["save_count"] = nextCount;
info["save_epoch"] = nextEpoch;

// serialize additional runtime data here
// make sure the additional data remains under its own mutex protection.
// todo: serialize additional runtime data

if (!Utils::checkJsonAlloc(doc, __FUNCTION__, __LINE__)) {
return cleanExit(false, "JSON alloc fault, skipping write");
}

File fRuntime = LittleFS.open(RUNTIME_FILENAME, "w");
if (!fRuntime) { return cleanExit(false, "Failed to open file for writing"); }

if (serializeJson(doc, fRuntime) == 0) {
fRuntime.close();
return cleanExit(false, "Failed to serialize to file");
}

fRuntime.close();

{
std::lock_guard<std::mutex> lock(_mutex);

// commit the new state only after a successful write
_fileVersion = RUNTIME_VERSION;
_writeEpoch = nextEpoch;
_writeCount = nextCount;

} // mutex is automatically released when lock goes out of this scope

return cleanExit(true, "Written to file");
}


/*
* Read the runtime data from LittleFS file
* mode = START_UP: read data that can be initialized during startup
* mode = ON_DEMAND: read data that can not be read during startup
*/
bool RuntimeClass::read(ReadMode const mode)
{
bool readOk = false;
JsonDocument doc;

// Note: We do not exit on read or allocation errors. In that case we need the default values
Comment thread
AndreasBoehm marked this conversation as resolved.
File fRuntime = LittleFS.open(RUNTIME_FILENAME, "r", false);
if (fRuntime) {
Utils::skipBom(fRuntime);
DeserializationError error = deserializeJson(doc, fRuntime);
if (!error && Utils::checkJsonAlloc(doc, __FUNCTION__, __LINE__)) {
readOk = true; // success of reading the runtime data
}
}

JsonObject info = doc["info"];
{ // mutex is automatically released when lock goes out of this scope
std::lock_guard<std::mutex> lock(_mutex);
_fileVersion = info["version"] | 0U; // 0 means no file available and runtime data is not valid
_writeCount = info["save_count"] | 0U;
_writeEpoch = info["save_epoch"] | 0U;
} // mutex is automatically released when lock goes out of this scope

// deserialize additional runtime data here, prepare default values and protect the shared data with a mutex
// use ReadMode::START_UP for all data that can be initialized during startup
if (mode == ReadMode::START_UP) {
; // todo: deserialize additional runtime data that can be initialized during startup
} else {
; // todo: deserialize additional runtime data that can not be initialized during startup
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What data to you have in mind that can not be read/initialized during startup?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Data that uses allocated memory. Like auto upInv = PowerLimiterInverter::create(invConfig);
So far I have only encountered one case... The correction list for inverter transmission in the case of the 24-VDC problem.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe i am not getting how this will work in the end.

If i trigger read() what will happen?
RuntimeClass will access e.g. PowerLimiterInverter and set a value on it?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found a example in a different PR of yours: https://github.com/hoylabs/OpenDTU-OnBattery/pull/2372/changes#diff-783e5c69ea25ae8ae20e5bd1f5faf7895f7ad15abebdb4ead79be611ed91ea23R174

I don't like the idea of injecting stored data into other objects.
Other objects should read from RuntimeData when they need the data.

Other objects will be tightly coupled to the RuntimeData class/object with the current approach.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I talke to claude about it and this is the result.

It violates both Single Responsibility (the class handles I/O and orchestrates restoration) and Open/Closed (not open for extension without modification).

inversion of control via an interface/protocol

Each subsystem should own its own serialization logic and register itself with a generic runtime-data manager. The manager only knows about the interface, not the concrete classes.

// Interface each subsystem implements
class IRuntimeDataProvider {
public:
    virtual std::string getId() const = 0;
    virtual JsonObject serialize() const = 0;
    virtual void deserialize(const JsonObject& data) = 0;
    virtual ~IRuntimeDataProvider() = default;
};

// Generic manager — knows nothing about PowerLimiter etc.
class RuntimeDataManager {
public:
    void registerProvider(IRuntimeDataProvider* provider) {
        _providers[provider->getId()] = provider;
    }

    void saveAll(const std::string& path) {
        JsonDocument doc;
        for (auto& [id, provider] : _providers)
            doc[id] = provider->serialize();
        writeJsonToDisk(doc, path);
    }

    void loadAll(const std::string& path) {
        JsonDocument doc = readJsonFromDisk(path);
        for (auto& [id, provider] : _providers)
            if (doc.containsKey(id))
                provider->deserialize(doc[id]);
    }

private:
    std::map<std::string, IRuntimeDataProvider*> _providers;
};

Then each subsystem is self-contained:

class PowerLimiter : public IRuntimeDataProvider {
public:
    std::string getId() const override { return "powerlimiter"; }
    JsonObject serialize() const override { /* pack own state */ }
    void deserialize(const JsonObject& data) override { /* restore own state */ }
};

And wiring it up happens at the composition root (e.g. main or your app init), not inside the manager:

manager.registerProvider(&powerLimiter);
manager.registerProvider(&battery);
manager.registerProvider(&solar);

What you gain:

  • RuntimeDataManager never changes when you add a subsystem
  • Each subsystem owns its own data schema — no leakage
  • Trivially testable in isolation
  • Ordering, optional providers, missing keys on load all handled generically

This pattern fits well with embedded/OpenDTU-style architectures where subsystems are long-lived singletons — registration in the constructor or an init() call works cleanly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand your concerns.

RuntimeDataManager never changes when you add a subsystem
I fully agree. (I just reused the pattern from the config)

Each subsystem owns its own data schema — no leakage
This is already the way of working. Hmmm ... maybe claude did not get it because this PR is only the shell?

Trivially testable in isolation
Ok

Ordering, optional providers, missing keys on load all handled generically
Ok, understood:

I will need some time to implement the new concept. 🤔

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me think about this for some more time before you change something

}


if (fRuntime) { fRuntime.close(); }
if (readOk) {
ESP_LOGI(TAG, "Read successfully");
} else {
ESP_LOGE(TAG, "Read fault, using default values");
}
_readOK.store(readOk);
return readOk;
}


/*
* Get the write counter
*/
uint16_t RuntimeClass::getWriteCount(void) const
{
std::lock_guard<std::mutex> lock(_mutex);
return _writeCount;
}


/*
* Get the write epoch time
*/
time_t RuntimeClass::getWriteEpochTime(void) const
{
std::lock_guard<std::mutex> lock(_mutex);
return _writeEpoch;
}


/*
* Get the write count and time as string
* Format: "<count> / <dd>-<mon> <hh>:<mm>"
* If epoch time and local time is not available the time is replaced by "no time"
*/
String RuntimeClass::getWriteCountAndTimeString(void) const
{
time_t epoch;
uint16_t count;
{
std::lock_guard<std::mutex> lock(_mutex);
epoch = _writeEpoch;
count = _writeCount;
} // mutex is automatically released when lock goes out of this scope

char buf[32] = "";
struct tm time;

// Before we can convert the epoch to local time, we need to ensure we've received the correct time
// from the time server. This may take some time after the system startup.
if ((epoch != 0) && (getLocalTime(&time, 1))) {
localtime_r(&epoch, &time);
strftime(buf, sizeof(buf), " / %d-%h %R", &time);
} else {
snprintf(buf, sizeof(buf), " / no time");
}
String ctString = String(count) + String(buf);
return ctString;
}


/*
* Returns true once a day between 00:05 - 00:10
*/
bool RuntimeClass::getWriteTrigger(void) {

struct tm nowTime;
if (!getLocalTime(&nowTime, 1)) {
return false;
}

std::lock_guard<std::mutex> lock(_mutex);
if ((nowTime.tm_hour == 0) && (nowTime.tm_min >= 5) && (nowTime.tm_min <= 10)) {
if (_lastTrigger == false) {
_lastTrigger = true;
return true;
}
} else {
_lastTrigger = false;
}
return false;
}
4 changes: 4 additions & 0 deletions src/WebApi_firmware.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#include <AsyncJson.h>
#include <Update.h>
#include "esp_partition.h"
#include "RuntimeData.h"

void WebApiFirmwareClass::init(AsyncWebServer& server, Scheduler& scheduler)
{
Expand Down Expand Up @@ -47,6 +48,9 @@ void WebApiFirmwareClass::onFirmwareUpdateFinish(AsyncWebServerRequest* request)
response->addHeader(asyncsrv::T_Connection, asyncsrv::T_close);
response->addHeader(asyncsrv::T_CORS_ACAO, "*");
request->send(response);

// write the runtime data to LittleFS, but do not write if last write operation was less than 10 min ago
RuntimeData.write(10);
RestartHelper.triggerRestart();
}

Expand Down
4 changes: 4 additions & 0 deletions src/WebApi_maintenance.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include "WebApi.h"
#include "WebApi_errors.h"
#include <AsyncJson.h>
#include "RuntimeData.h"

void WebApiMaintenanceClass::init(AsyncWebServer& server, Scheduler& scheduler)
{
Expand Down Expand Up @@ -43,6 +44,9 @@ void WebApiMaintenanceClass::onRebootPost(AsyncWebServerRequest* request)
retMsg["code"] = WebApiError::MaintenanceRebootTriggered;

WebApi.sendJsonResponse(request, response, __FUNCTION__, __LINE__);

// write the runtime data to LittleFS, but do not write if last write operation was less than 10 min ago
RuntimeData.write(10);
RestartHelper.triggerRestart();
} else {
retMsg["message"] = "Reboot cancled!";
Expand Down
2 changes: 2 additions & 0 deletions src/WebApi_sysstatus.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
#include <Hoymiles.h>
#include <LittleFS.h>
#include <ResetReason.h>
#include "RuntimeData.h"

void WebApiSysstatusClass::init(AsyncWebServer& server, Scheduler& scheduler)
{
Expand Down Expand Up @@ -79,6 +80,7 @@ void WebApiSysstatusClass::onSystemStatus(AsyncWebServerRequest* request)
root["resetreason_1"] = reason;

root["cfgsavecount"] = Configuration.get().Cfg.SaveCount;
root["runtime_savecount"] = RuntimeData.getWriteCountAndTimeString();

char version[16];
snprintf(version, sizeof(version), "%d.%d.%d", CONFIG_VERSION >> 24 & 0xff, CONFIG_VERSION >> 16 & 0xff, CONFIG_VERSION >> 8 & 0xff);
Expand Down
8 changes: 7 additions & 1 deletion src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
#include <LittleFS.h>
#include <TaskScheduler.h>
#include <esp_heap_caps.h>
#include "RuntimeData.h"

#undef TAG
static const char* TAG = "main";
Expand Down Expand Up @@ -145,12 +146,17 @@ void setup()
Datastore.init(scheduler);
RestartHelper.init(scheduler);

// OpenDTU-OnBattery-specific initializations go below
// OpenDTU-OnBattery-specific initializations go between here...
SolarCharger.init(scheduler);
PowerMeter.init(scheduler);
PowerLimiter.init(scheduler);
GridCharger.init(scheduler);
Battery.init(scheduler);
// ... and here (before RuntimeData)

// Must be done after all other components have been initialized
Comment thread
SW-Niko marked this conversation as resolved.
RuntimeData.init(scheduler);
RuntimeData.read();
Comment thread
SW-Niko marked this conversation as resolved.

ESP_LOGI(TAG, "Startup complete");
}
Expand Down
Loading