-
-
Notifications
You must be signed in to change notification settings - Fork 102
Save Runtime data in LittleFS #2262
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: development
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; |
| 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 | ||
| } | ||
| } | ||
|
|
||
|
|
||
| /* | ||
| * 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 | ||
|
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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Data that uses allocated memory. Like
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 will be tightly coupled to the RuntimeData class/object with the current approach.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. manager.registerProvider(&powerLimiter);
manager.registerProvider(&battery);
manager.registerProvider(&solar);What you gain:
This pattern fits well with embedded/OpenDTU-style architectures where subsystems are long-lived singletons — registration in the constructor or an
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I understand your concerns. 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 I will need some time to implement the new concept. 🤔
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.