From bcf821e074768aac65db880648cd1d136cbbfef8 Mon Sep 17 00:00:00 2001 From: Gunnar Skjold Date: Wed, 2 Sep 2026 12:46:33 +0200 Subject: [PATCH 1/2] Publish fixed prices whether or not price fetching is enabled A fixed price is a price we always know, but publishing it to MQTT was tied to the dynamic price service in three ways: - The price point trigger in PriceService::loop() only fell back to the fixed price when fetching was disabled, so a device with fetching enabled and a failing price source never published its fixed price at all. - Day init returned without publishing, so a fixed price was not published until the next price point. - Publishing todays prices was suppressed if we were also about to fetch tomorrows, so a device that fetched today after 13:00 while tomorrow was unavailable withheld the prices it just got until the next price point. The handlers gated the whole payload on hasPrice(), which only looks at the current price point. A fixed price configured for a period or for certain hours of the day is unknown right now but known later, so nothing was published. They now gate on hasAnyPrice() and publish null for the points where a dynamic component is involved, and the Home Assistant discovery spans those holes out to the last point we know a price for. Home Assistant price sensors expired 300 seconds after their price point, which left no margin at 60 minute resolution: one missed publish took every price entity unavailable until the next hour. They now get a full extra price point of slack. Finally, disabling price fetching deleted the PriceService that also holds the fixed prices, and left EnergyAccounting with a dangling pointer to it. It is now kept alive and reconfigured, mirroring what setup() does at boot. Co-Authored-By: Claude Opus 5 --- src/AmsToMqttBridge.cpp | 8 ++++--- src/PriceService.cpp | 30 +++++++++++++++++++++++---- src/PriceService.h | 3 +++ src/mqtt/HomeAssistantMqttHandler.cpp | 25 +++++++++++----------- src/mqtt/JsonMqttHandler.cpp | 4 ++-- src/mqtt/RawMqttHandler.cpp | 4 ++-- 6 files changed, 50 insertions(+), 24 deletions(-) diff --git a/src/AmsToMqttBridge.cpp b/src/AmsToMqttBridge.cpp index 57889a5d..18c3dbce 100644 --- a/src/AmsToMqttBridge.cpp +++ b/src/AmsToMqttBridge.cpp @@ -1069,7 +1069,7 @@ void handleCustomMqtt() { debugE_P(PSTR("Custom MQTT connector reporting error (%d)"), err); customMqttHandler->connect(); customMqttHandler->publishSystem(&hw, ps, &ea); - if(ps != NULL && ps->hasPrice()) { + if(ps != NULL && ps->hasAnyPrice()) { customMqttHandler->publishPrices(ps); } } @@ -1451,7 +1451,7 @@ void handlePriceService(unsigned long now) { if(config.isPriceServiceChanged()) { PriceServiceConfig price; - if(config.getPriceServiceConfig(price) && price.enabled && strlen(price.area) > 0) { + if(config.getPriceServiceConfig(price)) { if(ps == NULL) { ps = new PriceService(&Debug); ea.setPriceService(ps); @@ -1462,11 +1462,13 @@ void handlePriceService(unsigned long now) { } #endif } + // Kept alive even when fetching is disabled, as it also holds the fixed prices ps->setup(price); } else if(ps != NULL) { delete ps; ps = NULL; ws.setPriceService(NULL); + ea.setPriceService(NULL); } ws.setPriceSettings(price.area, price.currency); config.ackPriceServiceChange(); @@ -1983,7 +1985,7 @@ void MQTT_connect() { mqttHandler->setDataStorage(&ds); mqttHandler->connect(); mqttHandler->publishSystem(&hw, ps, &ea); - if(ps != NULL && ps->hasPrice()) { + if(ps != NULL && ps->hasAnyPrice()) { mqttHandler->publishPrices(ps); } } diff --git a/src/PriceService.cpp b/src/PriceService.cpp index 3092dfc0..9e2dbe96 100644 --- a/src/PriceService.cpp +++ b/src/PriceService.cpp @@ -102,7 +102,7 @@ char* PriceService::getSource() { return this->today->getSource(); } else if(tomorrow != NULL) { return this->tomorrow->getSource(); - } else if(!this->config->enabled && this->priceConfig.capacity() != 0) { + } else if(hasFixedPrice()) { return "FIX"; // Fixed price } return ""; @@ -118,6 +118,27 @@ uint8_t PriceService::getNumberOfPointsAvailable() { return today->getNumberOfPoints(); } +bool PriceService::hasFixedPrice() { + for (uint8_t i = 0; i < priceConfig.size(); i++) { + if(priceConfig.at(i).type == PRICE_TYPE_FIXED) { + return true; + } + } + return false; +} + +int16_t PriceService::getLastKnownPricePoint(uint8_t direction) { + // Searching backwards, as the common case is that we know the price all the way + // to the end of the horizon and can return on the first probe. + uint8_t currentPricePointIndex = getCurrentPricePointIndex(); + for(int16_t point = getNumberOfPointsAvailable() - 1; point >= currentPricePointIndex; point--) { + if(getPricePoint(direction, point) != PRICE_NO_VALUE) { + return point; + } + } + return -1; +} + bool PriceService::isExportPricesDifferentFromImport() { for (uint8_t i = 0; i < priceConfig.size(); i++) { PriceConfig pc = priceConfig.at(i); @@ -282,6 +303,7 @@ bool PriceService::loop() { debugger->printf_P(PSTR("(PriceService) Day init\n")); currentDay = tm.Day; currentPricePoint = getCurrentPricePointIndex(); + return hasFixedPrice(); // Publish a fixed price right away, we have nothing to wait for } if(currentDay != tm.Day) { @@ -296,14 +318,14 @@ bool PriceService::loop() { } currentDay = tm.Day; currentPricePoint = getCurrentPricePointIndex(); - return today != NULL || (!config->enabled && priceConfig.capacity() != 0); // Only trigger MQTT publish if we have todays prices. + return today != NULL || hasFixedPrice(); // Only trigger MQTT publish if we have todays prices, or a fixed price to fall back on. } else if(currentPricePoint != getCurrentPricePointIndex()) { #if defined(AMS_REMOTE_DEBUG) if (debugger->isActive(RemoteDebug::INFO)) #endif debugger->printf_P(PSTR("(PriceService) Price point reset\n")); currentPricePoint = getCurrentPricePointIndex(); - return today != NULL || (!config->enabled && priceConfig.capacity() != 0); // Only trigger MQTT publish if we have todays prices. + return today != NULL || hasFixedPrice(); // Only trigger MQTT publish if we have todays prices, or a fixed price to fall back on. } if(!config->enabled) @@ -335,7 +357,7 @@ bool PriceService::loop() { today = NULL; } currentPricePoint = getCurrentPricePointIndex(); - return today != NULL && !readyToFetchForTomorrow; // Only trigger MQTT publish if we have todays prices and we are not immediately ready to fetch price for tomorrow. + return today != NULL; // Publish as soon as we have todays prices. Tomorrows fetch publishes again if it succeeds. } // Prices for next day are published at 13:00 CE(S)T, but to avoid heavy server traffic at that time, we will diff --git a/src/PriceService.h b/src/PriceService.h index 24d2c642..b5f1e026 100644 --- a/src/PriceService.h +++ b/src/PriceService.h @@ -87,6 +87,9 @@ class PriceService { bool hasPrice() { return hasPrice(PRICE_DIRECTION_IMPORT); } bool hasPrice(uint8_t direction) { return getCurrentPrice(direction) != PRICE_NO_VALUE; } bool hasPricePoint(uint8_t direction, int8_t point) { return getPricePoint(direction, point) != PRICE_NO_VALUE; } + bool hasAnyPrice() { return getLastKnownPricePoint(PRICE_DIRECTION_IMPORT) > -1 || getLastKnownPricePoint(PRICE_DIRECTION_EXPORT) > -1; } + bool hasFixedPrice(); + int16_t getLastKnownPricePoint(uint8_t direction); // Last point from the current one onwards that we know a price for, -1 if none float getCurrentPrice(uint8_t direction); float getPricePoint(uint8_t direction, uint8_t point); diff --git a/src/mqtt/HomeAssistantMqttHandler.cpp b/src/mqtt/HomeAssistantMqttHandler.cpp index 85d94cac..12e7e098 100644 --- a/src/mqtt/HomeAssistantMqttHandler.cpp +++ b/src/mqtt/HomeAssistantMqttHandler.cpp @@ -331,7 +331,7 @@ bool HomeAssistantMqttHandler::publishTemperatures(AmsConfiguration* config, HwT bool HomeAssistantMqttHandler::publishPrices(PriceService* ps) { if(pubTopic[0] == '\0' || !connected()) return false; - if(!ps->hasPrice()) + if(!ps->hasAnyPrice()) return false; publishPriceSensors(ps); @@ -347,7 +347,7 @@ bool HomeAssistantMqttHandler::publishPrices(PriceService* ps) { float val = ps->getPriceForRelativeHour(PRICE_DIRECTION_IMPORT, i); values[i] = val; - if(val == PRICE_NO_VALUE) break; + if(val == PRICE_NO_VALUE) continue; // A hole, the price for this hour depends on a dynamic price we do not have if(val < min) min = val; if(val > max) max = val; @@ -700,13 +700,14 @@ void HomeAssistantMqttHandler::publishPriceSensors(PriceService* ps) { } uint8_t currentPricePointIndex = ps->getCurrentPricePointIndex(); - uint8_t numberOfPoints = ps->getNumberOfPointsAvailable(); + // Discover sensors all the way out to the last point we know a price for. Points in + // between can still be unknown, if they depend on a dynamic price we do not have. + int16_t lastImportPoint = ps->getLastKnownPricePoint(PRICE_DIRECTION_IMPORT); + int16_t lastExportPoint = ps->getLastKnownPricePoint(PRICE_DIRECTION_EXPORT); - if(priceImportInit < numberOfPoints-currentPricePointIndex) { + if(priceImportInit < lastImportPoint-currentPricePointIndex+1) { uint8_t importPriceSensorNo = 0; - for(int pricePointIndex = currentPricePointIndex; pricePointIndex < numberOfPoints; pricePointIndex++) { - float val = ps->getPricePoint(PRICE_DIRECTION_IMPORT, pricePointIndex); - if(val == PRICE_NO_VALUE) break; + for(int pricePointIndex = currentPricePointIndex; pricePointIndex <= lastImportPoint; pricePointIndex++) { if(importPriceSensorNo < priceImportInit) { importPriceSensorNo++; continue; @@ -732,7 +733,7 @@ void HomeAssistantMqttHandler::publishPriceSensors(PriceService* ps) { importPriceSensorNo == 0 ? "Current import price" : name, "/prices", path, - resolution * 60 + 300, + resolution * 60 * 2 + 300, uom.c_str(), "monetary", importPriceSensorNo == 0 ? "total" : "", @@ -744,11 +745,9 @@ void HomeAssistantMqttHandler::publishPriceSensors(PriceService* ps) { } } - if(priceExportInit < numberOfPoints-currentPricePointIndex) { + if(priceExportInit < lastExportPoint-currentPricePointIndex+1) { uint8_t exportPriceSensorNo = 0; - for(int pricePointIndex = currentPricePointIndex; pricePointIndex < numberOfPoints; pricePointIndex++) { - float val = ps->getPricePoint(PRICE_DIRECTION_EXPORT, pricePointIndex); - if(val == PRICE_NO_VALUE) break; + for(int pricePointIndex = currentPricePointIndex; pricePointIndex <= lastExportPoint; pricePointIndex++) { if(exportPriceSensorNo < priceExportInit) { exportPriceSensorNo++; continue; @@ -774,7 +773,7 @@ void HomeAssistantMqttHandler::publishPriceSensors(PriceService* ps) { exportPriceSensorNo == 0 ? "Current export price" : name, "/prices", path, - resolution * 60 + 300, + resolution * 60 * 2 + 300, uom.c_str(), "monetary", exportPriceSensorNo == 0 ? "total" : "", diff --git a/src/mqtt/JsonMqttHandler.cpp b/src/mqtt/JsonMqttHandler.cpp index 3c9cc0ec..04c47679 100644 --- a/src/mqtt/JsonMqttHandler.cpp +++ b/src/mqtt/JsonMqttHandler.cpp @@ -305,7 +305,7 @@ bool JsonMqttHandler::publishTemperatures(AmsConfiguration* config, HwTools* hw) bool JsonMqttHandler::publishPrices(PriceService* ps) { if(strlen(mqttConfig.publishTopic) == 0 || !connected()) return false; - if(!ps->hasPrice()) + if(!ps->hasAnyPrice()) return false; time_t now = time(nullptr); @@ -319,7 +319,7 @@ bool JsonMqttHandler::publishPrices(PriceService* ps) { float val = ps->getPriceForRelativeHour(PRICE_DIRECTION_IMPORT, i); values[i] = val; - if(val == PRICE_NO_VALUE) break; + if(val == PRICE_NO_VALUE) continue; // A hole, the price for this hour depends on a dynamic price we do not have if(val < min) min = val; if(val > max) max = val; diff --git a/src/mqtt/RawMqttHandler.cpp b/src/mqtt/RawMqttHandler.cpp index 751a8ec2..bedc5659 100644 --- a/src/mqtt/RawMqttHandler.cpp +++ b/src/mqtt/RawMqttHandler.cpp @@ -243,7 +243,7 @@ bool RawMqttHandler::publishTemperatures(AmsConfiguration* config, HwTools* hw) bool RawMqttHandler::publishPrices(PriceService* ps) { if(topic.isEmpty() || !connected()) return false; - if(!ps->hasPrice()) + if(!ps->hasAnyPrice()) return false; time_t now = time(nullptr); @@ -258,7 +258,7 @@ bool RawMqttHandler::publishPrices(PriceService* ps) { values[i] = val; if(i > 23) continue; - if(val == PRICE_NO_VALUE) break; + if(val == PRICE_NO_VALUE) continue; // A hole, the price for this hour depends on a dynamic price we do not have if(val < min) min = val; if(val > max) max = val; From 6bd3357c11ee1dd7737191280b5818fd1573bc77 Mon Sep 17 00:00:00 2001 From: Gunnar Skjold Date: Wed, 2 Sep 2026 13:10:03 +0200 Subject: [PATCH 2/2] Skip price fetching when a fixed price covers the whole horizon When every hour of today and tomorrow is covered by a fixed price in both directions, the dynamic price cannot affect any price point, since getPricePoint() only falls back to it where no fixed price applies. Fetching it is then pure cost: a request, a possible error state and a red banner for a value that is never used. This is the configuration #1253 suggests solving by hand, by unticking the fetch option. isDynamicPriceNeeded() walks the 48 hours of today and tomorrow and asks whether a fixed price applies to each of them, accumulating the directions covered so that a split import/export configuration counts as covered. The horizon is anchored at the start of today rather than at the current price point, since the day cost is calculated backwards from midnight, and it only moves at midnight, so the answer is cached and invalidated on day change and whenever the price configuration is touched. A partial fixed price, for a period of the year or for certain hours, still needs the dynamic price and keeps fetching. A period boundary entering the horizon makes it start fetching a day ahead of needing it. Also widen getFixedPrice() to take a uint8_t point. getPricePoint() passes points up to 191 for a 15 minute two day horizon, and the int8_t parameter truncated everything from 128 up into a negative offset, so a partial fixed price resolved against a time before todays midnight for the back half of tomorrow. Co-Authored-By: Claude Opus 5 --- src/PriceService.cpp | 61 +++++++++++++++++++++++++++++++++++++++++++- src/PriceService.h | 7 ++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/PriceService.cpp b/src/PriceService.cpp index 9e2dbe96..1d0df789 100644 --- a/src/PriceService.cpp +++ b/src/PriceService.cpp @@ -73,6 +73,7 @@ void PriceService::setup(PriceServiceConfig& config) { #endif load(); + dynamicPriceNeedKnown = false; } void PriceService::setTimezone(Timezone* tz) { @@ -127,6 +128,47 @@ bool PriceService::hasFixedPrice() { return false; } +bool PriceService::isDynamicPriceNeeded() { + if(!dynamicPriceNeedKnown) { + dynamicPriceNeeded = calculateDynamicPriceNeed(); + dynamicPriceNeedKnown = true; + #if defined(AMS_REMOTE_DEBUG) + if (debugger->isActive(RemoteDebug::INFO)) + #endif + debugger->printf_P(PSTR("(PriceService) Dynamic price is %sneeded\n"), dynamicPriceNeeded ? "" : "not "); + } + return dynamicPriceNeeded; +} + +bool PriceService::calculateDynamicPriceNeed() { + if(!hasFixedPrice()) return true; + + time_t ts = time(nullptr); + tmElements_t tm; + breakTime(entsoeTz->toLocal(ts), tm); + tm.Hour = tm.Minute = tm.Second = 0; + time_t startOfDay = entsoeTz->toUTC(makeTime(tm)); + + // Fixed price periods have hour granularity, and we can be asked for any hour of + // today and tomorrow, so those 48 hours are the whole horizon. Anchored at the start + // of today rather than at the current point, as the day cost is calculated backwards + // from midnight. + for(uint8_t hour = 0; hour < 48; hour++) { + breakTime(tz->toLocal(startOfDay + (hour * SECS_PER_HOUR)), tm); + tm.Minute = tm.Second = 0; + + uint8_t covered = 0; + for(uint8_t i = 0; i < priceConfig.size(); i++) { + PriceConfig pc = priceConfig.at(i); + if(pc.type != PRICE_TYPE_FIXED) continue; + if(!timeIsInPeriod(tm, pc)) continue; + covered |= pc.direction; + } + if((covered & PRICE_DIRECTION_BOTH) != PRICE_DIRECTION_BOTH) return true; + } + return false; +} + int16_t PriceService::getLastKnownPricePoint(uint8_t direction) { // Searching backwards, as the common case is that we know the price all the way // to the end of the horizon and can return on the first probe. @@ -258,7 +300,7 @@ float PriceService::getPriceForRelativeHour(uint8_t direction, int8_t hour) { return valueSum / valueCount; } -float PriceService::getFixedPrice(uint8_t direction, int8_t point) { +float PriceService::getFixedPrice(uint8_t direction, uint8_t point) { time_t ts = time(nullptr); tmElements_t tm; @@ -302,6 +344,7 @@ bool PriceService::loop() { #endif debugger->printf_P(PSTR("(PriceService) Day init\n")); currentDay = tm.Day; + dynamicPriceNeedKnown = false; currentPricePoint = getCurrentPricePointIndex(); return hasFixedPrice(); // Publish a fixed price right away, we have nothing to wait for } @@ -317,6 +360,7 @@ bool PriceService::loop() { tomorrow = NULL; } currentDay = tm.Day; + dynamicPriceNeedKnown = false; currentPricePoint = getCurrentPricePointIndex(); return today != NULL || hasFixedPrice(); // Only trigger MQTT publish if we have todays prices, or a fixed price to fall back on. } else if(currentPricePoint != getCurrentPricePointIndex()) { @@ -331,6 +375,19 @@ bool PriceService::loop() { if(!config->enabled) return false; + // A fixed price covering every hour in both directions makes the dynamic price + // irrelevant, so do not spend requests on fetching it + if(!isDynamicPriceNeeded()) { + if(today != NULL || tomorrow != NULL) { + if(today != NULL) delete today; + if(tomorrow != NULL) delete tomorrow; + today = tomorrow = NULL; + lastTodayFetch = lastTomorrowFetch = 0; + } + lastError = 0; + return false; + } + #ifndef AMS2MQTT_PRICE_KEY if(strlen(getToken()) == 0) { return false; @@ -671,6 +728,7 @@ std::vector& PriceService::getPriceConfig() { } void PriceService::setPriceConfig(uint8_t index, PriceConfig &priceConfig) { + dynamicPriceNeedKnown = false; stripNonAscii((uint8_t*) priceConfig.name, 32, true); if(this->priceConfig.capacity() != index+1) @@ -682,6 +740,7 @@ void PriceService::setPriceConfig(uint8_t index, PriceConfig &priceConfig) { } void PriceService::cropPriceConfig(uint8_t size) { + dynamicPriceNeedKnown = false; this->priceConfig.resize(size); this->priceConfig.shrink_to_fit(); diff --git a/src/PriceService.h b/src/PriceService.h index b5f1e026..0256aaba 100644 --- a/src/PriceService.h +++ b/src/PriceService.h @@ -89,6 +89,7 @@ class PriceService { bool hasPricePoint(uint8_t direction, int8_t point) { return getPricePoint(direction, point) != PRICE_NO_VALUE; } bool hasAnyPrice() { return getLastKnownPricePoint(PRICE_DIRECTION_IMPORT) > -1 || getLastKnownPricePoint(PRICE_DIRECTION_EXPORT) > -1; } bool hasFixedPrice(); + bool isDynamicPriceNeeded(); // False when a fixed price covers the whole horizon, making the dynamic price irrelevant int16_t getLastKnownPricePoint(uint8_t direction); // Last point from the current one onwards that we know a price for, -1 if none float getCurrentPrice(uint8_t direction); @@ -135,11 +136,15 @@ class PriceService { int16_t lastError = 0; + bool dynamicPriceNeeded = true; + bool dynamicPriceNeedKnown = false; + bool calculateDynamicPriceNeed(); + PricesContainer* fetchPrices(time_t); bool retrieve(const char* url, Stream* doc); float getCurrencyMultiplier(const char* from, const char* to, time_t t); bool timeIsInPeriod(tmElements_t tm, PriceConfig pc); - float getFixedPrice(uint8_t direction, int8_t point); + float getFixedPrice(uint8_t direction, uint8_t point); float getEnergyPricePoint(uint8_t direction, uint8_t point); }; #endif