Skip to content

Save Runtime data in LittleFS#2262

Open
SW-Niko wants to merge 3 commits intohoylabs:developmentfrom
SW-Niko:RuntimeData
Open

Save Runtime data in LittleFS#2262
SW-Niko wants to merge 3 commits intohoylabs:developmentfrom
SW-Niko:RuntimeData

Conversation

@SW-Niko
Copy link
Copy Markdown

@SW-Niko SW-Niko commented Oct 1, 2025

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 10 Minutes ago.
  • Thread save access to the data is provided by a mutex/lock.
  • Indicate the save count and the last save time in the web UI
grafik

I should also mention the following points:

  • The mutex only protects the RuntimeData class's own data
  • I simply attached it to the OTA update callback and reset callback function to save the runtime data

See issue #1676

To quickly test the runtime functionality on your system, follow these steps:

  • Update your system with the new firmware.
  • Go to "Info" -> "System" and check "Runtime data save count / time 0 / no time"
  • Force the system to save the runtime data on a JSON-File by doing a reboot via the web interface.
  • Again go to "Info" -> "System" and check "Runtime data save count / time 1 / 15-Oct 19:33"
  • You can also download the JSON-File and review its content.

{
    "info": {
        "version": 0,
        "save_count": 1,
        "save_epoch": 1760549624
    }
}

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Oct 1, 2025

Build Artifacts

Firmware built from this pull request's code:

Notice

  • These artifacts are ZIP files containing the factory update binary as well as the OTA update binary.
    Extract the binaries from the ZIP files first. Do not use the ZIP files themselves to perform an update.
  • These links point to artifacts of the latest successful build run.
  • The linked artifacts were built from 7567c92.

@SW-Niko
Copy link
Copy Markdown
Author

SW-Niko commented Oct 8, 2025

Runtime data also written to LittleFS if WebUI 'Device Reboot' is used.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Oct 11, 2025

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a thread-safe RuntimeClass singleton (RuntimeData) that persists runtime metadata to LittleFS as JSON with minute scheduling, read/write APIs, and accessors; integrates writes into OTA and reboot flows; exposes runtime_savecount in system status and updates web UI, locales, and types.

Changes

Cohort / File(s) Summary
Runtime data module
include/RuntimeData.h, src/RuntimeData.cpp
New RuntimeClass and global RuntimeData: constructor, deleted copy/move, init(Scheduler&), read(), write(uint16_t), requestWriteOnNextTaskLoop(), minute loop() with daily trigger/debounce, mutex/atomics for state, JSON persistence to /runtime.json, and accessors (getWriteCount, getWriteEpochTime, getWriteCountAndTimeString, getReadState, getWriteState).
Web API integrations
src/WebApi_firmware.cpp, src/WebApi_maintenance.cpp, src/WebApi_sysstatus.cpp
Include RuntimeData.h; call RuntimeData.write(60) after OTA finish and after confirmed reboot (throttled); add runtime_savecount to /api/system/status JSON using getWriteCountAndTimeString().
App initialization
src/main.cpp
Include RuntimeData.h; call RuntimeData.init(scheduler) and RuntimeData.read() during setup after component initializations.
Webapp UI
webapp/src/components/FirmwareInfo.vue
Add a table row to display systemStatus.runtime_savecount.
Locales
webapp/src/locales/en.json, webapp/src/locales/de.json
Add firmwareinfo.RuntimeSaveCount translation entries (English and German).
Type definitions
webapp/src/types/SystemStatus.ts
Add runtime_savecount: string to the SystemStatus interface.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant Main as Main (setup)
  participant Scheduler as Scheduler
  participant RD as RuntimeData
  participant FS as LittleFS
  participant API as Web API
  participant UI as Webapp UI

  Main->>RD: init(scheduler)
  RD->>Scheduler: register periodic loop (1 min)
  Main->>RD: read()
  RD->>FS: read `/runtime.json`
  FS-->>RD: data / error
  RD-->>Main: read() result

  Note over Scheduler,RD: Scheduled loop checks daily trigger window (00:05–00:10) and explicit _writeNow flag

  User->>API: complete firmware upload
  API->>RD: write(60)
  RD->>FS: write `/runtime.json`
  FS-->>RD: result
  RD-->>API: write() result

  User->>API: reboot command
  API->>RD: write(60)
  RD->>FS: write `/runtime.json`

  UI->>API: GET /api/system/status
  API->>RD: getWriteCountAndTimeString()
  RD-->>API: "count / time" or "no time"
  API-->>UI: JSON includes `runtime_savecount`
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

I hop through bytes and tuck them tight,
LittleFS burrow saves the night.
Scheduler hums, I scribble score,
Count and time — I'll keep much more. 🐇

🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The PR description clearly describes the changeset: persistent runtime data storage in LittleFS with JSON format, write triggers (OTA, reboot, daily), throttling logic, thread safety, and UI integration.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
include/RuntimeData.h (1)

33-40: Document atomic flags individually

Both _readOK and _writeOK share the same comment (“true if the last read/write operation was successful”), which makes it unclear which flag refers to which operation. Please update the comments (or rename the members) so each atomic’s responsibility is explicit.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between af39f5a and a33bcd6.

