Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
43 changes: 43 additions & 0 deletions include/RuntimeData.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once

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


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

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<bool> _readOK = false; // true if the last read operation was successful
std::atomic<bool> _writeOK = false; // true if the last write operation was successful
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;
226 changes: 226 additions & 0 deletions src/RuntimeData.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
// 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.
* - Threadsave access to the data is provided by a mutex.
*
* Note: 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 local data on demand!
*
* 2025.09.11 - 1.0 - first version
*/

#include <Utils.h>
#include <LittleFS.h>
#include <LogHelper.h>
#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 whether it is time to write the runtime data but do not write if last write operation was less than 60 min ago
if (getWriteTrigger()) { write(60); }
}


/*
* 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;

{ // prepare the next write count
std::lock_guard<std::mutex> lock(_mutex);

// we do not write more than once in a hour
if ((_writeEpoch != 0) && (difftime(nextEpoch, _writeEpoch) < 60 * freezeMinutes)) {
return cleanExit(false, "Time interval between 2 write operations too short, skipping write");
}
nextCount = _writeCount + 1;
} // mutex is automatically released when lock goes out of this scope

JsonDocument doc;
JsonObject info = doc["info"].to<JsonObject>();
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
std::lock_guard<std::mutex> lock(_mutex);
_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<std::mutex> 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<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
{
std::lock_guard<std::mutex> 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) {
std::lock_guard<std::mutex> lock(_mutex);
struct tm actTime;
if (getLocalTime(&actTime, 5)) {
if ((actTime.tm_hour == 0) && (actTime.tm_min >= 5) && (actTime.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 60 min ago
RuntimeData.write(60);
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 60 min ago
RuntimeData.write(60);
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
RuntimeData.init(scheduler);
RuntimeData.read();

ESP_LOGI(TAG, "Startup complete");
}
Expand Down
4 changes: 4 additions & 0 deletions webapp/src/components/FirmwareInfo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@
<th>{{ $t('firmwareinfo.ConfigSaveCount') }}</th>
<td>{{ $n(systemStatus.cfgsavecount, 'decimal') }}</td>
</tr>
<tr>
<th>{{ $t('firmwareinfo.RuntimeSaveCount') }}</th>
<td>{{ systemStatus.runtime_savecount }}</td>
</tr>
<tr>
<th>{{ $t('firmwareinfo.Uptime') }}</th>
<td>
Expand Down
1 change: 1 addition & 0 deletions webapp/src/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
},
Expand Down
1 change: 1 addition & 0 deletions webapp/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
},
Expand Down
1 change: 1 addition & 0 deletions webapp/src/types/SystemStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down