diff --git a/include/RuntimeData.h b/include/RuntimeData.h new file mode 100644 index 000000000..504a4296d --- /dev/null +++ b/include/RuntimeData.h @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include +#include +#include +#include + + +class RuntimeClass { +public: + explicit RuntimeClass(uint16_t version) : _writeVersion(version) {}; + ~RuntimeClass() = default; + RuntimeClass(const RuntimeClass&) = delete; + RuntimeClass& operator=(const RuntimeClass&) = delete; + RuntimeClass(RuntimeClass&&) = delete; + RuntimeClass& operator=(RuntimeClass&&) = delete; + + void init(Scheduler& scheduler); + bool read(void); + bool write(uint16_t const freezeMinutes = 10); // do not write if last write operation was less than freezeMinutes ago + void requestWriteOnNextTaskLoop(void) { _writeNow = true; }; // use this member function to store data on demand + + uint16_t getWriteCount(void) const; + time_t getWriteEpochTime(void) const; + bool getReadState(void) const { return _readOK; } + bool getWriteState(void) const { return _writeOK; } + String getWriteCountAndTimeString(void) const; + +private: + void loop(void); + bool getWriteTrigger(void); + + Task _loopTask; + std::atomic _readOK = false; // true if the last read operation was successful + std::atomic _writeOK = false; // true if the last write operation was successful + std::atomic _writeNow = false; // if true, the data is stored in the next 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 _writeVersion = 0; // shared data: version of the runtime data + 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; diff --git a/src/RuntimeData.cpp b/src/RuntimeData.cpp new file mode 100644 index 000000000..31c66fdf0 --- /dev/null +++ b/src/RuntimeData.cpp @@ -0,0 +1,236 @@ +// 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() to avoid deadlocks if you want to save locally data on demand. + * + * 2025.09.11 - 1.0 - first version + */ + +#include +#include +#include +#include "RuntimeData.h" + + +#undef TAG +static const char* TAG = "runtimedata"; +static const char* SUBTAG = "Handler"; + + +constexpr const char* RUNTIME_FILENAME = "/runtime.json"; // filename of the runtime data file +constexpr uint16_t RUNTIME_VERSION = 0; // version of the runtime data structure, prepared for future use + + +RuntimeClass RuntimeData {RUNTIME_VERSION}; // 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 || getWriteTrigger()) { + _writeNow = false; + write(0); // no freeze time. + } +} + + +/* + * 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) { + DTU_LOGI("%s", text); + } else { + DTU_LOGE("%s", text); + } + _writeOK = writeOk; + return writeOk; + }; + + // we need the next local time before we can write the runtime data + time_t nextEpoch; + if (!Utils::getEpoch(&nextEpoch, 5)) { return cleanExit(false, "Local time not available, skipping write"); } + uint16_t nextCount; + + { + std::lock_guard lock(_mutex); + + // check minimum interval between writes (enforced only when freezeMinutes > 0) + if ((_writeEpoch != 0) && (difftime(nextEpoch, _writeEpoch) < 60 * freezeMinutes)) { + return cleanExit(false, "Time interval between 2 write operations too short, skipping write"); + } + + // prepare the next write count + nextCount = _writeCount + 1; + + JsonDocument doc; + JsonObject info = doc["info"].to(); + info["version"] = RUNTIME_VERSION; + info["save_count"] = nextCount; + info["save_epoch"] = nextEpoch; + + // Insert additional runtime data here and protect the shared data with a local mutex + + + + 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(); + + // commit the new state only after a successful write + _writeVersion = 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 + */ +bool RuntimeClass::read(void) +{ + bool readOk = false; + JsonDocument doc; + + // Note: We do not exit on read or allocation errors. In that case we need the default values + 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 lock(_mutex); + _writeVersion = info["version"] | RUNTIME_VERSION; + _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 local mutex + + + if (fRuntime) { fRuntime.close(); } + if (readOk) { + DTU_LOGI("Read successfully"); + } else { + DTU_LOGE("Read fault, using default values"); + } + _readOK = readOk; + return readOk; +} + + +/* + * Get the write counter + */ +uint16_t RuntimeClass::getWriteCount(void) const +{ + std::lock_guard lock(_mutex); + return _writeCount; +} + + +/* + * Get the write epoch time + */ +time_t RuntimeClass::getWriteEpochTime(void) const +{ + std::lock_guard lock(_mutex); + return _writeEpoch; +} + + +/* + * Get the write count and time as string + * Format: " /
- :" + * If epoch time and local time is not available the time is replaced by "no time" + */ +String RuntimeClass::getWriteCountAndTimeString(void) const +{ + std::lock_guard lock(_mutex); + 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 boots. + if ((_writeEpoch != 0) && (getLocalTime(&time, 5))) { + localtime_r(&_writeEpoch, &time); + strftime(buf, sizeof(buf), " / %d-%h %R", &time); + } else { + snprintf(buf, sizeof(buf), " / no time"); + } + String ctString = String(_writeCount) + String(buf); + return ctString; +} + + +/* + * Returns true at the daily trigger time at 00:05 + */ +bool RuntimeClass::getWriteTrigger(void) { + + struct tm nowTime; + if (!getLocalTime(&nowTime, 5)) { + return false; + } + + std::lock_guard 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; +} diff --git a/src/WebApi_firmware.cpp b/src/WebApi_firmware.cpp index 91925ccad..5b873944f 100644 --- a/src/WebApi_firmware.cpp +++ b/src/WebApi_firmware.cpp @@ -10,6 +10,7 @@ #include #include #include "esp_partition.h" +#include "RuntimeData.h" void WebApiFirmwareClass::init(AsyncWebServer& server, Scheduler& scheduler) { @@ -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 60 min ago + RuntimeData.write(60); RestartHelper.triggerRestart(); } diff --git a/src/WebApi_maintenance.cpp b/src/WebApi_maintenance.cpp index 1835138f5..ce7ab89fd 100644 --- a/src/WebApi_maintenance.cpp +++ b/src/WebApi_maintenance.cpp @@ -8,6 +8,7 @@ #include "WebApi.h" #include "WebApi_errors.h" #include +#include "RuntimeData.h" void WebApiMaintenanceClass::init(AsyncWebServer& server, Scheduler& scheduler) { @@ -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 60 min ago + RuntimeData.write(60); RestartHelper.triggerRestart(); } else { retMsg["message"] = "Reboot cancled!"; diff --git a/src/WebApi_sysstatus.cpp b/src/WebApi_sysstatus.cpp index e48c5f02e..d72feb439 100644 --- a/src/WebApi_sysstatus.cpp +++ b/src/WebApi_sysstatus.cpp @@ -14,6 +14,7 @@ #include #include #include +#include "RuntimeData.h" void WebApiSysstatusClass::init(AsyncWebServer& server, Scheduler& scheduler) { @@ -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); diff --git a/src/main.cpp b/src/main.cpp index c4faf034f..36287d50c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -36,6 +36,7 @@ #include #include #include +#include "RuntimeData.h" #undef TAG static const char* TAG = "main"; @@ -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 + RuntimeData.init(scheduler); + RuntimeData.read(); ESP_LOGI(TAG, "Startup complete"); } diff --git a/webapp/src/components/FirmwareInfo.vue b/webapp/src/components/FirmwareInfo.vue index ffbe34e86..b3b075d50 100644 --- a/webapp/src/components/FirmwareInfo.vue +++ b/webapp/src/components/FirmwareInfo.vue @@ -83,6 +83,10 @@ {{ $t('firmwareinfo.ConfigSaveCount') }} {{ $n(systemStatus.cfgsavecount, 'decimal') }} + + {{ $t('firmwareinfo.RuntimeSaveCount') }} + {{ systemStatus.runtime_savecount }} + {{ $t('firmwareinfo.Uptime') }} diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index 1b5f70cf4..4aa05b052 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -282,6 +282,7 @@ "ResetReason0": "Reset Grund CPU 0", "ResetReason1": "Reset Grund CPU 1", "ConfigSaveCount": "Anzahl der Konfigurationsspeicherungen", + "RuntimeSaveCount": "Laufzeitdatenspeicherungen Anzahl / Zeit", "Uptime": "Betriebszeit", "UptimeValue": "0 Tage {time} | 1 Tag {time} | {count} Tage {time}" }, diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index 1ce2d9802..dda46a949 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -282,6 +282,7 @@ "ResetReason0": "Reset Reason CPU 0", "ResetReason1": "Reset Reason CPU 1", "ConfigSaveCount": "Config save count", + "RuntimeSaveCount": "Runtime data save count / time", "Uptime": "Uptime", "UptimeValue": "0 days {time} | 1 day {time} | {count} days {time}" }, diff --git a/webapp/src/types/SystemStatus.ts b/webapp/src/types/SystemStatus.ts index 52c90d4b8..30f199f50 100644 --- a/webapp/src/types/SystemStatus.ts +++ b/webapp/src/types/SystemStatus.ts @@ -31,6 +31,7 @@ export interface SystemStatus { resetreason_0: string; resetreason_1: string; cfgsavecount: number; + runtime_savecount: string; uptime: number; update_text: string; update_url: string;