PRICE-01: price_service provider, fallback chain, and the gate read from it
checks / unit (pull_request) Successful in 39m31s
infra-pin / fresh (pull_request) Successful in 1s

A new price source, `price_provider: price_service`: the owner's price server,
one fixed HTTPS host, a bearer token (new password option
`price_service_token`), wholesale energy only so markup and taxes are added as
for generic_dayahead.

The chain is server, then energy-charts, then the prices the history store
kept. A step counts only if it has a price for now. Each step down and back up
is one log line. With every source down a site plans from what it saved.

No price for now: one status row, one Home plan line and one notification, in
English and Dutch, cleared when a price returns. With this provider the
maintenance charge gate reads the provider's prices and keeps the Home
Assistant rule: the current 15-minute price is at or below today's average.

The token is hidden on the settings page, refused when missing, and taken out
of any error text. Other providers, `off` and the Home Assistant pair take the
road they always took (scripted-day probe against 0ce7d35: one line differs,
the refusal that lists the known providers). See
DECISIONS/PRICE-01-own-price-server.md.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
glenn schrooyen
2026-10-14 16:22:24 +02:00
co-authored by Claude
parent 0ce7d35f80
commit 9ac3a9d2bc
21 changed files with 2169 additions and 32 deletions
+82 -1
View File
@@ -90,6 +90,7 @@ from .p1 import P1Ingest, build_source, is_enabled
from .price import Absence, Uncertainty, brussels, price_option, provider_configured
from .price import required_spread as price_required_spread
from .price import uncertainty_of as price_uncertainty_of
from .price_chain import PriceWatch, gate_for, uses_chain
from .price_history import PriceHistory
from .static_tariff import resolve_tariff, static_gated, static_status
from .tariff import PeakStore, PeakTracker, next_quarter
@@ -1202,6 +1203,16 @@ class Controller:
self.peak_fc = None
self.cheap = False
self._price_gated = False # MAINT-13: is a price gate set up
# PRICE-01. Which step of the price chain the prices come from (logged
# once per change), the last series that was published (so a gap can say
# since when), and the gap itself: True while a refresh has finished and
# no published price covers now. The notification is raised once per
# gap and taken down when it ends.
self.price_watch = PriceWatch()
self._last_published = None
self.price_gap = False
self.price_gap_since = None
self._price_gap_announced = False
self._gated_key = None # MAINT-13: (options, hour) of the cache
# TARIFF-03. One line of prose for the status page saying WHICH tariff
# window we are in and why it is or is not cheap - so "Cheap window:
@@ -3435,6 +3446,12 @@ class Controller:
self.cheap = (now_p is not None and avg_p is not None and now_p <= avg_p)
self._price_gated = True
self.tariff_words = "Prices from Home Assistant entities"
elif uses_chain(o):
# PRICE-01. A provider with fallbacks gives the maintenance gate
# its own prices: the current 15-minute price against today's
# average, as the Home Assistant pair did. Still no I/O here - the
# series is the one the background refresh stored.
self._provider_gate(datetime.now(timezone.utc))
else:
# TARIFF-03. §9.2 makes a static day/night tariff a first-class
# product, and its required EMS handling is TIME-WINDOW SHIFTING.
@@ -3998,6 +4015,35 @@ class Controller:
inv.refusal_note())]
if n]
def _provider_gate(self, now: datetime) -> None:
"""PRICE-01: `cheap`, the gate and the gap from the standing plan.
The gate exists whenever the provider does: no price for now is a
closed gate, so the charge waits and does not run unpriced. The gap
flag stays False until the first refresh has finished, so a start-up
is not a gap.
"""
plan = self.fresh_tariff_plan(now)
published = None if plan is None else plan.published
if published is not None:
self._last_published = published
gate = gate_for(published, now)
self.cheap, self._price_gated = gate.cheap, True
self.tariff_words = gate.words
self.price_gap = (not gate.covered) and self.tariff_at is not None
last = self._last_published
if gate.covered or last is None:
self.price_gap_since = None
elif last.covers(now):
# The last series still covers now, but no refresh has made it
# current for TARIFF_MAX_AGE_S: the refresh task has stopped. The
# gap began when the plan was retired.
self.price_gap_since = (
None if self.tariff_at is None
else self.tariff_at + timedelta(seconds=TARIFF_MAX_AGE_S))
else:
self.price_gap_since = last.last_end_before(now)
def _static_gated(self, o: dict, now: datetime) -> bool:
"""`static_gated()`, worked out once an hour: it builds the same
month-long plan as `static_status()` (about 20 ms with a profile), and
@@ -5536,6 +5582,11 @@ class Controller:
# is not, and test_freeze.py part 5 fails on exactly that.
await self.check_meter_frozen()
# --- PRICE-01: no price for now, said once and taken down once --------
# Not a safety check, so it is one of the cheap ones: an attribute test
# and, on a change only, one Home Assistant call.
await self.check_price_gap()
# --- SAFETY-31's alarm, and its boundary is the LATCH, not a number ---
# Unconstrained where the two above are not: it grades
# `self.circ_wd.tripped`, which was advanced near the top of this method
@@ -6152,6 +6203,33 @@ class Controller:
await self.hass.call("persistent_notification", "create",
messages.not_delivering(inv.name, slug).data())
async def check_price_gap(self) -> None:
"""PRICE-01. One notification when no price covers now, gone when one does.
`price_gap` is worked out on the tick from the stored plan. A gap
raises the notification once: the flag is set BEFORE the await, for
`check_circulating()`'s reason (a Home Assistant call can raise or
hang, and a retry every cycle would be a storm). When the gap ends the
same notification is taken down. Control is not touched either way: the
maintenance charge waits because its gate is shut (`_provider_gate`),
and nothing here decides that.
"""
if self.price_gap and not self._price_gap_announced:
self._price_gap_announced = True
await self.hass.call("persistent_notification", "create",
messages.no_prices(self._gap_text()).data())
elif not self.price_gap and self._price_gap_announced:
self._price_gap_announced = False
await self.hass.call("persistent_notification", "dismiss",
messages.prices_back().data())
def _gap_text(self) -> str:
"""`00:00` for a gap that began today, `13 Oct at 21:00` before that."""
if self.price_gap_since is None:
return ""
return messages.since_text(brussels(self.price_gap_since),
brussels(datetime.now(timezone.utc)))
async def check_meter_frozen(self) -> None:
"""SAFETY-18. Alarm on a meter that answers forever without changing.
@@ -6286,7 +6364,7 @@ class Controller:
try:
plan = await resolve_tariff(
self.o, now, now + timedelta(hours=horizon), now=now,
history=self.price_history)
history=self.price_history, watch=self.price_watch)
standing = self.fresh_tariff_plan(now)
# Three terms, and each of them is the only false one in some
# check: the feed answered (replace), nothing current stands
@@ -7363,6 +7441,9 @@ class Controller:
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
("Cheap window", "yes" if self.cheap else "no"),
("Tariff", self.tariff_words),
# PRICE-01: only while no price covers now.
*((("Prices", messages.row_no_prices(self._gap_text())),)
if self.price_gap else ()),
# FCST-04. WHAT §8.3 HAS TO CARRY FORWARD, which is the difference
# between a 48-hour plan and a 12-hour one on a weekday morning.
# It is its own row rather than a clause of "Tariff" because it