PRICE-01: price_service provider, fallback chain, and the gate read from it
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:
co-authored by
Claude
parent
0ce7d35f80
commit
9ac3a9d2bc
@@ -582,6 +582,8 @@ def build_plan(c, now, shadow, running, held=False, settled=True):
|
||||
cyc, waits = maintenance_runs(c, day0, day1, now, shadow)
|
||||
runs += cyc
|
||||
lines += waits
|
||||
if getattr(c, "price_gap", False):
|
||||
lines.append(line_no_prices(c.price_gap_since, now))
|
||||
runs += peak_run(c, day0, day1, now, shadow, limit_w)
|
||||
if c.p1_enabled:
|
||||
# The guard's ceiling: the cap, or this month's peak once above it.
|
||||
@@ -1268,6 +1270,13 @@ def line_cycle_waits(name) -> str:
|
||||
return _t("cycle_waits", name=name)
|
||||
|
||||
|
||||
def line_no_prices(since, now) -> str:
|
||||
"""PRICE-01: no price covers now. `since` is an instant or None."""
|
||||
if since is None:
|
||||
return _t("no_prices_yet")
|
||||
return _t("no_prices_since", at=messages.since_text(brussels(since), brussels(now)))
|
||||
|
||||
|
||||
def line_peak_limit(limit_w, shadow) -> str:
|
||||
return _w(shadow, "step_in", kw=_kw(limit_w))
|
||||
|
||||
@@ -1387,6 +1396,12 @@ def _samples() -> list:
|
||||
("plan", run_house_why()), ("plan", run_house_why(True)),
|
||||
("plan", run_house_amount()),
|
||||
("plan", line_cycle_waits("Battery B")),
|
||||
("plan", line_no_prices(None, datetime(2026, 10, 14, 9, 0,
|
||||
tzinfo=timezone.utc))),
|
||||
("plan", line_no_prices(datetime(2026, 10, 13, 22, 0,
|
||||
tzinfo=timezone.utc),
|
||||
datetime(2026, 10, 14, 9, 0,
|
||||
tzinfo=timezone.utc))),
|
||||
("plan", line_prices(0.16, {"eur_kwh": 0.14, "label": "13:00"},
|
||||
{"eur_kwh": 0.36, "label": "19:00"})),
|
||||
("plan", line_prices(None, None, None)),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -129,6 +129,8 @@ S_NOT_DELIVERING = "An inverter is not delivering"
|
||||
T_METER_FROZEN = "Meter reading may be stuck"
|
||||
T_MAINT_FAILED = "Battery cycle did not finish"
|
||||
T_MAINT_FAILED_NL = "Batterijcyclus niet afgerond"
|
||||
T_NO_PRICES = "No prices"
|
||||
T_NO_PRICES_NL = "Geen prijzen"
|
||||
|
||||
ID_LOOP_BOUND = "goodwe_integrator_bound"
|
||||
ID_OUTPUT_CLAMP = "goodwe_output_clamp"
|
||||
@@ -138,6 +140,7 @@ ID_CIRCULATING = "goodwe_circulating_power"
|
||||
ID_NOT_DELIVERING = "goodwe_not_delivering_{slug}"
|
||||
ID_METER_FROZEN = "goodwe_meter_frozen"
|
||||
ID_MAINT_FAILED = "goodwe_maint_failed_{slug}"
|
||||
ID_NO_PRICES = "goodwe_no_prices"
|
||||
|
||||
# Which limit cut a command (clamp.ClampResult.bound, plus the loop's own).
|
||||
# The setting is named by its label, "Inverter rating (W)".
|
||||
@@ -314,6 +317,51 @@ def maint_failed(kind: str, days: int, unit: str = "", slug: str = "") -> Notice
|
||||
T_MAINT_FAILED, en, body_nl=f"{T_MAINT_FAILED_NL}. {nl}")
|
||||
|
||||
|
||||
def since_text(since, now) -> str:
|
||||
"""PRICE-01: `00:00` for a moment today, `13 Oct at 21:00` for an earlier
|
||||
day. Both arguments are already in the site's zone. Empty for no moment."""
|
||||
if since is None:
|
||||
return ""
|
||||
return f"{since:%H:%M}" if since.date() == now.date() else when(since)
|
||||
|
||||
|
||||
def no_prices(since_text: str = "") -> Notice:
|
||||
"""PRICE-01: no source has a price for now. Said once, gone with the gap."""
|
||||
if since_text:
|
||||
en = (f"No prices since {since_text}, so the monthly battery cycle "
|
||||
f"waits until they return.")
|
||||
nl = (f"Geen prijzen sinds {since_text}, dus de maandelijkse "
|
||||
f"batterijcyclus wacht tot ze terug zijn.")
|
||||
else:
|
||||
en = ("No prices have come in yet, so the monthly battery cycle waits "
|
||||
"until they do.")
|
||||
nl = ("Er zijn nog geen prijzen binnen, dus de maandelijkse "
|
||||
"batterijcyclus wacht tot ze er zijn.")
|
||||
return Notice(ID_NO_PRICES, WARNING, T_NO_PRICES, en,
|
||||
body_nl=f"{T_NO_PRICES_NL}. {nl}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Dismiss:
|
||||
"""The payload that takes one notification down again."""
|
||||
id: str
|
||||
|
||||
def data(self) -> dict:
|
||||
return {"notification_id": self.id}
|
||||
|
||||
|
||||
def prices_back() -> Dismiss:
|
||||
"""PRICE-01: the 'No prices' notification, taken down when a price is back."""
|
||||
return Dismiss(ID_NO_PRICES)
|
||||
|
||||
|
||||
def row_no_prices(since_text: str = "") -> str:
|
||||
"""PRICE-01: the status row while no price covers now."""
|
||||
if since_text:
|
||||
return f"No prices since {since_text}, maintenance waits."
|
||||
return "No prices yet, maintenance waits."
|
||||
|
||||
|
||||
def row_maint_waits(until_text: str) -> str:
|
||||
"""MAINT-13: the status row while a failed cycle waits."""
|
||||
return f"Waits until {until_text} after a cycle that did not finish."
|
||||
@@ -1186,6 +1234,9 @@ def status_samples() -> list:
|
||||
True)),
|
||||
("row_circulating_check", GREEN, row_circulating_check(0, 0, 60)),
|
||||
("row_maint_waits", AMBER, row_maint_waits("2026-10-13")),
|
||||
("row_no_prices", AMBER, row_no_prices("00:00")),
|
||||
("row_no_prices", AMBER, row_no_prices("13 Oct at 21:00")),
|
||||
("row_no_prices", AMBER, row_no_prices()),
|
||||
]
|
||||
|
||||
|
||||
@@ -1218,6 +1269,9 @@ def samples() -> list:
|
||||
meter_frozen(90),
|
||||
maint_failed("charge", 7),
|
||||
maint_failed("drain", 7, "inverter-b", "inverter_b"),
|
||||
no_prices("00:00"),
|
||||
no_prices("13 Oct at 21:00"),
|
||||
no_prices(),
|
||||
]
|
||||
|
||||
|
||||
@@ -1250,6 +1304,7 @@ LABELS = {
|
||||
"price_formula": "Your own tariff formula",
|
||||
"price_export_formula": "Your own injection formula",
|
||||
"price_csv_path": "Price CSV file",
|
||||
"price_service_token": "Price service token",
|
||||
"price_now_entity": "Current price entity",
|
||||
"price_avg_entity": "Average price entity",
|
||||
"arbitrage_enabled": "Trade on the price spread",
|
||||
|
||||
@@ -750,6 +750,18 @@ class _Series(PriceRead):
|
||||
except Absence:
|
||||
return False
|
||||
|
||||
def last_end_before(self, instant: datetime):
|
||||
"""When the last price that has run out by `instant` stopped covering,
|
||||
or None if no price has (PRICE-01: "no prices since 00:00").
|
||||
|
||||
Each point's end is the one `_cover_table()` worked out, so a hole in
|
||||
the series is reported from where the hole starts and not from the end
|
||||
of the day.
|
||||
"""
|
||||
u = instant.astimezone(timezone.utc)
|
||||
ends = [end for _start, end, _p in self._cover if end <= u]
|
||||
return max(ends) if ends else None
|
||||
|
||||
def window(self, since: datetime, until: datetime) -> tuple:
|
||||
"""Points whose START falls in [since, until). May legitimately be empty.
|
||||
|
||||
@@ -906,6 +918,10 @@ PRICE_OPTION_DEFAULTS = {
|
||||
"export_price_eur_kwh": 0.04, # §14 tariffs.export_price_eur_kwh (AC4)
|
||||
"price_csv_path": "/share/goodwe_prices.csv",
|
||||
"price_feed_url": "",
|
||||
# PRICE-01. The bearer token of the owner's price server. A secret: it is
|
||||
# in SECRET_OPTIONS below, so no text built from an error reaches a log,
|
||||
# the status page or a notification with it in.
|
||||
"price_service_token": "",
|
||||
"price_horizon_h": 48,
|
||||
"price_timeout_s": 10,
|
||||
# TARIFF-03. §14 `tariffs.static_profile: null`. A JSON object describing a
|
||||
@@ -992,6 +1008,26 @@ FRAMEWORK_KEYS = frozenset({
|
||||
})
|
||||
|
||||
|
||||
# PRICE-01. Options whose VALUE must never be printed. `scrub()` removes them
|
||||
# from any text the price code builds out of an error.
|
||||
SECRET_OPTIONS = frozenset({"price_service_token"})
|
||||
|
||||
|
||||
def scrub(text, opts) -> str:
|
||||
"""`text` with every secret option's value replaced by `***`.
|
||||
|
||||
The price code builds reasons out of library errors, and a library may put
|
||||
a request header into its error text. Taking the value out here is the
|
||||
last line of defence; the plugin also never puts it in a message itself.
|
||||
"""
|
||||
out = str(text)
|
||||
for key in SECRET_OPTIONS:
|
||||
secret = str((opts or {}).get(key) or "").strip()
|
||||
if len(secret) >= 4:
|
||||
out = out.replace(secret, "***")
|
||||
return out
|
||||
|
||||
|
||||
class PluginOptions:
|
||||
"""The options dict, minus the keys only the framework may read.
|
||||
|
||||
@@ -1216,6 +1252,13 @@ class PriceProvider(ABC):
|
||||
|
||||
id = "?"
|
||||
|
||||
# PRICE-01. The ids to try, in order, when this source cannot answer, and
|
||||
# whether the last answer is kept for a day without any source. Empty and
|
||||
# False for every plugin that existed before, which is what keeps them
|
||||
# exactly as they were: `price_chain.py` is not used for them at all.
|
||||
FALLBACKS: tuple = ()
|
||||
KEEPS_LAST_GOOD: bool = False
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def capabilities(cls) -> ProviderCapabilities:
|
||||
@@ -1533,19 +1576,54 @@ def assemble(caps: ProviderCapabilities, raw, markup: float,
|
||||
# Transport - one place, HTTPS only, host checked against the declaration
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
async def https_get(url: str, timeout_s: float) -> bytes:
|
||||
async def https_get(url: str, timeout_s: float, headers=None) -> bytes:
|
||||
"""The default transport. §16: outbound is allow-listed and TLS'd.
|
||||
|
||||
Imported lazily so `manual_csv` - the offline fallback - never needs aiohttp
|
||||
to be importable at all. AC3 says it works fully offline; that includes a
|
||||
machine where the HTTP stack is missing.
|
||||
|
||||
PRICE-01. `headers` is None for every plugin but `price_service`, and with
|
||||
None this is the code it has always been, byte for byte. With headers it
|
||||
answers in the SAFE words of `status_words()` and never lets a library
|
||||
error through: an error from a request that carried a token is the one
|
||||
place a token could be printed by somebody else's code.
|
||||
"""
|
||||
import aiohttp
|
||||
t = aiohttp.ClientTimeout(total=float(timeout_s))
|
||||
async with aiohttp.ClientSession(timeout=t) as s:
|
||||
async with s.get(url) as r:
|
||||
r.raise_for_status()
|
||||
return await r.read()
|
||||
if headers is None:
|
||||
async with aiohttp.ClientSession(timeout=t) as s:
|
||||
async with s.get(url) as r:
|
||||
r.raise_for_status()
|
||||
return await r.read()
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=t) as s:
|
||||
async with s.get(url, headers=headers) as r:
|
||||
if r.status != 200:
|
||||
raise SourceUnavailable(status_words(r.status))
|
||||
return await r.read()
|
||||
except SourceUnavailable:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise SourceUnavailable(
|
||||
f"the price service did not answer ({type(e).__name__})")
|
||||
|
||||
|
||||
# PRICE-01. The one phrase a refused token is reported with. The fallback chain
|
||||
# reads it to tell "the owner must act" from "the server is down", so it is a
|
||||
# constant both sides import.
|
||||
AUTH_REFUSED = "the price service refused the token"
|
||||
|
||||
|
||||
def status_words(status: int) -> str:
|
||||
"""What an HTTP status from the price service is called. No header text."""
|
||||
if status in (401, 403):
|
||||
return f"{AUTH_REFUSED} ({status})"
|
||||
if status == 429:
|
||||
return "the price service says too many requests (429)"
|
||||
if status >= 500:
|
||||
return f"the price service has an error ({status})"
|
||||
return f"the price service answered {status}"
|
||||
|
||||
|
||||
def check_egress(url: str, caps: ProviderCapabilities) -> str:
|
||||
@@ -1870,6 +1948,91 @@ class GenericDayAhead(PriceProvider):
|
||||
provisional_default=False)
|
||||
|
||||
|
||||
@register
|
||||
class PriceService(PriceProvider):
|
||||
"""The owner's own price server (PRICE-01), COLUMNAR, in EUR/MWh.
|
||||
|
||||
SOURCES: energy
|
||||
The framework ADDS: markup, taxes
|
||||
|
||||
The server answers the energy-charts layout (`unix_seconds`, `price`,
|
||||
`unit`) plus `covers`, the complete Brussels days it holds. The price is
|
||||
the wholesale energy price and nothing else, so markup and taxes are added
|
||||
by the framework exactly as for `generic_dayahead`.
|
||||
|
||||
The host is fixed here, HTTPS only, and a bearer token (the option
|
||||
`price_service_token`) goes in the `Authorization` header. The token is not
|
||||
put into any message this class builds.
|
||||
|
||||
`FALLBACKS` names the next source down. `app/price_chain.py` walks it and
|
||||
then the stored prices; this class only fetches.
|
||||
"""
|
||||
|
||||
id = "price_service"
|
||||
|
||||
HOST = "prices.kammenstraatha.duckdns.org"
|
||||
URL = "https://prices.kammenstraatha.duckdns.org/v1/dayahead/BE"
|
||||
FALLBACKS = ("generic_dayahead",)
|
||||
KEEPS_LAST_GOOD = True
|
||||
|
||||
@classmethod
|
||||
def capabilities(cls):
|
||||
return ProviderCapabilities(
|
||||
product=PriceProduct.DAY_AHEAD,
|
||||
sources=frozenset({Component.ENERGY}),
|
||||
egress_hosts=(cls.HOST,))
|
||||
|
||||
async def fetch(self, opts, since, until, transport):
|
||||
token = str(opts.get("price_service_token") or "").strip()
|
||||
if not token:
|
||||
raise SourceUnavailable("no token is set for the price service")
|
||||
if any(c.isspace() or not c.isprintable() for c in token):
|
||||
raise SourceUnavailable(
|
||||
"the price service token has a space or a line break in it")
|
||||
check_egress(self.URL, self.capabilities())
|
||||
try:
|
||||
body = await transport(
|
||||
self.URL, float(opts.get("price_timeout_s")),
|
||||
headers={"Authorization": "Bearer " + token})
|
||||
except SourceUnavailable:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise SourceUnavailable(
|
||||
f"the price service did not answer ({type(e).__name__})")
|
||||
points = _points_from_columnar_json(body, expect_unit="EUR / MWh",
|
||||
scale=0.001,
|
||||
provisional_default=False)
|
||||
covers = _covered_days(body)
|
||||
if covers is None:
|
||||
return points
|
||||
whole = [p for p in points
|
||||
if brussels(p.start).date().isoformat() in covers]
|
||||
if not whole:
|
||||
raise SourceUnavailable(
|
||||
"the price service sent no complete day")
|
||||
return whole
|
||||
|
||||
|
||||
def _covered_days(body):
|
||||
"""The `covers` list of a price service answer as a set, or None if absent.
|
||||
|
||||
A day the server does not list as complete is not used: an hour missing
|
||||
inside a day is a hole, and a hole is not a free hour.
|
||||
"""
|
||||
try:
|
||||
text = body.decode("utf-8") if isinstance(body, (bytes, bytearray)) \
|
||||
else str(body)
|
||||
covers = json.loads(text).get("covers")
|
||||
except (ValueError, UnicodeDecodeError, AttributeError):
|
||||
return None
|
||||
if covers is None:
|
||||
return None
|
||||
if not isinstance(covers, list) or \
|
||||
not all(isinstance(d, str) for d in covers):
|
||||
raise SourceUnavailable("the price service sent a bad list of days")
|
||||
return set(covers)
|
||||
|
||||
|
||||
@register
|
||||
class ManualCsv(PriceProvider):
|
||||
"""A file on disk. AC3: the documented fallback, fully offline.
|
||||
@@ -2169,9 +2332,10 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
|
||||
except FrameworkOption:
|
||||
raise # a plugin bug, not a site condition
|
||||
except SourceUnavailable as e:
|
||||
return PriceRead.no_data(f"{pid}: {e}")
|
||||
return PriceRead.no_data(scrub(f"{pid}: {e}", opts))
|
||||
except Exception as e: # noqa: BLE001 - see the docstring
|
||||
return PriceRead.no_data(f"{pid}: {type(e).__name__}: {e}")
|
||||
return PriceRead.no_data(
|
||||
scrub(f"{pid}: {type(e).__name__}: {e}", opts))
|
||||
|
||||
_emit_startup_preview(plugin, pid, points)
|
||||
return PriceRead.series(points, caps.product, pid,
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""PRICE-01 - where the prices come from when the first source does not answer.
|
||||
|
||||
A provider that names `FALLBACKS` (app/price.py) is read through this module
|
||||
instead of straight from `load_prices()`. The order is fixed:
|
||||
|
||||
1. the provider itself (the owner's price server)
|
||||
2. each id in its `FALLBACKS` (energy-charts.info)
|
||||
3. the prices the history store kept from the last fetch that worked
|
||||
|
||||
A step counts only if its answer holds a price for NOW. A server that answers
|
||||
200 with yesterday's prices is as useless to a battery as one that is down, so
|
||||
it is skipped, with the same log line.
|
||||
|
||||
Every other provider never comes here (`uses_chain()` is False for it), so
|
||||
`generic_dayahead`, `manual_csv`, `custom_formula` and `off` take the road they
|
||||
always took.
|
||||
|
||||
THE GATE. `gate_for()` is the maintenance charge rule for a site on such a
|
||||
provider: the current 15-minute price is at or below today's average. It is the
|
||||
rule the Home Assistant pair (`price_now_entity`, `price_avg_entity`) gave, read
|
||||
from the provider's own prices, so moving a site from the pair to the provider
|
||||
does not change WHEN the charge runs. DECISIONS/PRICE-01-own-price-server.md
|
||||
says how equal, and where it is not.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from .price import (AUTH_REFUSED, PROVIDERS, PriceRead, brussels,
|
||||
provider_id, scrub)
|
||||
|
||||
_LOG = logging.getLogger("goodwe.price.chain")
|
||||
|
||||
# The four places a plan can come from, best first. A higher number is further
|
||||
# down the chain.
|
||||
STEP_OWN, STEP_PUBLIC, STEP_STORED, STEP_NONE = 0, 1, 2, 3
|
||||
|
||||
# What a step is called in a status line.
|
||||
SOURCE_WORDS = {
|
||||
"price_service": "the price service",
|
||||
"generic_dayahead": "energy-charts",
|
||||
"price_history": "saved prices",
|
||||
}
|
||||
|
||||
|
||||
def uses_chain(opts: dict) -> bool:
|
||||
"""Does the selected provider ask for the fallback chain?"""
|
||||
cls = PROVIDERS.get(provider_id(opts))
|
||||
return bool(cls is not None and (cls.FALLBACKS or cls.KEEPS_LAST_GOOD))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Resolved:
|
||||
"""What the chain found. `published` is the series BEFORE the yesterday
|
||||
fill and the window, which is what the day's average has to be read from:
|
||||
the plan itself starts at the current hour."""
|
||||
|
||||
read: PriceRead
|
||||
step: int
|
||||
reasons: tuple = ()
|
||||
|
||||
@property
|
||||
def published(self):
|
||||
return self.read.match(series=lambda r: r, no_data=lambda _r: None)
|
||||
|
||||
|
||||
class PriceWatch:
|
||||
"""Which step the prices come from, and one log line per change of it.
|
||||
|
||||
The line is written when the step CHANGES and not before. Nothing repeats
|
||||
while a source stays down, and nothing is written for a refresh that
|
||||
changes nothing. The reasons (a status code, a timeout) go on a DEBUG line
|
||||
beside it, with the token taken out.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.step = None
|
||||
self.auth_refused = False
|
||||
|
||||
def note(self, step: int, reasons=(), opts=None) -> None:
|
||||
old, self.step = self.step, step
|
||||
self.auth_refused = any(AUTH_REFUSED in r for r in reasons)
|
||||
if old == step:
|
||||
return
|
||||
if reasons:
|
||||
_LOG.debug("Price sources said: %s.",
|
||||
scrub("; ".join(reasons), opts))
|
||||
if step == STEP_OWN:
|
||||
if old is None:
|
||||
_LOG.info("Prices come from the price service.")
|
||||
else:
|
||||
_LOG.info("The price service answers again.")
|
||||
elif step == STEP_PUBLIC:
|
||||
if old is not None and old > step:
|
||||
_LOG.info("Prices come from energy-charts again.")
|
||||
elif self.auth_refused:
|
||||
_LOG.warning("The price service refused the token, so prices "
|
||||
"come from energy-charts.")
|
||||
else:
|
||||
_LOG.warning("The price service does not answer, so prices "
|
||||
"come from energy-charts.")
|
||||
elif step == STEP_STORED:
|
||||
_LOG.warning("No price source answers, so the add-on uses the "
|
||||
"prices it saved.")
|
||||
else:
|
||||
_LOG.warning("No price source answers, and no saved price covers "
|
||||
"now.")
|
||||
|
||||
|
||||
async def resolve(opts: dict, since: datetime, until: datetime, transport,
|
||||
history, stamp: datetime, watch: PriceWatch,
|
||||
load) -> Resolved:
|
||||
"""Walk the chain and return the first step that has a price for `stamp`.
|
||||
|
||||
`load` is the caller's `load_prices`, passed in so that `static_tariff.py`
|
||||
stays the one module that fetches prices. Never raises
|
||||
for a source condition: a read that gets nowhere is a `no_data` whose
|
||||
reason names every step, so the §11.4 row applies exactly as for a single
|
||||
provider that failed.
|
||||
"""
|
||||
pid = provider_id(opts)
|
||||
cls = PROVIDERS[pid]
|
||||
reasons = []
|
||||
for step, sid in enumerate((pid,) + tuple(cls.FALLBACKS)):
|
||||
# A fallback is asked for ITS OWN feed. The operator's feed override is
|
||||
# for the first provider and would send energy-charts' request to a
|
||||
# URL it was never meant for.
|
||||
o = opts if step == 0 else dict(opts, price_provider=sid,
|
||||
price_feed_url="")
|
||||
read = await load(o, since, until, transport)
|
||||
if read.match(series=lambda r: r.covers(stamp), no_data=lambda _r: False):
|
||||
# One fallback is shipped. Any further one would also be "public".
|
||||
where = STEP_OWN if step == 0 else STEP_PUBLIC
|
||||
watch.note(where, reasons, opts)
|
||||
return Resolved(read, where, tuple(reasons))
|
||||
reasons.append(read.match(
|
||||
series=lambda r: f"{sid}: it has no price for now",
|
||||
no_data=lambda r: r.reason))
|
||||
if cls.KEEPS_LAST_GOOD:
|
||||
kept = history.stored(stamp)
|
||||
if kept.match(series=lambda r: r.covers(stamp),
|
||||
no_data=lambda _r: False):
|
||||
watch.note(STEP_STORED, reasons, opts)
|
||||
return Resolved(kept, STEP_STORED, tuple(reasons))
|
||||
reasons.append(kept.match(
|
||||
series=lambda r: "price_history: it has no price for now",
|
||||
no_data=lambda r: r.reason))
|
||||
watch.note(STEP_NONE, reasons, opts)
|
||||
# The reason is short on purpose: it is printed on the status page and in
|
||||
# a WARNING every refresh. What each step said is on the DEBUG line.
|
||||
return Resolved(PriceRead.no_data(
|
||||
"no price source has a price for now"), STEP_NONE,
|
||||
tuple(scrub(r, opts) for r in reasons))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The maintenance charge gate, read from the provider's prices
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Gate:
|
||||
"""What the 30 s tick takes from the standing plan.
|
||||
|
||||
`covered` is whether a published price covers now. When it is False,
|
||||
`cheap` is False and the charge waits: no price is not a cheap price.
|
||||
`since` is when the cover ended, for the status line, or None when nothing
|
||||
was ever published.
|
||||
"""
|
||||
|
||||
cheap: bool
|
||||
covered: bool
|
||||
since: datetime | None
|
||||
words: str
|
||||
|
||||
|
||||
NO_PLAN = Gate(False, False, None, "No prices yet")
|
||||
|
||||
|
||||
def _round5(value: float) -> float:
|
||||
# The Home Assistant template rounded the average to 5 decimals, and a
|
||||
# price to 5 as well. Both sides are compared as it did, so a quarter that
|
||||
# sits within 0.00001 EUR/kWh of the average gets the same answer.
|
||||
return round(float(value), 5)
|
||||
|
||||
|
||||
def gate_for(published, now: datetime) -> Gate:
|
||||
"""The charge gate for `now`, from the series the chain published.
|
||||
|
||||
THE RULE (owner's decision, PRICE-01): the current 15-minute price is at or
|
||||
below the average of today's prices, today being the Brussels day. This is
|
||||
what `now_p <= avg_p` was on the Home Assistant pair. It is NOT
|
||||
`TariffPlan.cheap_now()`, which is strictly below the mean of the firm
|
||||
hours of the whole plan, and it is not meant to be.
|
||||
|
||||
`published` is a series or None. With no price for now there is no gate
|
||||
answer: the charge waits.
|
||||
"""
|
||||
if published is None:
|
||||
return NO_PLAN
|
||||
if not published.covers(now):
|
||||
return Gate(False, False, published.last_end_before(now),
|
||||
"No price for now")
|
||||
today = brussels(now).date()
|
||||
todays = [p.eur_per_kwh for p in published.points
|
||||
if not p.provisional and brussels(p.start).date() == today]
|
||||
here = published.at(now)
|
||||
if here.provisional or not todays:
|
||||
return Gate(False, False, published.last_end_before(now),
|
||||
"No price for now")
|
||||
avg = sum(todays) / len(todays)
|
||||
cheap = _round5(here.eur_per_kwh) <= _round5(avg)
|
||||
source = SOURCE_WORDS.get(published.provider, published.provider)
|
||||
return Gate(cheap, True, None,
|
||||
f"Prices from {source}, {here.eur_per_kwh:.4f} EUR/kWh now, "
|
||||
f"{'cheap' if cheap else 'not cheap'} against today's average "
|
||||
f"of {avg:.4f}")
|
||||
@@ -581,7 +581,7 @@ class PriceHistory:
|
||||
|
||||
# -- the one public entry point -----------------------------------------
|
||||
def carry(self, read: PriceRead, *, since: datetime, until: datetime,
|
||||
stamp: datetime) -> PriceRead:
|
||||
stamp: datetime, remember: bool = True) -> PriceRead:
|
||||
"""Remember what this read published, then §8.3-fill the missing hours.
|
||||
|
||||
THE ORDER IS DELIBERATE AND IS THE POINT OF THE METHOD. The read is
|
||||
@@ -599,7 +599,11 @@ class PriceHistory:
|
||||
source can price is left out (§11.4 for that hour), and `no_data` comes
|
||||
back when that is every hour.
|
||||
"""
|
||||
self._remember(read, stamp)
|
||||
if remember:
|
||||
# PRICE-01: False only for a read that CAME FROM this store
|
||||
# (`stored()`), which is already filed; filing it again would
|
||||
# rewrite the same file for nothing, and nothing else may pass it.
|
||||
self._remember(read, stamp)
|
||||
return fill_from_yesterday(read, yesterday=self.yesterday(stamp),
|
||||
since=since, until=until, stamp=stamp)
|
||||
|
||||
@@ -798,6 +802,37 @@ class PriceHistory:
|
||||
str(rec.get("provider") or "price_history"),
|
||||
stamp)
|
||||
|
||||
def stored(self, stamp: datetime) -> PriceRead:
|
||||
"""PRICE-01: every price this store holds, as one series, or no_data.
|
||||
|
||||
This is the LAST step of the price server's fallback (price_chain.py):
|
||||
the answer of the last fetch that worked, kept on disk, so a site can
|
||||
plan for as long as the days it holds last. It is a read of what was
|
||||
PUBLISHED and filed, in the order they happened, and it is never the
|
||||
carried fill (rule 1 holds: `carry(..., remember=False)` is how this
|
||||
read is filled, and it is not filed again).
|
||||
|
||||
The prices are the all-in ones stored when they were fetched. They are
|
||||
not worked out again, so a change of markup or taxes shows from the
|
||||
next fetch that works, not in these.
|
||||
"""
|
||||
if stamp.tzinfo is None or stamp.utcoffset() is None:
|
||||
raise ValueError(f"stored({stamp!r}): a naive datetime is not an "
|
||||
f"instant")
|
||||
points = []
|
||||
for day in sorted(self._days):
|
||||
rec = self._days[day]
|
||||
got, _bad = _parse_points(rec.get("points") if isinstance(
|
||||
rec, dict) else None)
|
||||
points.extend(got)
|
||||
if not points:
|
||||
why = self.last_error or "it holds no prices"
|
||||
return PriceRead.no_data(
|
||||
f"the price history at {self.path} gives nothing: {why}")
|
||||
points.sort(key=lambda p: p.start.astimezone(timezone.utc))
|
||||
return PriceRead.series(points, PriceProduct.DAY_AHEAD,
|
||||
"price_history", stamp)
|
||||
|
||||
# -- observability -------------------------------------------------------
|
||||
@property
|
||||
def words(self) -> str:
|
||||
@@ -873,9 +908,14 @@ class NoHistory:
|
||||
last_error = "no history is kept by this caller"
|
||||
words = "not kept"
|
||||
|
||||
def carry(self, read: PriceRead, *, since, until, stamp) -> PriceRead:
|
||||
def carry(self, read: PriceRead, *, since, until, stamp,
|
||||
remember: bool = True) -> PriceRead:
|
||||
return read
|
||||
|
||||
def stored(self, stamp: datetime) -> PriceRead:
|
||||
return PriceRead.no_data("this caller keeps no price history, so "
|
||||
"there are no stored prices")
|
||||
|
||||
def yesterday(self, stamp: datetime) -> PriceRead:
|
||||
return PriceRead.no_data(
|
||||
"this caller keeps no price history, so there are no prices from "
|
||||
|
||||
@@ -204,12 +204,13 @@ import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from . import messages
|
||||
from . import messages, price_chain
|
||||
from .price import (
|
||||
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
|
||||
Uncertainty, _Outcome, brussels, load_prices, price_option,
|
||||
provider_configured, required_spread, uncertainty_of, widen_factor,
|
||||
)
|
||||
from .price_chain import uses_chain
|
||||
|
||||
_LOG = logging.getLogger("goodwe.tariff.static")
|
||||
|
||||
@@ -793,6 +794,13 @@ class TariffPlan:
|
||||
self.read = read
|
||||
self.profile = profile
|
||||
self.origin = origin
|
||||
# PRICE-01. The series the price chain found, BEFORE the yesterday fill
|
||||
# cut it to the hours ahead: the maintenance gate needs the whole of
|
||||
# today to know its average. None for every plan that did not come
|
||||
# through the chain, which is every plan of every other provider.
|
||||
self.published = None
|
||||
self.step = None
|
||||
self.reasons = ()
|
||||
|
||||
# -- derived facts -------------------------------------------------------
|
||||
@property
|
||||
@@ -1274,7 +1282,8 @@ def static_status(opts: dict, now: datetime):
|
||||
|
||||
|
||||
async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
||||
transport=None, now=None, *, history) -> TariffPlan:
|
||||
transport=None, now=None, *, history,
|
||||
watch=None) -> TariffPlan:
|
||||
"""The full §9.2 + §11.4 resolution. MAY PERFORM I/O - see `static_plan`.
|
||||
|
||||
⚠️ NOT ON THE 30 s TICK, and that is a deliberate boundary rather than an
|
||||
@@ -1330,13 +1339,30 @@ async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
||||
if not provider_configured(opts):
|
||||
return static_plan(opts, since, until, provisional=False, now=stamp)
|
||||
|
||||
if uses_chain(opts):
|
||||
# PRICE-01. The provider names fallbacks, so its prices come from the
|
||||
# first step of the chain that has one for now. Nothing else does.
|
||||
found = await price_chain.resolve(
|
||||
opts, since, until, transport, history, stamp,
|
||||
watch if watch is not None else price_chain.PriceWatch(),
|
||||
load=load_prices)
|
||||
plan = found.read.match(
|
||||
series=lambda r: _provider_plan(
|
||||
opts, r, since, until, stamp, history,
|
||||
remember=found.step != price_chain.STEP_STORED),
|
||||
no_data=lambda r: _degrade(opts, r, since, until, stamp))
|
||||
plan.published, plan.step = found.published, found.step
|
||||
plan.reasons = found.reasons
|
||||
return plan
|
||||
|
||||
read = await load_prices(opts, since, until, transport)
|
||||
return read.match(
|
||||
series=lambda r: _provider_plan(opts, r, since, until, stamp, history),
|
||||
no_data=lambda r: _degrade(opts, r, since, until, stamp))
|
||||
|
||||
|
||||
def _provider_plan(opts, read, since, until, stamp, history) -> TariffPlan:
|
||||
def _provider_plan(opts, read, since, until, stamp, history, *,
|
||||
remember=True) -> TariffPlan:
|
||||
"""§8.3: record what the feed published, carry yesterday into the gaps.
|
||||
|
||||
The fill can itself return `no_data` - a feed whose points all fall outside
|
||||
@@ -1344,7 +1370,12 @@ def _provider_plan(opts, read, since, until, stamp, history) -> TariffPlan:
|
||||
§11.4 row arrived at one step later, so it degrades exactly as a failed
|
||||
fetch does rather than being special-cased here.
|
||||
"""
|
||||
filled = history.carry(read, since=since, until=until, stamp=stamp)
|
||||
if remember:
|
||||
filled = history.carry(read, since=since, until=until, stamp=stamp)
|
||||
else:
|
||||
# PRICE-01: a read that came out of the store is not filed again.
|
||||
filled = history.carry(read, since=since, until=until, stamp=stamp,
|
||||
remember=False)
|
||||
return filled.match(
|
||||
series=lambda r: TariffPlan(r, _quiet_profile(opts), "provider"),
|
||||
no_data=lambda r: _degrade(opts, r, since, until, stamp))
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"meter_source": {"off": "One grid power entity", "ha_dsmr": "Import and export entities", "mqtt_p1": "Meter over MQTT", "ha_signed": "One net power entity", "homewizard_local": "HomeWizard meter on the network"},
|
||||
"meter_phases": {"1": "1 phase", "3": "3 phases"},
|
||||
"shadow_mode": {"auto": "Automatic", "on": "Always on", "off": "Off"},
|
||||
"price_provider": {"generic_dayahead": "Day-ahead market price", "custom_formula": "Your own price formula", "manual_csv": "A price file"},
|
||||
"price_provider": {"generic_dayahead": "Day-ahead market price", "custom_formula": "Your own price formula", "manual_csv": "A price file", "price_service": "Your own price service"},
|
||||
"log_level": {"trace": "Everything, for support", "debug": "Detailed, to find a problem", "info": "Normal", "warning": "Warnings and errors", "error": "Errors only"},
|
||||
"period_days": {"mon-sun": "Every day", "mon-fri": "Monday to Friday", "sat,sun": "Saturday and Sunday", "mon": "Monday", "tue": "Tuesday", "wed": "Wednesday", "thu": "Thursday", "fri": "Friday", "sat": "Saturday", "sun": "Sunday"}
|
||||
},
|
||||
@@ -410,6 +410,8 @@
|
||||
"give_would": "Tomorrow: would give power at {at}, {eur} per kWh."
|
||||
},
|
||||
"cycle_waits": "{name} waits its turn for the monthly cycle.",
|
||||
"no_prices_yet": "No prices yet, maintenance waits.",
|
||||
"no_prices_since": "No prices since {at}, maintenance waits.",
|
||||
"step_in": "The batteries step in above {kw}.",
|
||||
"step_in_would": "The batteries would step in above {kw}.",
|
||||
"period": "This 15-minute period is heading for {kw}.",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"meter_source": {"off": "Eén entiteit voor netvermogen", "ha_dsmr": "Entiteiten voor afname en injectie", "mqtt_p1": "Meter via MQTT", "ha_signed": "Eén entiteit voor nettovermogen", "homewizard_local": "HomeWizard-meter op het netwerk"},
|
||||
"meter_phases": {"1": "1 fase", "3": "3 fasen"},
|
||||
"shadow_mode": {"auto": "Automatisch", "on": "Altijd aan", "off": "Uit"},
|
||||
"price_provider": {"generic_dayahead": "Day-aheadprijs van de markt", "custom_formula": "Uw eigen prijsformule", "manual_csv": "Een prijsbestand"},
|
||||
"price_provider": {"generic_dayahead": "Day-aheadprijs van de markt", "custom_formula": "Uw eigen prijsformule", "manual_csv": "Een prijsbestand", "price_service": "Uw eigen prijsdienst"},
|
||||
"log_level": {"trace": "Alles, voor support", "debug": "Uitgebreid, om een probleem te vinden", "info": "Normaal", "warning": "Waarschuwingen en fouten", "error": "Alleen fouten"},
|
||||
"period_days": {"mon-sun": "Elke dag", "mon-fri": "Maandag tot vrijdag", "sat,sun": "Zaterdag en zondag", "mon": "maandag", "tue": "dinsdag", "wed": "woensdag", "thu": "donderdag", "fri": "vrijdag", "sat": "zaterdag", "sun": "zondag"}
|
||||
},
|
||||
@@ -345,6 +345,8 @@
|
||||
},
|
||||
"tomorrow_trade": {"grid": "Morgen: laadt om {at}, {eur} per kWh.", "grid_would": "Morgen: zou laden om {at}, {eur} per kWh.", "give": "Morgen: levert stroom om {at}, {eur} per kWh.", "give_would": "Morgen: zou stroom leveren om {at}, {eur} per kWh."},
|
||||
"cycle_waits": "{name} wacht zijn beurt af voor de maandelijkse cyclus.",
|
||||
"no_prices_yet": "Nog geen prijzen, onderhoud wacht.",
|
||||
"no_prices_since": "Geen prijzen sinds {at}, onderhoud wacht.",
|
||||
"step_in": "De batterijen springen bij boven {kw}.",
|
||||
"step_in_would": "De batterijen zouden bijspringen boven {kw}.",
|
||||
"period": "Dit kwartier gaat richting {kw}.",
|
||||
|
||||
@@ -297,7 +297,9 @@ DERIVED = {
|
||||
"price_taxes_profile": "ui.region", # a preset writes it, "own" keeps it
|
||||
}
|
||||
# Option keys the page never shows and never writes: passed through as stored.
|
||||
HIDDEN: dict = {}
|
||||
# PRICE-01: the price service token is a secret. It is set in the Configuration
|
||||
# tab (a password field there) and this page never draws it.
|
||||
HIDDEN: dict = {"price_service_token": "a secret, set on the Configuration tab"}
|
||||
|
||||
|
||||
def fields():
|
||||
|
||||
@@ -18,8 +18,10 @@ from . import uimodel as U
|
||||
from .configui import help_of, label_of, problems_by_field, split_group
|
||||
from .validate import power_request_entities
|
||||
|
||||
# A choice the page cannot make, only keep. None since P5's period editor.
|
||||
ONLY_IF_HELD = ()
|
||||
# A choice the page cannot make, only keep. `price_service` needs a token, and
|
||||
# the token is a secret this page never draws (uilayout.HIDDEN), so it is set on
|
||||
# the Configuration tab and the page only keeps it (PRICE-01).
|
||||
ONLY_IF_HELD = ("price_service",)
|
||||
_TIERS = ("bad", "warn", "info")
|
||||
|
||||
|
||||
|
||||
@@ -1706,6 +1706,22 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
||||
f"{S('price_csv_path')} is empty. Set the path to the CSV file, or "
|
||||
"choose 'off'.")
|
||||
|
||||
# PRICE-01. The price service answers only to its token. Without one every
|
||||
# fetch is refused and the add-on lives on its fallbacks, which the
|
||||
# operator would not see: a source they chose, overridden by a layer they
|
||||
# cannot see. A token the server cannot read as a header is the same case.
|
||||
if pid == "price_service":
|
||||
token = str(opts.get("price_service_token", "") or "").strip()
|
||||
if not token:
|
||||
problems.append(
|
||||
f"{S('price_provider')} is 'price_service', but "
|
||||
f"{S('price_service_token')} is empty. Set the token, or "
|
||||
"choose another source.")
|
||||
elif any(c.isspace() or not c.isprintable() for c in token):
|
||||
problems.append(
|
||||
f"{S('price_service_token')} has a space or a line break in "
|
||||
"it. Paste the token again, with nothing around it.")
|
||||
|
||||
return problems
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user