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
+55
View File
@@ -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",