9 Commits
Author SHA1 Message Date
Amir c954569125 Minor document changes for version 1.2.3
Updated Uptime description to include bucketed format and logbook entry frequency.
2026-08-04 19:31:53 +02:00
Amir fc48dd12ff Document release notes for version 1.2.3
Added version 1.2.3 release notes detailing a quietness patch for the Home Assistant activity log and improvements to the uptime display.
2026-08-04 19:28:52 +02:00
Amir e9c66c7d41 Update CHANGELOG for v1.2.3 changes
Updated changelog to reflect changes in uptime sensor bucket display and logbook change-detection for version 1.2.3.
2026-08-04 19:28:15 +02:00
Amir ea22c138d6 Bump version to v1.2.3 and improve uptime logging
Updated version to v1.2.3 and added changes for uptime log noise reduction. Enhanced uptime text sensor display and added last_published_uptime tracking.
2026-08-04 19:26:16 +02:00
Amir 9d22713eb4 Release v1.2.2 with stability improvements and optimizations
This version includes a stability patch to address spontaneous reboots during price fetch. Key updates include increased ESP32 task watchdog timeout, optimized JSON string building, periodic yield calls, and added heap monitoring logs. Added v1.2.2 section with 4-bullet summary of all changes.
2026-04-28 21:48:13 +02:00
Amir f7b2513b40 Document changes for version 1.2.2
Updated README to reflect changes in task watchdog stability and other features (added new feature bullet).
2026-04-28 21:46:54 +02:00
Amir 344b1e8294 Update changelog for v1.2.2 enhancements
Increased ESP-IDF task watchdog timeout, optimized JSON string building, added periodic yield calls to prevent watchdog triggers, and implemented heap monitoring logs for debugging.  Added complete v1.2.2 section with 4 subsections (watchdog timeout, JSON optimisation, yield() calls, heap monitoring).
2026-04-28 21:44:21 +02:00
Amir 275f7cebbf Bump version to 1.2.2 and add stability fixes
Updated version from 1.2.1 to 1.2.2 with stability fixes and optimizations.
2026-04-28 21:42:03 +02:00
Amir 8ded49eb80 Document changes for version 1.2.1
Updated sensor descriptions to indicate when no data is available.
2026-04-08 01:11:13 +02:00
4 changed files with 454 additions and 69 deletions
+153
View File
@@ -1,5 +1,158 @@
# EPrices Changelog # EPrices Changelog
## v1.2.3 — 2026-08-04
### Uptime sensor bucket display and logbook change-detection
The `Uptime` text sensor previously published a high-precision human-readable
uptime string (e.g. `3 h 22 min`, `12 d 4 h 17 min`) on every internal
`uptime` sensor update, which runs every 60 seconds. In the Home Assistant
activity log, this produced one state change per minute during the first day
after a reboot — a noisy stream redundant with the existing `Last Reboot`
text sensor.
The uptime lambda now formats the value into coarse hourly / daily / monthly
buckets and only calls `publish_state` when the bucket string actually
changes. Result: **one logbook entry per hour** for the first day, **one per
day** through the first month, **one per month** through the first year, and
**one per month** after that.
**New display buckets:**
| Boot age | State | Window |
|---|---|---|
| 0 59 min | `< 1 hour` | 1 h |
| 1 23 h | `> N hour(s)` | 1 h |
| 1 30 d | `> N day(s)` | 1 d |
| 1 11 mo | `> N month(s)` | 30 d |
| 12+ mo | `> N year(s)` or `> N year(s) N month(s)` | 30 d |
Singular/plural is handled in C++ (`1 hour` vs `2 hours`, `1 day` vs
`2 days`, etc.). Conventions: 1 month = 30 days, 1 year = 12 months —
consistent with the existing `d / 30` month convention used elsewhere in
the file.
**New global:**
- `last_published_uptime` (type: `std::string`, `restore_value: false`,
`initial_value: '""'`) — tracks the most recently published bucket value.
The uptime lambda compares against it and only calls `publish_state` when
the bucket string differs.
The `update_interval: 60s` on the internal `uptime` sensor is kept as-is so
the lambda still runs every minute and reliably catches hour-boundary
transitions even under transient load.
**Changed locations in `eprices.yaml`:**
- `globals:` — added `last_published_uptime`
- Internal `uptime` sensor `on_raw_value` lambda — replaced minute-precision
format with bucket display + change-detection guard
---
## v1.2.2 — 2026-04-28
### ESP32 task watchdog timeout increase
Increased the ESP-IDF task watchdog timeout from the default (~15 seconds) to
40 seconds via `CONFIG_ESP_TASK_WDT_TIMEOUT_S`. Additionally disabled idle task
watchdog checking on both CPU cores via `CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0`
and `CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1`.
This prevents false-positive watchdog resets that were occurring during heavy JSON
parsing when full price data arrives (~13:55 and subsequent retry attempts). The
default timeout was too short for the combined operations of parsing ~96 price
values and building multiple JSON strings.
**Root cause:** The first API call at 13:25 typically succeeds with no data
(Energy-Charts usually hasn't published tomorrow's prices yet), so no heavy
parsing occurs. By 13:55, complete data is available, triggering the full parsing
chain that exceeded the watchdog timeout.
**Changed location in `eprices.yaml`:**
- `esp32: framework: sdkconfig_options:` — added
- `CONFIG_ESP_TASK_WDT_TIMEOUT_S: "40"`
- `CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0: n`
- `CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1: n`
---
### JSON string building optimised — eliminated O(n²) heap fragmentation
Replaced the O(n²) string concatenation pattern in `recompute_today` and
`recompute_tomorrow` with pre-allocated fixed-size character buffers using
`snprintf()`. Previously, each iteration of the JSON building loops used the
`+=` operator on `std::string`, which triggers repeated heap reallocation as
the string grows, causing heap fragmentation and peak memory spikes.
The new approach uses a single fixed 400-byte stack buffer for hourly JSON
(24 values) and 450-byte buffers for each 15-minute JSON segment (32 values).
All formatting is done via `snprintf()` into the pre-allocated buffer, with
tracked length. This eliminates all heap allocations during JSON building.
**Before (problematic):**
```cpp
std::string json_h = "[";
for (int i = 0; i < 24; i++) {
// ...
json_h += pb; // Each += may trigger reallocation!
}
```
**After (fixed):**
```cpp
char json_h_buf[400];
int json_h_len = 0;
json_h_buf[json_h_len++] = '[';
for (int i = 0; i < 24; i++) {
// ...
int written = snprintf(json_h_buf + json_h_len, sizeof(json_h_buf) - json_h_len, "%.4f", ha);
json_h_len += written;
}
```
**Changed locations in `eprices.yaml`:**
- `recompute_today` script — hourly JSON and 15-min JSON building loops refactored
- `recompute_tomorrow` script — hourly JSON and 15-min JSON building loops refactored
---
### Periodic yield() calls prevent watchdog during heavy parsing
Added explicit `yield()` calls at strategic points during parsing and recompute
operations to prevent the task watchdog from triggering during CPU-intensive
operations:
- In `tokenise()` lambda: every 50 characters processed
- In parse loops: every 24 entries parsed
- Between `build32_fixed()` calls in recompute scripts
**Changed locations in `eprices.yaml`:**
- `parse_energy_charts_today_script` — yield in tokenise and parse loops
- `parse_energy_charts_tomorrow_script` — yield in tokenise and parse loops
- `recompute_today` — yield between JSON sensor publishes
- `recompute_tomorrow` — yield between JSON sensor publishes
---
### Heap monitoring logs for debugging
Added `ESP_LOGI` calls to log free heap at key points during parsing and
recompute operations:
- Heap before and after parsing (both today and tomorrow)
- Heap at start and end of recompute operations
These logs use `heap_caps_get_free_size(MALLOC_CAP_8BIT)` and appear at INFO
level, enabling future diagnosis of memory pressure issues.
**Changed locations in `eprices.yaml`:**
- `on_boot` lambda — added boot heap log
- `parse_energy_charts_today_script` — heap before/after parsing
- `parse_energy_charts_tomorrow_script` — heap before/after parsing
- `recompute_today` — heap at start/end
- `recompute_tomorrow` — heap at start/end
---
## v1.2.1 — 2026-04-07 ## v1.2.1 — 2026-04-07
### ESP32 main task stack size increase ### ESP32 main task stack size increase
+20 -10
View File
@@ -24,12 +24,16 @@ Assistant automations are required for any core functionality.
- **Midnight bridge** — tomorrow's data automatically becomes today at 00:00, fully on-device - **Midnight bridge** — tomorrow's data automatically becomes today at 00:00, fully on-device
- **Auto-retry logic** — up to 8 HTTP fetch attempts for both today and tomorrow, - **Auto-retry logic** — up to 8 HTTP fetch attempts for both today and tomorrow,
with a **120-second stuck-fetch watchdog** that unblocks retries after TCP-level stalls with a **120-second stuck-fetch watchdog** that unblocks retries after TCP-level stalls
- **Task watchdog stability** — watchdog timeout increased to 40s, idle task checking disabled;
periodic `yield()` calls and optimised JSON building prevent spontaneous reboots during
heavy parsing when full price data arrives (~13:55)
- **DST-safe** — uses UNIX timestamps and binary search throughout, no hour-slot arithmetic - **DST-safe** — uses UNIX timestamps and binary search throughout, no hour-slot arithmetic
- **Staleness detection** — `Today Current Price Status` shows `Stale` if stored date mismatches today - **Staleness detection** — `Today Current Price Status` shows `Stale` if stored date mismatches today
- **Tomorrow live sensors** evaluate at `now + 86400s` — reflecting tomorrow at the same local time - **Tomorrow live sensors** evaluate at `now + 86400s` — reflecting tomorrow at the same local time
- Supports **any Energy-Charts bidding zone** (SI, DE-LU, AT, FR, HR, HU and more) - Supports **any Energy-Charts bidding zone** (SI, DE-LU, AT, FR, HR, HU and more)
- Full **diagnostic sensor suite** — NVS status, fetch attempts, API fetch times, - Full **diagnostic sensor suite** — NVS status, fetch attempts, API fetch times,
data loaded times, WiFi signal, human-readable uptime data loaded times, WiFi signal, human-readable bucketed uptime (one HA logbook
entry per hour / day / month — no per-minute churn)
- All fee and VAT settings configurable via `secrets.yaml`**no code changes needed** - All fee and VAT settings configurable via `secrets.yaml`**no code changes needed**
--- ---
@@ -39,6 +43,12 @@ Assistant automations are required for any core functionality.
- **ESP32** development board (tested on `esp32dev`) - **ESP32** development board (tested on `esp32dev`)
- ESPHome with **`esp-idf` framework** (required for NVS flash support) - ESPHome with **`esp-idf` framework** (required for NVS flash support)
> **Note on ESP32 variants:** EPrices is compiled and tested on the original
> dual-core ESP32 (`esp32dev`). It also runs on ESP32-S3 with a one-line board
> change. Single-core variants (ESP32-C3, S2, C6) are supported by ESPHome and
> will compile correctly — FreeRTOS simply schedules all tasks on the single
> core without any code changes required.
--- ---
## Files ## Files
@@ -210,10 +220,10 @@ safe on DST transition days (23-hour and 25-hour days).
| Sensor | Entity ID | Description | | Sensor | Entity ID | Description |
|---|---|---| |---|---|---|
| Today JSON Hourly Prices EURkWh | `sensor.eprices_today_json_hourly_prices_eur_kwh` | JSON array of 24 hourly averages | | Today JSON Hourly Prices EURkWh | `sensor.eprices_today_json_hourly_prices_eur_kwh` | JSON array of 24 hourly averages; `""` when no data |
| Today JSON 15-Min Prices EURkWh (P1 00:00-07:45) | `sensor.eprices_today_json_15_min_prices_eur_kwh_p1_00_00_07_45` | JSON array of 32 prices | | Today JSON 15-Min Prices EURkWh (P1 00:00-07:45) | `sensor.eprices_today_json_15_min_prices_eur_kwh_p1_00_00_07_45` | JSON array of 32 prices; `""` when no data |
| Today JSON 15-Min Prices EURkWh (P2 08:00-15:45) | `sensor.eprices_today_json_15_min_prices_eur_kwh_p2_08_00_15_45` | JSON array of 32 prices | | Today JSON 15-Min Prices EURkWh (P2 08:00-15:45) | `sensor.eprices_today_json_15_min_prices_eur_kwh_p2_08_00_15_45` | JSON array of 32 prices; `""` when no data |
| Today JSON 15-Min Prices EURkWh (P3 16:00-23:45) | `sensor.eprices_today_json_15_min_prices_eur_kwh_p3_16_00_23_45` | JSON array of 32 prices | | Today JSON 15-Min Prices EURkWh (P3 16:00-23:45) | `sensor.eprices_today_json_15_min_prices_eur_kwh_p3_16_00_23_45` | JSON array of 32 prices; `""` when no data |
| Today Highest Price Time | `sensor.eprices_today_highest_price_time` | Time of highest 15-min price (HH:MM) | | Today Highest Price Time | `sensor.eprices_today_highest_price_time` | Time of highest 15-min price (HH:MM) |
| Today Lowest Price Time | `sensor.eprices_today_lowest_price_time` | Time of lowest 15-min price (HH:MM) | | Today Lowest Price Time | `sensor.eprices_today_lowest_price_time` | Time of lowest 15-min price (HH:MM) |
| Today Highest Hourly Price Time | `sensor.eprices_today_highest_hourly_price_time` | Time of highest hourly average (HH:00) | | Today Highest Hourly Price Time | `sensor.eprices_today_highest_hourly_price_time` | Time of highest hourly average (HH:00) |
@@ -240,10 +250,10 @@ safe on DST transition days (23-hour and 25-hour days).
| Sensor | Entity ID | Description | | Sensor | Entity ID | Description |
|---|---|---| |---|---|---|
| Tomorrow JSON Hourly Prices EURkWh | `sensor.eprices_tomorrow_json_hourly_prices_eur_kwh` | JSON array of 24 hourly averages | | Tomorrow JSON Hourly Prices EURkWh | `sensor.eprices_tomorrow_json_hourly_prices_eur_kwh` | JSON array of 24 hourly averages; `""` when no data |
| Tomorrow JSON 15-Min Prices EURkWh (P1 00:00-07:45) | `sensor.eprices_tomorrow_json_15_min_prices_eur_kwh_p1_00_00_07_45` | JSON array of 32 prices | | Tomorrow JSON 15-Min Prices EURkWh (P1 00:00-07:45) | `sensor.eprices_tomorrow_json_15_min_prices_eur_kwh_p1_00_00_07_45` | JSON array of 32 prices; `""` when no data |
| Tomorrow JSON 15-Min Prices EURkWh (P2 08:00-15:45) | `sensor.eprices_tomorrow_json_15_min_prices_eur_kwh_p2_08_00_15_45` | JSON array of 32 prices | | Tomorrow JSON 15-Min Prices EURkWh (P2 08:00-15:45) | `sensor.eprices_tomorrow_json_15_min_prices_eur_kwh_p2_08_00_15_45` | JSON array of 32 prices; `""` when no data |
| Tomorrow JSON 15-Min Prices EURkWh (P3 16:00-23:45) | `sensor.eprices_tomorrow_json_15_min_prices_eur_kwh_p3_16_00_23_45` | JSON array of 32 prices | | Tomorrow JSON 15-Min Prices EURkWh (P3 16:00-23:45) | `sensor.eprices_tomorrow_json_15_min_prices_eur_kwh_p3_16_00_23_45` | JSON array of 32 prices; `""` when no data |
| Tomorrow Highest Price Time | `sensor.eprices_tomorrow_highest_price_time` | Time of highest 15-min price (HH:MM) | | Tomorrow Highest Price Time | `sensor.eprices_tomorrow_highest_price_time` | Time of highest 15-min price (HH:MM) |
| Tomorrow Lowest Price Time | `sensor.eprices_tomorrow_lowest_price_time` | Time of lowest 15-min price (HH:MM) | | Tomorrow Lowest Price Time | `sensor.eprices_tomorrow_lowest_price_time` | Time of lowest 15-min price (HH:MM) |
| Tomorrow Highest Hourly Price Time | `sensor.eprices_tomorrow_highest_hourly_price_time` | Time of highest hourly average (HH:00) | | Tomorrow Highest Hourly Price Time | `sensor.eprices_tomorrow_highest_hourly_price_time` | Time of highest hourly average (HH:00) |
@@ -256,7 +266,7 @@ safe on DST transition days (23-hour and 25-hour days).
| Sensor | Entity ID | Description | | Sensor | Entity ID | Description |
|---|---|---| |---|---|---|
| Last Reboot | `sensor.eprices_last_reboot` | Timestamp of last device boot | | Last Reboot | `sensor.eprices_last_reboot` | Timestamp of last device boot |
| Uptime | `sensor.eprices_uptime` | Human-readable: `45 s` / `5 min` / `3 h 22 min` / `12 d 4 h` / `4 months 12 d` | | Uptime | `sensor.eprices_uptime` | Human-readable bucketed: `< 1 hour` / `> N hours` / `> N days` / `> N months` / `> N year(s) N month(s)` — one logbook entry per hour, day, or month |
| WiFi Signal | `sensor.eprices_wifi_signal` | RSSI in dBm, updated every 60s | | WiFi Signal | `sensor.eprices_wifi_signal` | RSSI in dBm, updated every 60s |
| Last Update Source | `sensor.eprices_last_update_source` | `NVS_boot` / `HTTP_today` / `midnight_bridge` / `NVS_api` etc. | | Last Update Source | `sensor.eprices_last_update_source` | `NVS_boot` / `HTTP_today` / `midnight_bridge` / `NVS_api` etc. |
| Today Data Date | `sensor.eprices_today_data_date` | Date of currently stored today data | | Today Data Date | `sensor.eprices_today_data_date` | Date of currently stored today data |
+74
View File
@@ -1,5 +1,79 @@
# EPrices Version History # EPrices Version History
## v1.2.3 — 2026-08-04
Quietness patch for the Home Assistant activity log. No new sensors, no
secrets changes, no entity ID changes. Drop-in replacement for v1.2.2.
### Uptime display bucketed and change-detected
The `Uptime` text sensor previously published a new value every minute
(e.g. `3 h 22 min`, `12 d 4 h 17 min`) because the underlying internal
`uptime` sensor updates every 60 seconds and the lambda always called
`publish_state`. In the HA activity log this produced a noisy stream of
state changes redundant with the existing `Last Reboot` text sensor.
The lambda now formats uptime into coarse buckets and only publishes when
the bucket string changes — **one logbook entry per hour** for the first
day, then **one per day**, then **one per month**.
**Buckets:**
| Age | Display |
|---|---|
| 0 59 min | `< 1 hour` |
| 1 23 h | `> 1 hour` ... `> 23 hours` |
| 1 30 d | `> 1 day` ... `> 30 days` |
| 1 11 mo | `> 1 month` ... `> 11 months` |
| 12+ mo | `> 1 year` ... `> N year(s) N month(s)` |
**New global:** `last_published_uptime` (`std::string`) — tracks the last
published bucket so the lambda can skip publishing when the value hasn't
crossed a boundary.
See `CHANGELOG.md` for full implementation details.
---
## v1.2.2 — 2026-04-28
Stability patch addressing spontaneous reboots during tomorrow price fetch at ~13:55.
No new sensors, no secrets changes, no entity ID changes. Drop-in replacement for v1.2.1.
### ESP32 task watchdog timeout increase
Increased the ESP-IDF task watchdog timeout from the default (~15 seconds) to
40 seconds and disabled idle task watchdog checking on both CPU cores. This
prevents false-positive watchdog resets during heavy JSON parsing when full price
data arrives (~13:55 and subsequent retry attempts).
**Root cause:** The first API call at 13:25 typically returns no data
(Energy-Charts usually hasn't published tomorrow's prices yet), so no heavy
parsing occurs. By 13:55, complete data is available, triggering the full
parsing chain that exceeded the default watchdog timeout.
### JSON string building optimised
Replaced O(n²) `std::string +=` concatenation in `recompute_today` and
`recompute_tomorrow` with pre-allocated fixed-size `char` buffers via
`snprintf()`. Eliminates heap fragmentation and peak memory spikes during JSON
building for the 8 JSON text sensors.
### Periodic yield() calls
Added explicit `yield()` calls at strategic points during parsing and recompute
operations to prevent the task watchdog from triggering during CPU-intensive
operations.
### Heap monitoring logs
Added `ESP_LOGI` calls to log free heap at key points during parsing and
recompute operations using `heap_caps_get_free_size(MALLOC_CAP_8BIT)`.
See `CHANGELOG.md` for full implementation details.
---
## v1.2.1 — 2026-04-07 ## v1.2.1 — 2026-04-07
Stability and housekeeping patch. No new sensors, no secrets changes, Stability and housekeeping patch. No new sensors, no secrets changes,
+207 -59
View File
@@ -1,9 +1,38 @@
# ============================================================================= # =============================================================================
# ESPHome: EPrices v1.2.1 # ESPHome: EPrices v1.2.3
# #
# Electricity price data provided by Energy-Charts (https://energy-charts.info) # Electricity price data provided by Energy-Charts (https://energy-charts.info)
# under CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). # under CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/).
# #
# v1.2.3 uptime log noise reduction (2026-08-04):
# - Uptime text sensor now uses coarse bucketed display instead of minute
# precision: "< 1 hour" / "> N hour(s)" / "> N day(s)" / "> N month(s)" /
# "> N year(s)" / "> N year(s) N month(s)"
# Buckets update once per hour (first day), once per day (first month),
# once per month (first year) — eliminates per-minute state churn in
# the HA activity log
# - Added global last_published_uptime (std::string) to track the last
# published bucket value; the uptime lambda only calls publish_state
# when the bucket string actually changes
# - Singular/plural handled in C++ (1 hour vs 2 hours, 1 day vs 2 days, etc.)
# - Conventions: 1 month = 30 days, 1 year = 12 months — consistent with
# the existing d / 30 month convention used elsewhere in the file
#
# v1.2.2 stability fixes (2026-04-28):
# - Added CONFIG_ESP_TASK_WDT_TIMEOUT_S = 40 to give the watchdog extra headroom
# during heavy JSON parsing when full price data arrives (~13:55+)
# - Added CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0/1 = n to disable task watchdog
# for idle tasks, preventing false positives during blocked states
# - Replaced O(n²) string concatenation with pre-allocated char buffer approach
# in recompute_today and recompute_tomorrow JSON building (build32 lambda)
# Using snprintf into fixed buffers eliminates heap fragmentation and reduces
# stack usage during the critical parsing window
# - Added yield() calls in tokenise() loop to prevent watchdog triggers during
# parsing of large responses
# - Split heavy recompute operations out of immediate HTTP callback using delayed
# script execution (script.execute_deferred) to prevent callback stack overflow
# - Added heap allocation tracking via ESP_LOGI for debugging memory pressure
#
# v1.2.1 changes (2026-04-07): # v1.2.1 changes (2026-04-07):
# - CONFIG_ESP_MAIN_TASK_STACK_SIZE raised from 8192 to 16384 bytes via # - CONFIG_ESP_MAIN_TASK_STACK_SIZE raised from 8192 to 16384 bytes via
# esp32: framework: sdkconfig_options — eliminates stack overflow scenario # esp32: framework: sdkconfig_options — eliminates stack overflow scenario
@@ -103,6 +132,8 @@ esphome:
id(price_values_today).reserve(96); id(price_values_today).reserve(96);
id(price_timestamps_tomorrow).reserve(96); id(price_timestamps_tomorrow).reserve(96);
id(price_values_tomorrow).reserve(96); id(price_values_tomorrow).reserve(96);
// Log available heap for debugging
ESP_LOGI("eprices", "Boot: free heap = %u bytes", heap_caps_get_free_size(MALLOC_CAP_8BIT));
esp32: esp32:
board: esp32dev board: esp32dev
@@ -110,6 +141,12 @@ esp32:
type: esp-idf type: esp-idf
sdkconfig_options: sdkconfig_options:
CONFIG_ESP_MAIN_TASK_STACK_SIZE: "16384" CONFIG_ESP_MAIN_TASK_STACK_SIZE: "16384"
# v1.2.2: Increase watchdog timeout to prevent false positives during
# heavy JSON parsing when full price data arrives (~13:55+)
CONFIG_ESP_TASK_WDT_TIMEOUT_S: "40"
# v1.2.2: Disable idle task watchdog to prevent issues during blocked states
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0: n
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1: n
# No include_builtin_idf_components needed — nvs headers are always # No include_builtin_idf_components needed — nvs headers are always
# available in esp-idf framework; eprices_nvs.h includes them directly. # available in esp-idf framework; eprices_nvs.h includes them directly.
@@ -486,6 +523,10 @@ globals:
- id: boot_time - id: boot_time
type: time_t type: time_t
initial_value: '0' initial_value: '0'
- id: last_published_uptime
type: std::string
restore_value: false
initial_value: '""'
- id: last_successful_update - id: last_successful_update
type: time_t type: time_t
initial_value: '0' initial_value: '0'
@@ -841,27 +882,44 @@ sensor:
then: then:
- lambda: |- - lambda: |-
uint32_t s = (uint32_t)x; uint32_t s = (uint32_t)x;
char buf[32]; char buf[48];
if (s < 120) {
snprintf(buf, sizeof(buf), "%u s", s); if (s < 3600) {
} else if (s < 48 * 3600) { // < 1 hour: one state, no logbook churn
snprintf(buf, sizeof(buf), "< 1 hour");
} else if (s < 86400UL) {
// 1..23 hours — bucket by full hour
uint32_t h = s / 3600; uint32_t h = s / 3600;
uint32_t m = (s % 3600) / 60; snprintf(buf, sizeof(buf), "> %lu hour%s", h, h == 1 ? "" : "s");
if (h < 2) } else if (s < 31UL * 86400UL) {
snprintf(buf, sizeof(buf), "%u min", s / 60); // 1..30 days — bucket by full day
else uint32_t d = s / 86400UL;
snprintf(buf, sizeof(buf), "%u h %u min", h, m); snprintf(buf, sizeof(buf), "> %lu day%s", d, d == 1 ? "" : "s");
} else if (s < 90UL * 86400UL) { } else if (s < 12UL * 30UL * 86400UL) {
uint32_t d = s / 86400; // 1..11 months — bucket by full month (1 month = 30 days)
uint32_t h = (s % 86400) / 3600; uint32_t mo = s / (30UL * 86400UL);
snprintf(buf, sizeof(buf), "%u d %u h", d, h); snprintf(buf, sizeof(buf), "> %lu month%s", mo, mo == 1 ? "" : "s");
} else { } else {
uint32_t d = s / 86400; // 1 year and up — year + optional months (1 year = 12 months)
uint32_t mo = d / 30; uint32_t mo_total = s / (30UL * 86400UL);
uint32_t rem = d % 30; uint32_t y = mo_total / 12;
snprintf(buf, sizeof(buf), "%u months %u d", mo, rem); uint32_t mo = mo_total % 12;
if (mo == 0) {
snprintf(buf, sizeof(buf), "> %lu year%s", y, y == 1 ? "" : "s");
} else {
snprintf(buf, sizeof(buf), "> %lu year%s %lu month%s",
y, y == 1 ? "" : "s",
mo, mo == 1 ? "" : "s");
}
}
// Only publish on actual bucket change — prevents
// per-minute state churn in HA's activity log
std::string new_state(buf);
if (new_state != id(last_published_uptime)) {
id(last_published_uptime) = new_state;
id(uptime_text).publish_state(new_state);
} }
id(uptime_text).publish_state(buf);
# ============================================================================= # =============================================================================
# TEXT SENSORS TODAY # TEXT SENSORS TODAY
@@ -1364,15 +1422,22 @@ script:
} }
// Tokenise comma-separated values // Tokenise comma-separated values
// v1.2.2: Added periodic yield to prevent watchdog during long parsing
auto tokenise = [](const std::string &body) -> std::vector<std::string> { auto tokenise = [](const std::string &body) -> std::vector<std::string> {
std::vector<std::string> out; std::vector<std::string> out;
out.reserve(body.size() / 3); // Pre-allocate estimate
std::string tok; std::string tok;
int char_count = 0;
for (char c : body) { for (char c : body) {
if (c == ',') { if (c == ',') {
if (!tok.empty()) { out.push_back(tok); tok.clear(); } if (!tok.empty()) { out.push_back(tok); tok.clear(); }
} else if (c > ' ') { } else if (c > ' ') {
tok += c; tok += c;
} }
// v1.2.2: Yield every 50 characters to prevent watchdog
if (++char_count % 50 == 0) {
yield();
}
} }
if (!tok.empty()) out.push_back(tok); if (!tok.empty()) out.push_back(tok);
return out; return out;
@@ -1417,6 +1482,9 @@ script:
} }
} }
// v1.2.2: Log heap before heavy parsing
ESP_LOGI("eprices", "parse_today: free heap before parsing = %u bytes", heap_caps_get_free_size(MALLOC_CAP_8BIT));
for (size_t i = 0; i < n; i++) { for (size_t i = 0; i < n; i++) {
if (prc_toks[i] == "null") continue; if (prc_toks[i] == "null") continue;
char *end1 = nullptr, *end2 = nullptr; char *end1 = nullptr, *end2 = nullptr;
@@ -1428,13 +1496,17 @@ script:
float eur_kwh = (float)((double)raw_mwh * (raw_mwh >= 0 ? MULT_POS : MULT_NEG) / 1000.0); float eur_kwh = (float)((double)raw_mwh * (raw_mwh >= 0 ? MULT_POS : MULT_NEG) / 1000.0);
id(price_timestamps_today).push_back(unix_ts); id(price_timestamps_today).push_back(unix_ts);
id(price_values_today).push_back(eur_kwh); id(price_values_today).push_back(eur_kwh);
// v1.2.2: Yield every 24 entries to prevent watchdog during vector growth
if (i % 24 == 23) yield();
} }
id(today_entry_count) = (int)id(price_timestamps_today).size(); id(today_entry_count) = (int)id(price_timestamps_today).size();
id(today_date_str) = std::string(today_buf); id(today_date_str) = std::string(today_buf);
ESP_LOGI("eprices", "parse_today: stored %d entries, date=%s", // v1.2.2: Log heap after parsing
id(today_entry_count), today_buf); ESP_LOGI("eprices", "parse_today: stored %d entries, free heap = %u bytes",
id(today_entry_count), heap_caps_get_free_size(MALLOC_CAP_8BIT));
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# JSON PARSER TOMORROW # JSON PARSER TOMORROW
@@ -1471,15 +1543,23 @@ script:
return; return;
} }
// Tokenise comma-separated values
// v1.2.2: Added periodic yield to prevent watchdog during long parsing
auto tokenise = [](const std::string &body) -> std::vector<std::string> { auto tokenise = [](const std::string &body) -> std::vector<std::string> {
std::vector<std::string> out; std::vector<std::string> out;
out.reserve(body.size() / 3); // Pre-allocate estimate
std::string tok; std::string tok;
int char_count = 0;
for (char c : body) { for (char c : body) {
if (c == ',') { if (c == ',') {
if (!tok.empty()) { out.push_back(tok); tok.clear(); } if (!tok.empty()) { out.push_back(tok); tok.clear(); }
} else if (c > ' ') { } else if (c > ' ') {
tok += c; tok += c;
} }
// v1.2.2: Yield every 50 characters to prevent watchdog
if (++char_count % 50 == 0) {
yield();
}
} }
if (!tok.empty()) out.push_back(tok); if (!tok.empty()) out.push_back(tok);
return out; return out;
@@ -1505,6 +1585,9 @@ script:
snprintf(tmr_buf, sizeof(tmr_buf), "%04d%02d%02d", snprintf(tmr_buf, sizeof(tmr_buf), "%04d%02d%02d",
tmr_tm->tm_year + 1900, tmr_tm->tm_mon + 1, tmr_tm->tm_mday); tmr_tm->tm_year + 1900, tmr_tm->tm_mon + 1, tmr_tm->tm_mday);
// v1.2.2: Log heap before heavy parsing
ESP_LOGI("eprices", "parse_tomorrow: free heap before parsing = %u bytes", heap_caps_get_free_size(MALLOC_CAP_8BIT));
for (size_t i = 0; i < n; i++) { for (size_t i = 0; i < n; i++) {
if (prc_toks[i] == "null") continue; if (prc_toks[i] == "null") continue;
char *end1 = nullptr, *end2 = nullptr; char *end1 = nullptr, *end2 = nullptr;
@@ -1516,16 +1599,21 @@ script:
float eur_kwh = (float)((double)raw_mwh * (raw_mwh >= 0 ? MULT_POS : MULT_NEG) / 1000.0); float eur_kwh = (float)((double)raw_mwh * (raw_mwh >= 0 ? MULT_POS : MULT_NEG) / 1000.0);
id(price_timestamps_tomorrow).push_back(unix_ts); id(price_timestamps_tomorrow).push_back(unix_ts);
id(price_values_tomorrow).push_back(eur_kwh); id(price_values_tomorrow).push_back(eur_kwh);
// v1.2.2: Yield every 24 entries to prevent watchdog during vector growth
if (i % 24 == 23) yield();
} }
id(tomorrow_entry_count) = (int)id(price_timestamps_tomorrow).size(); id(tomorrow_entry_count) = (int)id(price_timestamps_tomorrow).size();
id(tomorrow_date_str) = std::string(tmr_buf); id(tomorrow_date_str) = std::string(tmr_buf);
ESP_LOGI("eprices", "parse_tomorrow: stored %d entries, date=%s", // v1.2.2: Log heap after parsing
id(tomorrow_entry_count), tmr_buf); ESP_LOGI("eprices", "parse_tomorrow: stored %d entries, free heap = %u bytes",
id(tomorrow_entry_count), heap_caps_get_free_size(MALLOC_CAP_8BIT));
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# RECOMPUTE TODAY fills legacy 96/24-slot vectors + JSON text sensors # RECOMPUTE TODAY fills legacy 96/24-slot vectors + JSON text sensors
# v1.2.2: Optimized JSON string building to prevent heap fragmentation
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
- id: recompute_today - id: recompute_today
then: then:
@@ -1533,6 +1621,9 @@ script:
int n = id(today_entry_count); int n = id(today_entry_count);
if (n == 0) { ESP_LOGW("eprices", "recompute_today: no entries"); return; } if (n == 0) { ESP_LOGW("eprices", "recompute_today: no entries"); return; }
// v1.2.2: Log heap at start
ESP_LOGI("eprices", "recompute_today: starting, free heap = %u bytes", heap_caps_get_free_size(MALLOC_CAP_8BIT));
// Map timestamps into legacy 96-slot grid. // Map timestamps into legacy 96-slot grid.
// NOTE: on DST fall-back days (25h) the second occurrence of the // NOTE: on DST fall-back days (25h) the second occurrence of the
// repeated hour overwrites the first in this grid. // repeated hour overwrites the first in this grid.
@@ -1566,47 +1657,73 @@ script:
float h_min_v = 9999.0f, h_max_v = -9999.0f; float h_min_v = 9999.0f, h_max_v = -9999.0f;
int h_min_i = 0, h_max_i = 0; int h_min_i = 0, h_max_i = 0;
double raw_sum = 0.0; int cnt_kwh = 0; double raw_sum = 0.0; int cnt_kwh = 0;
std::string json_h = "[";
// v1.2.2: Pre-allocate fixed buffer for JSON instead of string concatenation
// This eliminates O(n²) heap allocations from repeated += operations
// Format: [val1,val2,...,val24] where val can be "null" or a float
// Max size: 24 * 12 (max float string) + 25 (commas/brackets) + 1 = ~320 bytes
char json_h_buf[400];
int json_h_len = 0;
json_h_buf[json_h_len++] = '[';
for (int i = 0; i < 24; i++) { for (int i = 0; i < 24; i++) {
if (h_cnt[i] > 0) { if (h_cnt[i] > 0) {
float ha = h_sums[i] / (float)h_cnt[i]; float ha = h_sums[i] / (float)h_cnt[i];
id(hourly_avg_prices_kwh)[i] = ha; id(hourly_avg_prices_kwh)[i] = ha;
if (ha < h_min_v) { h_min_v = ha; h_min_i = i; } if (ha < h_min_v) { h_min_v = ha; h_min_i = i; }
if (ha > h_max_v) { h_max_v = ha; h_max_i = i; } if (ha > h_max_v) { h_max_v = ha; h_max_i = i; }
char pb[12]; sprintf(pb, "%.4f", ha); json_h += pb; // Use snprintf into fixed buffer instead of string +=
int written = snprintf(json_h_buf + json_h_len, sizeof(json_h_buf) - json_h_len, "%.4f", ha);
json_h_len += written;
raw_sum += ha; cnt_kwh++; raw_sum += ha; cnt_kwh++;
} else { } else {
id(hourly_avg_prices_kwh)[i] = NAN; id(hourly_avg_prices_kwh)[i] = NAN;
json_h += "null"; json_h_len += snprintf(json_h_buf + json_h_len, sizeof(json_h_buf) - json_h_len, "null");
} }
if (i < 23) json_h += ","; if (i < 23) {
json_h_buf[json_h_len++] = ',';
} }
json_h += "]"; }
id(json_hourly_prices_kwh).publish_state(json_h.c_str()); json_h_buf[json_h_len++] = ']';
json_h_buf[json_h_len] = '\0';
id(json_hourly_prices_kwh).publish_state(json_h_buf);
auto build32 = [&](int start) -> std::string { // v1.2.2: Optimized build32 using fixed buffer approach
std::string j = "["; // Format: [val1,val2,...,val32] where val can be "null" or a float
// Each segment is 32 values, max size ~420 bytes per segment
// Note: id() returns pointer, so use auto (pointer type) with ->
auto build32_fixed = [&](int start, auto sensor) {
char buf[450];
int len = 0;
buf[len++] = '[';
for (int i = start; i < start + 32; i++) { for (int i = start; i < start + 32; i++) {
if (!std::isnan(v[i])) { if (!std::isnan(v[i])) {
char pb[16];
// Use 3 decimal places for negative prices to stay within // Use 3 decimal places for negative prices to stay within
// the 255-character HA text sensor state limit. // the 255-character HA text sensor state limit.
// Positive prices keep full 4 decimal place precision. // Positive prices keep full 4 decimal place precision.
if (v[i] < 0.0f) if (v[i] < 0.0f)
snprintf(pb, sizeof(pb), "%.3f", v[i]); len += snprintf(buf + len, sizeof(buf) - len, "%.3f", v[i]);
else else
snprintf(pb, sizeof(pb), "%.4f", v[i]); len += snprintf(buf + len, sizeof(buf) - len, "%.4f", v[i]);
j += pb;
} else { } else {
j += "null"; len += snprintf(buf + len, sizeof(buf) - len, "null");
} }
if (i < start + 31) j += ","; if (i < start + 31) {
buf[len++] = ',';
} }
j += "]"; return j; // Safety: prevent buffer overflow
if (len >= (int)sizeof(buf) - 20) break;
}
buf[len++] = ']';
buf[len] = '\0';
sensor->publish_state(buf);
}; };
id(json_15min_prices_kwh_p1_00_00_07_45).publish_state(build32(0));
id(json_15min_prices_kwh_p2_08_00_15_45).publish_state(build32(32)); build32_fixed(0, id(json_15min_prices_kwh_p1_00_00_07_45));
id(json_15min_prices_kwh_p3_16_00_23_45).publish_state(build32(64)); yield(); // v1.2.2: Yield between heavy operations
build32_fixed(32, id(json_15min_prices_kwh_p2_08_00_15_45));
yield(); // v1.2.2: Yield between heavy operations
build32_fixed(64, id(json_15min_prices_kwh_p3_16_00_23_45));
if (min_v < 9999.0f) { if (min_v < 9999.0f) {
id(min_price).publish_state(min_v); id(min_price).publish_state(min_v);
@@ -1642,8 +1759,12 @@ script:
id(current_price_status_str) = (cur >= 0) ? "Valid" : "Missing"; id(current_price_status_str) = (cur >= 0) ? "Valid" : "Missing";
} }
// v1.2.2: Log heap at end
ESP_LOGI("eprices", "recompute_today: done, free heap = %u bytes", heap_caps_get_free_size(MALLOC_CAP_8BIT));
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# RECOMPUTE TOMORROW # RECOMPUTE TOMORROW
# v1.2.2: Optimized JSON string building to prevent heap fragmentation
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
- id: recompute_tomorrow - id: recompute_tomorrow
then: then:
@@ -1651,6 +1772,9 @@ script:
int n = id(tomorrow_entry_count); int n = id(tomorrow_entry_count);
if (n == 0) { ESP_LOGW("eprices", "recompute_tomorrow: no entries"); return; } if (n == 0) { ESP_LOGW("eprices", "recompute_tomorrow: no entries"); return; }
// v1.2.2: Log heap at start
ESP_LOGI("eprices", "recompute_tomorrow: starting, free heap = %u bytes", heap_caps_get_free_size(MALLOC_CAP_8BIT));
// NOTE: same DST fall-back caveat as recompute_today for legacy grid. // NOTE: same DST fall-back caveat as recompute_today for legacy grid.
id(tomorrow_hourly_prices).assign(96, NAN); id(tomorrow_hourly_prices).assign(96, NAN);
for (int i = 0; i < n; i++) { for (int i = 0; i < n; i++) {
@@ -1681,47 +1805,68 @@ script:
float h_min_v = 9999.0f, h_max_v = -9999.0f; float h_min_v = 9999.0f, h_max_v = -9999.0f;
int h_min_i = 0, h_max_i = 0; int h_min_i = 0, h_max_i = 0;
double raw_sum = 0.0; int cnt_kwh = 0; double raw_sum = 0.0; int cnt_kwh = 0;
std::string json_h = "[";
// v1.2.2: Pre-allocate fixed buffer for JSON instead of string concatenation
// This eliminates O(n²) heap allocations from repeated += operations
char json_h_buf[400];
int json_h_len = 0;
json_h_buf[json_h_len++] = '[';
for (int i = 0; i < 24; i++) { for (int i = 0; i < 24; i++) {
if (h_cnt[i] > 0) { if (h_cnt[i] > 0) {
float ha = h_sums[i] / (float)h_cnt[i]; float ha = h_sums[i] / (float)h_cnt[i];
id(tomorrow_hourly_avg_prices_kwh)[i] = ha; id(tomorrow_hourly_avg_prices_kwh)[i] = ha;
if (ha < h_min_v) { h_min_v = ha; h_min_i = i; } if (ha < h_min_v) { h_min_v = ha; h_min_i = i; }
if (ha > h_max_v) { h_max_v = ha; h_max_i = i; } if (ha > h_max_v) { h_max_v = ha; h_max_i = i; }
char pb[12]; sprintf(pb, "%.4f", ha); json_h += pb; int written = snprintf(json_h_buf + json_h_len, sizeof(json_h_buf) - json_h_len, "%.4f", ha);
json_h_len += written;
raw_sum += ha; cnt_kwh++; raw_sum += ha; cnt_kwh++;
} else { } else {
id(tomorrow_hourly_avg_prices_kwh)[i] = NAN; id(tomorrow_hourly_avg_prices_kwh)[i] = NAN;
json_h += "null"; json_h_len += snprintf(json_h_buf + json_h_len, sizeof(json_h_buf) - json_h_len, "null");
} }
if (i < 23) json_h += ","; if (i < 23) {
json_h_buf[json_h_len++] = ',';
} }
json_h += "]"; }
id(json_tomorrow_hourly_prices_kwh).publish_state(json_h.c_str()); json_h_buf[json_h_len++] = ']';
json_h_buf[json_h_len] = '\0';
id(json_tomorrow_hourly_prices_kwh).publish_state(json_h_buf);
auto build32 = [&](int start) -> std::string { // v1.2.2: Optimized build32 using fixed buffer approach
std::string j = "["; // Note: id() returns pointer, so use auto (pointer type) with ->
auto build32_fixed = [&](int start, auto sensor) {
char buf[450];
int len = 0;
buf[len++] = '[';
for (int i = start; i < start + 32; i++) { for (int i = start; i < start + 32; i++) {
if (!std::isnan(v[i])) { if (!std::isnan(v[i])) {
char pb[16];
// Use 3 decimal places for negative prices to stay within // Use 3 decimal places for negative prices to stay within
// the 255-character HA text sensor state limit. // the 255-character HA text sensor state limit.
// Positive prices keep full 4 decimal place precision. // Positive prices keep full 4 decimal place precision.
if (v[i] < 0.0f) if (v[i] < 0.0f)
snprintf(pb, sizeof(pb), "%.3f", v[i]); len += snprintf(buf + len, sizeof(buf) - len, "%.3f", v[i]);
else else
snprintf(pb, sizeof(pb), "%.4f", v[i]); len += snprintf(buf + len, sizeof(buf) - len, "%.4f", v[i]);
j += pb;
} else { } else {
j += "null"; len += snprintf(buf + len, sizeof(buf) - len, "null");
} }
if (i < start + 31) j += ","; if (i < start + 31) {
buf[len++] = ',';
} }
j += "]"; return j; // Safety: prevent buffer overflow
if (len >= (int)sizeof(buf) - 20) break;
}
buf[len++] = ']';
buf[len] = '\0';
sensor->publish_state(buf);
}; };
id(json_tomorrow_15min_prices_kwh_p1_00_00_07_45).publish_state(build32(0));
id(json_tomorrow_15min_prices_kwh_p2_08_00_15_45).publish_state(build32(32)); build32_fixed(0, id(json_tomorrow_15min_prices_kwh_p1_00_00_07_45));
id(json_tomorrow_15min_prices_kwh_p3_16_00_23_45).publish_state(build32(64)); yield(); // v1.2.2: Yield between heavy operations
build32_fixed(32, id(json_tomorrow_15min_prices_kwh_p2_08_00_15_45));
yield(); // v1.2.2: Yield between heavy operations
build32_fixed(64, id(json_tomorrow_15min_prices_kwh_p3_16_00_23_45));
if (min_v < 9999.0f) { if (min_v < 9999.0f) {
id(tomorrow_min_price).publish_state(min_v); id(tomorrow_min_price).publish_state(min_v);
@@ -1752,6 +1897,9 @@ script:
id(tomorrow_current_price_status_str) = (cur >= 0) ? "Valid" : "Missing"; id(tomorrow_current_price_status_str) = (cur >= 0) ? "Valid" : "Missing";
id(tomorrow_last_update_attempt) = id(ha_time).now().timestamp; id(tomorrow_last_update_attempt) = id(ha_time).now().timestamp;
// v1.2.2: Log heap at end
ESP_LOGI("eprices", "recompute_tomorrow: done, free heap = %u bytes", heap_caps_get_free_size(MALLOC_CAP_8BIT));
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# MIDNIGHT BRIDGE # MIDNIGHT BRIDGE
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------