Skip to content

Publish fixed prices independently of the dynamic price service - #1260

Open
gskjold wants to merge 2 commits into
mainfrom
fix/fixed-price-publishing
Open

Publish fixed prices independently of the dynamic price service#1260
gskjold wants to merge 2 commits into
mainfrom
fix/fixed-price-publishing

Conversation

@gskjold

@gskjold gskjold commented Sep 2, 2026

Copy link
Copy Markdown
Member

Fixes #1253. Also fixes the withheld-publish behaviour seen in #1252, and means the price entities in #1249 already carry fixed price + modifiers when the server is down.

In #1253 the reporter has a fixed price (Norgespris) plus modifiers, with Enable price fetch from remote server still ticked. While the price server was down nothing was published to MQTT, even though the web dashboard showed the right price. The price error itself never gated publishing — the publish trigger did, in several ways that all bite exactly when the price source is unavailable.

What was wrong

1. The fixed-price fallback only applied when fetching was disabled. Both price-point triggers in PriceService::loop() returned

return today != NULL || (!config->enabled && priceConfig.capacity() != 0);

With fetching enabled but not delivering (today == NULL), that is false || (false && …). This is the reported bug: a fixed price plus modifiers and the fetch option ticked published nothing on the hour. Only the one-shot publish in MQTT_connect() got anything out, which is why a reboot did not help either — at that point hasPrice() was consulted before any fetch had happened. Both triggers now fall back on a new hasFixedPrice() regardless of config->enabled.

2. Day init published nothing. The currentDay == 0 branch fell through without signalling a publish, so a fixed price was not published until the next price point — up to an hour after boot.

3. Today's prices were withheld when tomorrow's were pending.

return today != NULL && !readyToFetchForTomorrow;

Rebooting after 13:00 while tomorrow is unavailable fetched today's prices successfully, showed them in the UI, and then withheld them from MQTT until the next price point — the #1252 symptom. The tomorrow fetch publishes again if it succeeds, so the extra condition only ever cost correctness.

4. Partial fixed prices were dropped entirely. The three handlers gated the whole payload on ps->hasPrice(), which only looks at the current price point. A fixed price configured for a period of the year or for certain hours of the day is unknown right now but known later, so nothing at all was published. They now gate on hasAnyPrice(), emit null for the points where a dynamic component is involved, and the min/max/cheapest-window scan skips holes instead of stopping at the first one. Home Assistant discovery had the same break and now spans holes out to the last point we know a price for, so those entities exist and read unknown until their slot arrives.

5. Home Assistant price sensors had no expiry margin. expire_after = resolution * 60 + 300 is 3900 s against an hourly publish cadence. One missed publish — an MQTT drop across the hour boundary — took every price entity unavailable for the rest of the hour. They now get a full extra price point of slack.

6. Disabling price fetching freed the fixed prices and left a dangling pointer. handlePriceService() deleted the PriceService when enabled == false, but that object also holds the fixed prices loaded from LittleFS, so following the advice in #1253 to untick the option stopped the fixed price working until the next reboot. It also cleared the pointer in AmsWebServer but not in EnergyAccounting, whose if(ps == NULL) return; guard in calcDayCost() therefore did not protect it. The service is now kept alive and reconfigured through setup() (which already discards cached dynamic prices and reloads the fixed ones), mirroring what boot does; the teardown path nulls EnergyAccounting's pointer too.

getSource() used the same !config->enabled condition and so reported no source instead of FIX when fetching was enabled but failing.

Not fetching what cannot matter

Rather than asking users to untick the box, the firmware now works out whether the dynamic price can affect anything. getPricePoint() only falls back to the dynamic price where no fixed price applies, so if every hour of today and tomorrow is covered by a fixed price in both directions, fetching is pure cost — a request, an error state and a red banner for a value that is never used.

isDynamicPriceNeeded() walks those 48 hours and accumulates which directions a fixed price covers, so a split import/export configuration counts as covered just like a single BOTH entry. When nothing is needed, loop() skips the fetch, discards any cached containers and clears lastError. The horizon is anchored at the start of today rather than at the current price point, because calcDayCost() reaches backwards to midnight; that means it only moves at midnight, so the answer is cached and invalidated on day change and whenever the price configuration is touched. Full 24-hour coverage is also what makes this DST-safe: a config that covers every hour stays covered whichever way the clocks move, and a partial one needs fetching anyway.

A partial fixed price keeps fetching, and a period boundary entering the horizon starts fetching a day before it is needed.

This also answers the objection raised in #1253 that users would lose the signal that the price server is down. Where the dynamic price genuinely matters, its absence is still visible: those points publish as null and the corresponding Home Assistant entities read unknown. The signal is only suppressed where there is nothing to signal, because no price depends on the server.

Also fixed

getFixedPrice() took an int8_t point while getPricePoint() passes points up to 191 for a 15-minute two-day horizon, so everything from 128 up truncated to a negative offset and a partial fixed price resolved against a time before today's midnight for the back half of tomorrow. Widened to uint8_t.

Behaviour after this change

Setup Before After
Fixed price + modifiers, fetching enabled, server down (#1253) Nothing published Hourly, plus at boot, and no fetch attempted
Fixed price, fetching disabled Hourly, nothing at boot Hourly, plus at boot
Fixed price for a period/hours, server down Nothing published Known slots published, null elsewhere, keeps fetching
Dynamic, today fetched, tomorrow 404 (#1252) Withheld until next price point Published immediately
Fetching disabled at runtime Fixed price lost, dangling pointer Keeps working

Verification

  • pio run — all six targets in default_envs build (esp8266dev, esp32dev, esp32s2dev, esp32solodev, esp32c3dev, esp32s3dev).
  • pio test -e native — 17/17 decoder tests pass.
  • ESP8266 flash: 1026723 → 1027567 bytes (+844), 16897 bytes headroom remaining.
  • No config struct layout change, so EEPROM_CHECK_SUM is untouched.

Worth a look on real hardware for the two transitions, which I could not exercise here: making a config fully fixed while dynamic prices are cached (containers are discarded, so the price point grid moves from 15 to 60 minutes and Home Assistant re-discovers), and a period boundary entering the horizon.

Known limitation, not addressed here

getResolutionInMinutes() returns 60 whenever today == NULL, so a fixed price is published on an hourly grid even in a 15-minute area, and Home Assistant sensors discovered before the first successful fetch carry hour-based display names. Changing the fallback to config->resolutionInMinutes would fix the naming but would also discover 192 entities for a price that never changes, and would need a null guard added in getPriceForRelativeHour(), which dereferences today in its non-60 branch. Left alone; the values are unaffected, as prices.import[n] is always n price points from now.

Unrelated and left alone: getCurrentPrice() has an unused ts local that the compiler already warns about.

🤖 Generated with Claude Code

gskjold and others added 2 commits September 2, 2026 12:46
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@gskjold gskjold changed the title Publish fixed prices whether or not price fetching is enabled Publish fixed prices independently of the dynamic price service Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

🔧 PR Build Artifacts

Version: 6bd3357

All environments built successfully. Download the zip files:

Artifacts expire after 7 days. View workflow run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Price Modifiers not exposed to HomeAssistant when prices are not available

1 participant