📒 Files selected for processing (10)
  • include/RuntimeData.h (1 hunks)
  • src/RuntimeData.cpp (1 hunks)
  • src/WebApi_firmware.cpp (2 hunks)
  • src/WebApi_maintenance.cpp (2 hunks)
  • src/WebApi_sysstatus.cpp (2 hunks)
  • src/main.cpp (2 hunks)
  • webapp/src/components/FirmwareInfo.vue (1 hunks)
  • webapp/src/locales/de.json (1 hunks)
  • webapp/src/locales/en.json (1 hunks)
  • webapp/src/types/SystemStatus.ts (1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
{src,include,lib/Hoymiles,lib/MqttSubscribeParser,lib/TimeoutHelper,lib/ResetReason}/**/*.{c,cc,cpp,cxx,h,hpp,hxx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

C/C++ code must pass cpplint with the specified filters

Files:

  • src/WebApi_maintenance.cpp
  • src/WebApi_firmware.cpp
  • include/RuntimeData.h
  • src/RuntimeData.cpp
  • src/WebApi_sysstatus.cpp
  • src/main.cpp
webapp/src/**/*.{js,jsx,ts,tsx,vue}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

webapp/src/**/*.{js,jsx,ts,tsx,vue}: Webapp source must pass ESLint (yarn lint)
Webapp source must be Prettier-formatted (yarn prettier --check src/)

Files:

  • webapp/src/components/FirmwareInfo.vue
  • webapp/src/types/SystemStatus.ts
🧬 Code graph analysis (1)
include/RuntimeData.h (1)
src/RuntimeData.cpp (16)
  • init (37-44)
  • init (37-37)
  • read (116-150)
  • read (116-116)
  • write (60-110)
  • write (60-60)
  • getWriteCount (156-160)
  • getWriteCount (156-156)
  • getWriteEpochTime (166-170)
  • getWriteEpochTime (166-166)
  • getWriteCountAndTimeString (178-191)
  • getWriteCountAndTimeString (178-178)
  • loop (50-54)
  • loop (50-50)
  • getWriteTrigger (197-211)
  • getWriteTrigger (197-197)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: Build Environments (generic_esp32s3_usb)
  • GitHub Check: Build Environments (generic_esp32s3)
  • GitHub Check: Build Environments (generic_esp32_4mb_no_ota)
  • GitHub Check: Build Environments (generic_esp32_8mb)
🔇 Additional comments (7)
webapp/src/types/SystemStatus.ts (1)

34-34: LGTM!

The new runtime_savecount: string field is correctly positioned and follows the existing naming conventions. The string type is appropriate for the combined "count / time" format exposed by the backend.

src/WebApi_sysstatus.cpp (1)

17-17: LGTM!

The addition of RuntimeData.h include and the use of RuntimeData.getWriteCountAndTimeString() to populate the runtime_savecount field is straightforward and correct. This is a read-only operation with no side effects, appropriate for a status endpoint.

Also applies to: 81-81

webapp/src/locales/en.json (1)

285-285: LGTM!

The localization entry for RuntimeSaveCount is clear, descriptive, and follows the existing pattern. The label "Runtime data save count / time" effectively communicates the combined information displayed.

webapp/src/components/FirmwareInfo.vue (1)

86-89: LGTM with a minor observation.

The new table row for RuntimeSaveCount is correctly implemented and follows the existing pattern. The positioning between ConfigSaveCount and Uptime is logical.

One minor observation: Unlike cfgsavecount (line 84) which uses $n(systemStatus.cfgsavecount, 'decimal') for number formatting, runtime_savecount is displayed directly. This appears intentional since runtime_savecount is a string containing both count and time information (as indicated by the localization "Runtime data save count / time"). No action needed unless the backend format changes.

webapp/src/locales/de.json (1)

285-285: LGTM!

The German localization entry for RuntimeSaveCount is properly translated and follows the existing pattern. The label "Laufzeitdatenspeicherungen Anzahl / Zeit" accurately mirrors the English version.

src/main.cpp (1)

157-159: Verify non-blocking read and clarify initialization order.

  • Confirm that RuntimeData.read() completes quickly or handles missing/corrupt files without blocking startup.
  • Add error handling or at least log the result of read() for visibility.
  • Clarify the comment to explain why RuntimeData must be initialized last (e.g., it references other components or requires the scheduler to be fully configured).
src/WebApi_maintenance.cpp (1)

47-47: Handle write() return and assess blocking behavior

  • RuntimeData.write() returns a bool but isn’t checked here; wrap it in an if and log or act on failures.
  • LittleFS writes are synchronous, so verify that this call completes within acceptable time (<100 ms) or move it off the reboot path (e.g. perform the write before sending the response or offload it).

Comment thread src/RuntimeData.cpp Outdated
Comment thread src/WebApi_firmware.cpp Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/RuntimeData.cpp (1)

197-207: Guard _lastTrigger with the class mutex

getWriteTrigger() flips _lastTrigger without taking _mutex, so concurrent callers race the write, violating the class’s thread-safety guarantee. Please take _mutex before touching _lastTrigger so all state transitions stay atomic.

 bool RuntimeClass::getWriteTrigger(void) {
     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;
-        }
+        std::lock_guard<std::mutex> lock(_mutex);
+        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;
 }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a33bcd6 and f9449cb.

📒 Files selected for processing (2)
  • include/RuntimeData.h (1 hunks)
  • src/RuntimeData.cpp (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • include/RuntimeData.h
🧰 Additional context used
📓 Path-based instructions (1)
{src,include,lib/Hoymiles,lib/MqttSubscribeParser,lib/TimeoutHelper,lib/ResetReason}/**/*.{c,cc,cpp,cxx,h,hpp,hxx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

C/C++ code must pass cpplint with the specified filters

Files:

  • src/RuntimeData.cpp
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: Build Environments (generic_esp32s3_usb)
  • GitHub Check: Build Environments (generic_esp32_8mb)
  • GitHub Check: Build Environments (generic_esp32s3)
  • GitHub Check: Build Environments (generic_esp32_4mb_no_ota)

Comment thread src/RuntimeData.cpp Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/RuntimeData.cpp (1)

206-219: Race condition: _lastTrigger accessed without mutex protection.

The _lastTrigger member variable is read and modified (lines 210-211, 215) without mutex protection, creating a race condition. Since getWriteTrigger() is called from the scheduled loop task and potentially from other contexts, concurrent access can corrupt the trigger state, leading to missed or duplicate writes.

Apply this diff to add mutex protection:

 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;
 }
🧹 Nitpick comments (1)
src/RuntimeData.cpp (1)

192-193: Consider clarifying the time availability check.

Line 192 calls getLocalTime(&time, 5) but immediately overwrites time with localtime_r(&_writeEpoch, &time) on line 193. The getLocalTime call is only used for its boolean return to verify the time service is available, which makes the code somewhat confusing.

Consider extracting a helper method to check time service availability, or add a comment explaining that getLocalTime is used only as an availability check:

+    // Check if time service is available before converting epoch to local time
     if ((_writeEpoch != 0) && (getLocalTime(&time, 5))) {
         localtime_r(&_writeEpoch, &time);
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f9449cb and 960662f.

📒 Files selected for processing (1)
  • src/RuntimeData.cpp (1 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
{src,include,lib/Hoymiles,lib/MqttSubscribeParser,lib/TimeoutHelper,lib/ResetReason}/**/*.{c,cc,cpp,cxx,h,hpp,hxx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

C/C++ code must pass cpplint with the specified filters

Files:

  • src/RuntimeData.cpp
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: Build Environments (generic_esp32s3_usb)
  • GitHub Check: Build Environments (generic_esp32s3)
  • GitHub Check: Build Environments (generic_esp32_4mb_no_ota)
  • GitHub Check: Build Environments (generic_esp32_8mb)
🔇 Additional comments (1)
src/RuntimeData.cpp (1)

77-116: LGTM! Write state updates deferred until success.

The implementation correctly addresses the previous review feedback. The write counter and epoch are now only committed after a successful file write (lines 111-116), preventing the rate limiter from being incorrectly updated when writes fail.

Comment thread src/RuntimeData.cpp Outdated
@schlimmchen
Copy link
Copy Markdown
Member

The data is written once a day at 00:05, during WebApp 'OTA firmware update' and 'Reset'
The data is not written if last write was less then 1 hour ago.

These contradict. The first is reasonable. The second should be something like "runtime data is also saved periodically, every hour".

Is this still a shell? I don't see what data is actually saved.

@spcqike
Copy link
Copy Markdown

spcqike commented Oct 13, 2025

runtime data is also saved periodically, every hour

this depends on the actual data, doesn't it? i don't see why data like "battery last fully charged" or "calibration factors" should be written on an hourly basis. as they will change rarely.

see #1676 and #1466

IMO, for such slow changing data, a single write a day and planned writes on reboot or update should be enough.

and also there i think it would be smart to only write, when data was update/changed. i don't know if the filesystem handles this on its own to reduce unnecessary writecycles.

@SW-Niko
Copy link
Copy Markdown
Author

SW-Niko commented Oct 13, 2025

Hello @schlimmchen and @spcqike
let me answer your questions. 😀

Is this still a shell?

Yes, but I'm already testing the first function (saving the last SoC calibration time, 100% SoC).

The second should be something like "runtime data is also saved periodically, every hour".

I share @spcqike opinion. It depends on the information we want to save. And how critical it would be if we lose it.
Actual I just have slow changing data in mind and a single safety write a day should be enough.

But, we also can do it more frequently or ... and that is the option I would prefer .. On demand.
For example: if we want to save the "battery charge circle" state we can call RuntimeData.write() on every change.
(Requires a small code modification to disable the 1 hour freeze)

Would that be okay with you? 🤔

@spcqike
Copy link
Copy Markdown

spcqike commented Oct 13, 2025

and that is the option I would prefer .. On demand.
For example: if we want to save the "battery charge circle" state we can call RuntimeData.write() on every change.

shouldn't the RuntimeClass::loop() handle this? Check, whether or not data was changed and save it accordingly? the loop() may run every minute or faster, but only write when a change is detected and when the last write was > 1h ago, or so.

@SW-Niko
Copy link
Copy Markdown
Author

SW-Niko commented Oct 13, 2025

shouldn't the RuntimeClass::loop() handle this?

No, I think that is the wrong place.

Check, whether or not data was changed and save it accordingly?

This might be easy to decide with Boolean data. But when do we store changing values ​​like the battery's internal resistance?
12.05 mOhm. Every time the last digit changes? I would leave that decision to the data owner function.

However, so far we don't have any important data that needs to be saved to the runtime file so often.

Normally during WebApp 'OTA firmware update' and 'Reset' is enough.
An just for cases like 'supply power drop' or 'serial firmware update' or 'reset by firmware bug' or I added 1 safety write a day.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (2)
src/RuntimeData.cpp (2)

197-198: Minor optimization: redundant getLocalTime call.

Line 197 calls getLocalTime(&time, 5) solely to check if the time service is available, but the resulting time struct is immediately overwritten by localtime_r(&_writeEpoch, &time) on line 198. While not incorrect, this is slightly wasteful.

Consider checking time service availability without populating a struct that will be discarded:

-    if ((_writeEpoch != 0) && (getLocalTime(&time, 5))) {
+    struct tm tempCheck;
+    if ((_writeEpoch != 0) && (getLocalTime(&tempCheck, 5))) {
         localtime_r(&_writeEpoch, &time);

Or simply rely on _writeEpoch != 0 if time service availability is already implied by a non-zero epoch.


212-225: Consider protecting _lastTrigger with mutex for design robustness.

The comment on line 210 acknowledges that _lastTrigger is accessed without mutex protection, relying on the assumption that getWriteTrigger() is only called from the single-threaded loop(). While this may be safe in the current implementation, it creates a fragile design:

  1. Member variables with underscore prefix conventionally require synchronization in multi-threaded contexts.
  2. If future code changes call getWriteTrigger() from another thread (e.g., for debugging or manual trigger), a data race will occur.
  3. The edge-detection logic (lines 216-221) reads and writes _lastTrigger without atomic guarantees.

Consider protecting _lastTrigger with _mutex to make the design more robust:

 bool RuntimeClass::getWriteTrigger(void) {
     struct tm actTime;
     if (getLocalTime(&actTime, 5)) {
         if ((actTime.tm_hour == 0) && (actTime.tm_min >= 5) && (actTime.tm_min <= 10)) {
+            std::lock_guard<std::mutex> lock(_mutex);
             if (_lastTrigger == false) {
                 _lastTrigger = true;
                 return true;
             }
         } else {
+            std::lock_guard<std::mutex> lock(_mutex);
             _lastTrigger = false;
         }
     }
     return false;
 }

This adds minimal overhead (mutex is uncontended in single-threaded use) while preventing potential future data races.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2c15277 and 5a7eaec.

📒 Files selected for processing (2)
  • include/RuntimeData.h (1 hunks)
  • src/RuntimeData.cpp (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • include/RuntimeData.h
🧰 Additional context used
📓 Path-based instructions (1)
{src,include,lib/Hoymiles,lib/MqttSubscribeParser,lib/TimeoutHelper,lib/ResetReason}/**/*.{c,cc,cpp,cxx,h,hpp,hxx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

C/C++ code must pass cpplint with the specified filters

Files:

  • src/RuntimeData.cpp

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (2)
src/RuntimeData.cpp (2)

63-122: Excellent write implementation with proper state management.

The write method correctly addresses the previous review concern by staging the next state values and only committing them after successful file I/O. Rate limiting, mutex usage, and error handling are all properly implemented.

Consider making the rate-limit error message more specific:

-            return cleanExit(false, "Time interval between 2 write operations too short, skipping write");
+            return cleanExit(false, "Time interval between 2 write operations too short (requires %u minutes), skipping write", freezeMinutes);

190-206: Consider clarifying the time-checking logic.

The intent of checking getLocalTime() before converting _writeEpoch is to ensure the time system is initialized. However, the code pattern of populating time and then immediately overwriting it is confusing.

Consider this clearer implementation:

 String RuntimeClass::getWriteCountAndTimeString(void) const
 {
     std::lock_guard<std::mutex> lock(_mutex);
     char buf[32] = "";
     struct tm time;
+    struct tm timeCheck;
 
-    if ((_writeEpoch != 0) && (getLocalTime(&time, 5))) {
+    if ((_writeEpoch != 0) && (getLocalTime(&timeCheck, 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;
 }

This makes it explicit that getLocalTime() is only used to verify time system availability, while time is independently populated from _writeEpoch.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5a7eaec and 8772c31.

📒 Files selected for processing (1)
  • src/RuntimeData.cpp (1 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
{src,include,lib/Hoymiles,lib/MqttSubscribeParser,lib/TimeoutHelper,lib/ResetReason}/**/*.{c,cc,cpp,cxx,h,hpp,hxx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

C/C++ code must pass cpplint with the specified filters

Files:

  • src/RuntimeData.cpp
🧬 Code graph analysis (1)
src/RuntimeData.cpp (2)
src/WebApi_maintenance.cpp (2)
  • init (13-18)
  • init (13-13)
src/WebApi_firmware.cpp (2)
  • init (15-29)
  • init (15-15)
🔇 Additional comments (5)
src/RuntimeData.cpp (5)

1-33: LGTM! Clear documentation and proper setup.

The file header provides comprehensive documentation of the RuntimeData functionality. The TAG constant is appropriately set to "runtimedata", constants are well-defined, and the singleton pattern is correctly implemented.


39-46: LGTM! Proper scheduler task initialization.

The init method correctly sets up the periodic loop task with appropriate interval and callback binding.


52-56: LGTM! Clean loop implementation.

The loop method correctly checks the daily trigger condition and invokes write with a 60-minute debounce interval.


128-162: LGTM! Robust read implementation with proper error handling.

The read method gracefully handles missing files and deserialization errors, applies appropriate defaults, and correctly uses mutex protection for state updates.


212-226: LGTM! Trigger logic correctly uses instance state.

The getWriteTrigger method properly uses _lastTrigger as a class member (addressing the previous review concern about static state) and correctly implements debounce logic for the daily trigger window with appropriate mutex protection.

@SW-Niko
Copy link
Copy Markdown
Author

SW-Niko commented Oct 15, 2025

Hallo @AndreasBoehm ,
vielleicht kannst du mir einen Tipp geben wie ich folgendes Problem lösen kann.
Ich habe das nächste Feature fertig und das setzt den Runtime Branch auf.
Aber ich kann als Basis Branch 'Runtime' nicht auswählen und wenn ich "development' nehme dann wird 'Runtime' mit eingebunden.
Mach ich was falsch? Oder muss 'Runtime erst in 'development' ge-merged werden? 🤔

grafik

@AndreasBoehm
Copy link
Copy Markdown
Member

@SW-Niko Das geht leider nicht, da deine RuntimeData branch nicht im hoylabs repo sondern in deinem Fork liegt.

Du kannst development als base für deinen PR nehmen und erwähnen das er auf diesem hier basiert. Anders können wir das mit einem Fork leider nicht lösen.

@SW-Niko
Copy link
Copy Markdown
Author

SW-Niko commented Oct 16, 2025

@AndreasBoehm Danke für die Info. Das erklärt auch warum der Battery Gard in der Liste angeboten wird. 👍

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8772c31 and 8982927.

📒 Files selected for processing (10)
  • include/RuntimeData.h (1 hunks)
  • src/RuntimeData.cpp (1 hunks)
  • src/WebApi_firmware.cpp (2 hunks)
  • src/WebApi_maintenance.cpp (2 hunks)
  • src/WebApi_sysstatus.cpp (2 hunks)
  • src/main.cpp (2 hunks)
  • webapp/src/components/FirmwareInfo.vue (1 hunks)
  • webapp/src/locales/de.json (1 hunks)
  • webapp/src/locales/en.json (1 hunks)
  • webapp/src/types/SystemStatus.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (4)
  • webapp/src/components/FirmwareInfo.vue
  • src/WebApi_sysstatus.cpp
  • webapp/src/locales/en.json
  • webapp/src/types/SystemStatus.ts
🧰 Additional context used
📓 Path-based instructions (1)
{src,include,lib/Hoymiles,lib/MqttSubscribeParser,lib/TimeoutHelper,lib/ResetReason}/**/*.{c,cc,cpp,cxx,h,hpp,hxx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

C/C++ code must pass cpplint with the specified filters

Files:

  • src/WebApi_maintenance.cpp
  • src/WebApi_firmware.cpp
  • include/RuntimeData.h
  • src/RuntimeData.cpp
  • src/main.cpp
🧬 Code graph analysis (2)
include/RuntimeData.h (3)
src/RuntimeData.cpp (16)
  • init (39-46)
  • init (39-39)
  • read (128-162)
  • read (128-128)
  • write (63-122)
  • write (63-63)
  • getWriteCount (168-172)
  • getWriteCount (168-168)
  • getWriteEpochTime (178-182)
  • getWriteEpochTime (178-178)
  • getWriteCountAndTimeString (190-206)
  • getWriteCountAndTimeString (190-190)
  • loop (52-56)
  • loop (52-52)
  • getWriteTrigger (212-226)
  • getWriteTrigger (212-212)
src/WebApi_firmware.cpp (2)
  • init (15-29)
  • init (15-15)
src/WebApi_maintenance.cpp (2)
  • init (13-18)
  • init (13-13)
src/RuntimeData.cpp (2)
src/WebApi_firmware.cpp (2)
  • init (15-29)
  • init (15-15)
src/WebApi_maintenance.cpp (2)
  • init (13-18)
  • init (13-13)
🔇 Additional comments (17)
webapp/src/locales/de.json (1)

285-285: LGTM!

The German translation for RuntimeSaveCount is accurate and follows the existing localization pattern in the file.

src/WebApi_firmware.cpp (2)

13-13: LGTM!

The include is properly added for RuntimeData usage.


51-53: LGTM!

The RuntimeData.write(60) call is correctly placed after the HTTP response is sent but before the restart is triggered. This ensures runtime data is persisted during OTA updates without blocking the upload handler. The return value can be safely ignored here since the device will restart immediately.

src/main.cpp (1)

39-39: LGTM!

The include is properly added for RuntimeData usage.

src/WebApi_maintenance.cpp (2)

11-11: LGTM!

The include is properly added for RuntimeData usage.


47-49: LGTM!

The RuntimeData.write(60) call follows the same pattern as in src/WebApi_firmware.cpp, correctly placed after the HTTP response is sent but before the restart is triggered. This ensures runtime data is persisted during device reboots without blocking the request handler.

src/RuntimeData.cpp (7)

1-31: LGTM!

The file header provides clear documentation of the module's purpose, behavior, and constraints. The constants and TAG are well-defined.


39-46: LGTM!

The init() method correctly sets up the scheduler task with appropriate configuration:

  • Runs every minute
  • Properly binds the loop callback
  • Infinite iterations (TASK_FOREVER)

52-56: LGTM!

The loop() method implements the daily trigger mechanism cleanly by checking for the trigger condition and invoking write with the appropriate throttle parameter.


63-122: LGTM!

The write() method is well-implemented with proper error handling and thread safety:

  • Correctly validates local time availability before writing
  • Implements throttling to prevent excessive writes
  • Uses staging variables (nextCount, nextEpoch) and only commits them to class state after successful write
  • Proper mutex protection for shared state
  • Clear error messages via cleanExit lambda
  • The past review concern about deferring updates until write succeeds has been properly addressed (lines 114-119)

128-162: LGTM!

The read() method is well-implemented with proper defaults and thread safety:

  • Gracefully handles file not found or deserialization errors by using default values
  • Proper mutex protection when updating shared state
  • Uses Utils::skipBom() for BOM handling
  • Returns success status while always ensuring valid defaults
  • Clear logging of outcomes

168-206: LGTM!

The accessor methods (getWriteCount(), getWriteEpochTime(), getWriteCountAndTimeString()) are properly implemented with:

  • Consistent mutex protection for thread-safe access
  • getWriteCountAndTimeString() correctly handles cases where time is not yet available
  • Appropriate time formatting for UI display

212-226: LGTM!

The getWriteTrigger() method correctly implements the daily trigger window (00:05-00:10) with proper edge detection using _lastTrigger to prevent multiple triggers on the same day. The past review concern about moving this from a static local to a class member has been properly addressed.

include/RuntimeData.h (4)

1-8: LGTM!

The includes are appropriate for the class design:

  • ArduinoJson for serialization
  • TaskSchedulerDeclarations for task management
  • mutex and atomic for thread safety

10-27: LGTM!

The public interface is well-designed:

  • Explicit constructor prevents implicit conversions
  • Copy and move operations properly deleted for singleton pattern
  • init() for scheduler setup
  • read() and write() for persistence with throttling support
  • Clean accessor methods for querying state
  • getWriteState() and getReadState() provide operation status feedback
  • getWriteCountAndTimeString() provides formatted data for UI display

29-41: LGTM!

The private implementation details are well-structured:

  • Task for periodic execution
  • Atomics for operation status (thread-safe without mutex overhead for simple flags)
  • Mutable mutex for protecting shared data in const methods
  • State tracking for trigger detection and versioning
  • Clear separation between public API and internal implementation

43-43: LGTM!

The extern declaration properly exposes the global singleton instance that is defined in src/RuntimeData.cpp.

Comment thread src/main.cpp
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
src/RuntimeData.cpp (1)

69-129: LGTM! Write method correctly implements transactional semantics.

The implementation properly addresses the past critical issue: values are staged locally (line 95), I/O is performed, and state is committed only after a successful write (lines 122-124). The throttle logic correctly allows bypassing when freezeMinutes=0. The cleanExit lambda provides consistent logging and state management.

Minor clarity suggestion: The comment on line 103 says "protect the shared data with a local mutex" but the mutex is already held at this point. Consider rewording to "ensure any additional shared data added here remains under the existing mutex protection."

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8982927 and 97ffc77.

📒 Files selected for processing (2)
  • include/RuntimeData.h (1 hunks)
  • src/RuntimeData.cpp (1 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
{src,include,lib/Hoymiles,lib/MqttSubscribeParser,lib/TimeoutHelper,lib/ResetReason}/**/*.{c,cc,cpp,cxx,h,hpp,hxx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

C/C++ code must pass cpplint with the specified filters

Files:

  • src/RuntimeData.cpp
  • include/RuntimeData.h
🧠 Learnings (1)
📚 Learning: 2025-10-17T08:29:40.184Z
Learnt from: SW-Niko
PR: hoylabs/OpenDTU-OnBattery#2262
File: src/main.cpp:149-159
Timestamp: 2025-10-17T08:29:40.184Z
Learning: In the OpenDTU-OnBattery project (src/main.cpp), RuntimeData must be initialized after SolarCharger, PowerMeter, PowerLimiter, GridCharger, and Battery components have been initialized, but before the main loop. RuntimeData acts as a persistence service that these components access during their runtime operations (not during initialization).

Applied to files:

  • src/RuntimeData.cpp
🧬 Code graph analysis (2)
src/RuntimeData.cpp (2)
src/WebApi_firmware.cpp (2)
  • init (15-29)
  • init (15-15)
src/WebApi_maintenance.cpp (2)
  • init (13-18)
  • init (13-13)
include/RuntimeData.h (1)
src/RuntimeData.cpp (16)
  • init (41-48)
  • init (41-41)
  • read (135-169)
  • read (135-135)
  • write (69-129)
  • write (69-69)
  • getWriteCount (175-179)
  • getWriteCount (175-175)
  • getWriteEpochTime (185-189)
  • getWriteEpochTime (185-185)
  • getWriteCountAndTimeString (197-213)
  • getWriteCountAndTimeString (197-197)
  • loop (54-62)
  • loop (54-54)
  • getWriteTrigger (219-236)
  • getWriteTrigger (219-219)
🪛 Clang (14.0.6)
src/RuntimeData.cpp

[error] 20-20: 'Utils.h' file not found

(clang-diagnostic-error)

include/RuntimeData.h

[error] 4-4: 'ArduinoJson.h' file not found

(clang-diagnostic-error)

🔇 Additional comments (8)
include/RuntimeData.h (2)

10-43: LGTM! Thread-safe singleton design is solid.

The class correctly implements a non-copyable/non-movable singleton pattern. The synchronization strategy properly separates atomic flags (_readOK, _writeOK, _writeNow) from mutex-protected shared state (_writeVersion, _writeCount, _writeEpoch, _lastTrigger). The mutable mutex appropriately enables const methods to acquire locks.


19-28: LGTM! Public interface is clean and well-designed.

The API provides clear methods for initialization, persistence operations, and state queries. The default 10-minute throttle in write() and the inline requestWriteOnNextTaskLoop() setter are sensible design choices. Const correctness is properly maintained throughout.

src/RuntimeData.cpp (6)

1-35: LGTM! Clean module setup with clear documentation.

The file header provides excellent guidance for future maintainers, including deadlock avoidance advice. The TAG is now correctly set to "runtimedata" (addressing past feedback), and the constants and singleton initialization are properly defined.


41-62: LGTM! Task initialization and loop logic are correct.

The minute-based task is properly configured, and the loop logic correctly triggers writes either on explicit request (_writeNow) or during the daily maintenance window. Calling write(0) bypasses the throttle for these scheduled events, which is the intended behavior.


135-169: LGTM! Read method properly handles missing or corrupt data.

The implementation gracefully falls back to defaults when the file is missing or JSON deserialization fails. Mutex protection correctly guards shared state updates (lines 152-156), and the default operator | is the idiomatic way to provide fallback values with ArduinoJson.

(Same optional comment clarity note as in the write method applies to line 158.)


175-189: LGTM! Accessors properly protect shared state.

Both getters correctly acquire the mutex before returning the protected values.


197-213: LGTM! Time formatting handles unavailable time server gracefully.

The method correctly verifies time server availability (line 205) before converting the epoch to local time (line 206). The fallback to "no time" is appropriate for early boot scenarios.


219-236: LGTM! Daily trigger window is correctly implemented.

The method properly detects the 00:05–00:10 window and uses _lastTrigger for edge detection to prevent multiple fires. This addresses the past review concern about the static flag—_lastTrigger is now a proper instance member protected by the mutex.

@SW-Niko
Copy link
Copy Markdown
Author

SW-Niko commented Oct 20, 2025

Hello @schlimmchen or @AndreasBoehm ,
Do you see any chance of reviewing this PR in the near future?
I've expanded the Battery Guard with:

  • Write the calculated DC-Pulse value into the runtime data
  • Stop-Voltage Power Limiter
  • Recharge Helper

But I need this PR and #2298 first to avoid triple nested PRs. 😀

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
include/RuntimeData.h (1)

1-7: Consider forward declarations or moving ArduinoJson include to implementation.

ArduinoJson.h is included in the header but JsonDocument is only used internally in the .cpp file. This increases compilation coupling unnecessarily. Consider moving the include to RuntimeData.cpp since the public API doesn't expose any ArduinoJson types.

♻️ Suggested change
 // SPDX-License-Identifier: GPL-2.0-or-later
 `#pragma` once

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

The ArduinoJson.h include is already present in RuntimeData.cpp.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@include/RuntimeData.h` around lines 1 - 7, RuntimeData.h currently includes
ArduinoJson.h even though ArduinoJson types (e.g., JsonDocument) are only used
in the implementation; remove the ArduinoJson.h include from RuntimeData.h to
reduce compile coupling, ensure any references in the header use only standard
types or forward declarations (no ArduinoJson types are exposed), and keep
ArduinoJson.h included in RuntimeData.cpp where JsonDocument or other
ArduinoJson symbols are actually used (verify functions/methods in class
RuntimeData that manipulate JSON are implemented in RuntimeData.cpp and include
ArduinoJson.h there).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/RuntimeData.cpp`:
- Around line 81-92: Update the misleading comment and stop treating expected
throttling as an error: change the comment near Utils::getEpoch(&nextEpoch, 5)
to say it returns UTC epoch (not local time), and modify the throttling return
path that checks (_writeEpoch != 0) && (difftime(nextEpoch, _writeEpoch) < 60 *
freezeMinutes) so it does not call cleanExit(false, ...) which logs an error;
either return success (cleanExit(true, "...")) or add a non-error early return
path that returns true/no-error when writes are intentionally skipped due to
freezeMinutes, keeping cleanExit(false, ...) reserved for real failures.
- Around line 54-62: The check-reset of the _writeNow flag in RuntimeClass::loop
has a race where a concurrent requestWriteOnNextTaskLoop() can set it between
the if-check and the reset; make _writeNow an std::atomic<bool> (or protect with
a mutex) and perform an atomic test-and-clear (e.g. use
_writeNow.exchange(false)) so the check-and-clear is atomic, then keep the
getWriteTrigger() check and call write(0) if either condition held; update
requestWriteOnNextTaskLoop() to set the atomic and include <atomic> as needed.

---

Nitpick comments:
In `@include/RuntimeData.h`:
- Around line 1-7: RuntimeData.h currently includes ArduinoJson.h even though
ArduinoJson types (e.g., JsonDocument) are only used in the implementation;
remove the ArduinoJson.h include from RuntimeData.h to reduce compile coupling,
ensure any references in the header use only standard types or forward
declarations (no ArduinoJson types are exposed), and keep ArduinoJson.h included
in RuntimeData.cpp where JsonDocument or other ArduinoJson symbols are actually
used (verify functions/methods in class RuntimeData that manipulate JSON are
implemented in RuntimeData.cpp and include ArduinoJson.h there).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0fe60b2d-7006-4d69-b74a-b302eabc7246

📥 Commits

Reviewing files that changed from the base of the PR and between 97ffc77 and d724657.

📒 Files selected for processing (10)
  • include/RuntimeData.h
  • src/RuntimeData.cpp
  • src/WebApi_firmware.cpp
  • src/WebApi_maintenance.cpp
  • src/WebApi_sysstatus.cpp
  • src/main.cpp
  • webapp/src/components/FirmwareInfo.vue
  • webapp/src/locales/de.json
  • webapp/src/locales/en.json
  • webapp/src/types/SystemStatus.ts
✅ Files skipped from review due to trivial changes (6)
  • src/WebApi_sysstatus.cpp
  • webapp/src/components/FirmwareInfo.vue
  • src/WebApi_maintenance.cpp
  • webapp/src/locales/en.json
  • webapp/src/types/SystemStatus.ts
  • webapp/src/locales/de.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/WebApi_firmware.cpp

Comment thread src/RuntimeData.cpp
Comment thread src/RuntimeData.cpp Outdated
@SW-Niko
Copy link
Copy Markdown
Author

SW-Niko commented Mar 30, 2026

Hello @AndreasBoehm,
I have a newer improved version. Can I update the PR?

@AndreasBoehm
Copy link
Copy Markdown
Member

@SW-Niko yes, sure :)

@SW-Niko
Copy link
Copy Markdown
Author

SW-Niko commented Apr 1, 2026

@coderabbitai really no findings?

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 1, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

Let me do a thorough review of the actual code first.


🏁 Script executed:

#!/bin/bash
echo "=== include/RuntimeData.h ==="
cat include/RuntimeData.h

echo ""
echo "=== src/RuntimeData.cpp ==="
cat src/RuntimeData.cpp

echo ""
echo "=== src/WebApi_firmware.cpp (relevant section) ==="
cat src/WebApi_firmware.cpp

echo ""
echo "=== src/WebApi_maintenance.cpp (relevant section) ==="
cat src/WebApi_maintenance.cpp

echo ""
echo "=== src/WebApi_sysstatus.cpp (relevant section) ==="
cat src/WebApi_sysstatus.cpp

echo ""
echo "=== src/main.cpp ==="
cat src/main.cpp

echo ""
echo "=== webapp/src/components/FirmwareInfo.vue ==="
cat webapp/src/components/FirmwareInfo.vue

echo ""
echo "=== webapp/src/types/SystemStatus.ts ==="
cat webapp/src/types/SystemStatus.ts

Length of output: 32619


Let me check for other locale files before finalising.


🏁 Script executed:

#!/bin/bash
echo "=== Locale files present ==="
ls webapp/src/locales/

echo ""
echo "=== Check other locales for RuntimeSaveCount ==="
for f in webapp/src/locales/*.json; do
  echo "--- $f ---"
  grep -n "RuntimeSaveCount" "$f" || echo "(not found)"
done

echo ""
echo "=== Utils::getEpoch signature ==="
rg -n "getEpoch" --type=cpp -C2

echo ""
echo "=== RestartHelper::triggerRestart implementation ==="
rg -n "triggerRestart" --type=cpp -C3

Length of output: 5716


@SW-Niko: Apologies — the automated review missed these on the initial pass. Here is a thorough review now.


src/RuntimeData.cpp

1. 🐛 write() uses RUNTIME_VERSION constant instead of _writeVersion member

RUNTIME_VERSION is a file-local constexpr in the .cpp. The class stores the version passed to the constructor in _writeVersion, but write() ignores it:

// current — uses file-local constant
info["version"] = RUNTIME_VERSION;

// should be — uses the value stored at construction time
info["version"] = _writeVersion;

If the version ever differs from the compile-time constant (e.g., after migration logic), the file will silently record the wrong version.


2. 🐛 Mutex held during the entire file I/O operation in write()

The std::lock_guard in write() is held while building the JSON document, opening the file, serializing, and closing it. LittleFS I/O can take tens of milliseconds, which stalls every concurrent caller of getWriteCount(), getWriteEpochTime(), and getWriteCountAndTimeString() for that entire duration.

The fix is to copy shared state under the lock, release it, then do all I/O outside:

bool RuntimeClass::write(uint16_t const freezeMinutes)
{
    // ...epoch check...

    uint16_t nextCount;
    {
        std::lock_guard<std::mutex> lock(_mutex);
        if ((freezeMinutes > 0) && (_writeEpoch != 0) &&
            (difftime(nextEpoch, _writeEpoch) < 60 * freezeMinutes)) {
            return cleanExit(true, "Time interval too short, skipping write");
        }
        nextCount = _writeCount + 1;
    } // <-- release mutex before I/O

    // build JSON and write file here, outside the lock
    JsonDocument doc;
    // ...

    {
        std::lock_guard<std::mutex> lock(_mutex);
        _writeVersion = _writeVersion;   // already correct
        _writeEpoch   = nextEpoch;
        _writeCount   = nextCount;
    }
    return cleanExit(true, "Written to file");
}

3. ⚠️ _writeOK set to true when a write is merely skipped

// freezeMinutes guard
return cleanExit(true, "Time interval too short, skipping write");

cleanExit(true, …) stores _writeOK = true, so getWriteState() returns true even though nothing was written to disk. Consider returning without touching _writeOK in the skip path, or introducing a separate "skipped" state, so the UI/callers can distinguish "last attempt succeeded" from "last attempt was suppressed".


4. ⚠️ File is truncated before a safe write — corrupted on failure

The file is opened with "w" immediately, which truncates any existing content. If serializeJson(doc, fRuntime) == 0 then returns false, the file on disk is now empty:

File fRuntime = LittleFS.open(RUNTIME_FILENAME, "w");   // ← truncates here
// ...
if (serializeJson(doc, fRuntime) == 0) {
    fRuntime.close();
    return cleanExit(false, "Failed to serialize to file");   // old data gone
}

A safer pattern is to write to a temporary file (e.g. /runtime.tmp) and rename/replace only after a successful serializeJson:

constexpr const char* RUNTIME_FILENAME_TMP = "/runtime.tmp";

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

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

LittleFS.remove(RUNTIME_FILENAME);
LittleFS.rename(RUNTIME_FILENAME_TMP, RUNTIME_FILENAME);

5. ⚠️ getWriteCountAndTimeString() calls getLocalTime() while holding the mutex

String RuntimeClass::getWriteCountAndTimeString(void) const
{
    std::lock_guard<std::mutex> lock(_mutex);   // ← lock acquired
    // ...
    if ((_writeEpoch != 0) && (getLocalTime(&time, 1))) {  // ← blocks up to 1ms under lock

Copy _writeEpoch under the lock, then release before calling getLocalTime() / localtime_r():

String RuntimeClass::getWriteCountAndTimeString(void) const
{
    time_t epoch;
    uint16_t count;
    {
        std::lock_guard<std::mutex> lock(_mutex);
        epoch = _writeEpoch;
        count = _writeCount;
    }
    char buf[32] = "";
    struct tm time;
    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");
    }
    return String(count) + String(buf);
}

6. ⚠️ write(60) is called directly from async web server callback threads

In both onFirmwareUpdateFinish and onRebootPost, RuntimeData.write(60) runs synchronously on the async_tcp task, performing blocking LittleFS I/O there. The class already provides requestWriteOnNextTaskLoop() specifically to avoid this.

Since RestartHelper.triggerRestart() only enables a scheduler task (the actual reboot happens on the next loop tick), there is enough time to defer the write to the RuntimeClass::loop() and still have it complete before the reboot. Consider:

// onFirmwareUpdateFinish / onRebootPost
RuntimeData.requestWriteOnNextTaskLoop();   // deferred, safe from async_tcp context
RestartHelper.triggerRestart();

Note: if the freeze time (60 min) suppression is still desired, you can call write(60) directly in loop() when _writeNow is set, by passing the freeze time as a parameter to requestWriteOnNextTaskLoop — or simply let loop() call write(60) instead of write(0) when _writeNow is set.


7. ℹ️ ReadMode parameter is currently unused — both branches are empty stubs

if (mode == ReadMode::START_UP) {
    ;
} else {
    ;
}

This is clearly a designed extension point, but as-is the parameter has no effect. This is fine for a "shell" PR, but it may be worth a // TODO comment making the intent explicit so reviewers/contributors of dependent PRs know where to add their data.


8. ℹ️ _writeCount is uint16_t — wraps silently at 65,535

For infrequent saves (daily + reboot) this is inconsequential in practice, but uint32_t costs nothing and avoids any edge case confusion.


webapp/src/locales/fr.json

9. 🐛 Missing French translation for RuntimeSaveCount

de.json and en.json both have the key, but fr.json does not. The UI will fall back to the key string itself for French-locale users:

// webapp/src/locales/fr.json — add inside the "firmwareinfo" block:
"RuntimeSaveCount": "Nombre d'enregistrements des données d'exécution / Heure"

Summary table

# Severity Location Issue
1 🐛 Bug write() RUNTIME_VERSION constant used instead of _writeVersion member
2 🐛 Bug write() Mutex held during full file I/O — blocks all getters for ~tens of ms
3 ⚠️ Warning write() _writeOK = true when write is suppressed by freeze interval
4 ⚠️ Warning write() File truncated before serializeJson — corruption on failure
5 ⚠️ Warning getWriteCountAndTimeString() getLocalTime() called while mutex is held
6 ⚠️ Warning WebApi_firmware/maintenance.cpp Blocking file I/O on async TCP callback thread
7 ℹ️ Info read() ReadMode branches are empty stubs — document intent
8 ℹ️ Info RuntimeData.h _writeCount is uint16_t — wraps at 65,535
9 🐛 Bug fr.json Missing RuntimeSaveCount translation key

@SW-Niko
Copy link
Copy Markdown
Author

SW-Niko commented Apr 3, 2026

Hello @AndreasBoehm ,
I've incorporated a few more comments from CodeRabbit.
The pull request is now ready to be merged.

You will find a description of the feature on this wiki page: Link

SW-Niko added 3 commits April 11, 2026 13:43
- save and load runtime data persistent in a file
- read mode ON_DEMAND or ON_STARTUP
- 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
- into update, reset
- WebUI
- mutex no longer held during the entire file I/O
- _writeOK indicates successful write operation
Comment thread src/RuntimeData.cpp
Comment thread src/RuntimeData.cpp
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

Comment thread src/main.cpp
@AndreasBoehm AndreasBoehm linked an issue Apr 23, 2026 that may be closed by this pull request
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Request] Persist data in LittleFS

4 participants