F1: every password option is shown as ******** (set) or empty in /config/model (options, running, ui) and in a stale refusal; the classic page draws it as a password field; a save or validate that sends ******** or nothing keeps the stored value. F2: a static_profile beside price_service is said once at start (notification and log), not refused. F3 (owner, 2026-10-14): the no-price row, Home line and notification only while maintenance_enabled is on. F4: tests for the reviewer's R01 and R10. F5: price_service_token must be 16+ characters. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
109 lines
6.7 KiB
Markdown
109 lines
6.7 KiB
Markdown
# PRICE-01 - the owner's price server as the price source, with fallbacks
|
|
|
|
**Status: for review.** Tests: `test_price_service.py`. One option added,
|
|
`price_service_token` (password). Follows [[TARIFF-10]], whose section 4 this
|
|
card answers for one provider. Design: the owner's price service note of
|
|
2026-10-05. Owner go 2026-10-05 and 2026-10-14.
|
|
|
|
## What it is
|
|
|
|
`price_provider: price_service` reads `GET https://prices.kammenstraatha.duckdns.org/v1/dayahead/BE`
|
|
with `Authorization: Bearer <token>`. The answer is the energy-charts layout
|
|
plus `covers`, so the existing columnar parser reads it. The price is wholesale
|
|
energy only; the framework adds markup and taxes, as for `generic_dayahead`.
|
|
|
|
## Rules
|
|
|
|
1. **The host is a constant in the plugin**, declared as its only egress host,
|
|
HTTPS only. The operator's `price_feed_url` is not read by this provider.
|
|
*Cost:* every client talks to the one host, and moving the server is a new
|
|
add-on version. *What replaces the per-site setting:* the egress allow-list,
|
|
TLS, and a token per client that the server can revoke.
|
|
2. **The chain is server, then energy-charts, then the saved prices**
|
|
(`app/price_chain.py`). A step counts only if it has a price for *now*: a
|
|
server that answers with old prices is skipped like one that is down. A step
|
|
down and a step up are each one log line, written when the step changes. The
|
|
operator's `price_feed_url` is cleared for the energy-charts step, which is
|
|
asked for its own feed.
|
|
3. **The saved prices are `/data/price_history.json`**, the file `PriceHistory`
|
|
already keeps (the last three Brussels days of what was published). They are
|
|
the all-in prices as stored; a change of markup shows from the next fetch
|
|
that works. A read from the store is not filed again.
|
|
4. **No price for now** means no published price covers the present moment. Not
|
|
"none for the coming hours": before about 13:00 tomorrow's prices are not
|
|
published on any healthy day, and carried hours are already marked estimated.
|
|
It sets one status row (*Prices*), one Home plan line (English and Dutch),
|
|
and one notification (*No prices*, English and Dutch), taken down when a
|
|
price returns. A start-up before the first refresh finished is not a gap.
|
|
The notification is not re-asserted after a Home Assistant core restart
|
|
(SAFETY-34 does that for latched safety alarms); the status row stays.
|
|
*Open for the owner:* if "coming hours" should mean a look-ahead of N hours,
|
|
that is a change to `gate_for()` and `PriceWatch`, not to the chain.
|
|
5. **The maintenance charge gate** (owner's decision, 2026-10-14): the charge may
|
|
run when the *current 15-minute price is at or below today's average*, today
|
|
being the Brussels day, computed from the provider's prices. This is the rule
|
|
the Home Assistant pair gave (`price_service.yaml`), so a site that moves from
|
|
the pair to the provider does not change when the maintenance charge runs.
|
|
It is not `TariffPlan.cheap_now()` (strictly below the mean of the firm
|
|
hours of the whole plan). This replaces, for this provider only,
|
|
TARIFF-10 section 4: its three reasons are answered as follows. (1) Two
|
|
writers: the pair beside a provider stays refused; `static_profile` beside
|
|
`price_service` is accepted and no longer writes `cheap`. (2) An expired or
|
|
absent plan shuts the gate (the charge waits), as an unavailable HA sensor
|
|
did. (3) The basis is the one above, not `cheap_now()`.
|
|
6. **The token is a secret.** `password?` in the schema; hidden on the settings
|
|
page (`uilayout.HIDDEN`), which can keep `price_service` but not choose it;
|
|
never put in a message by the plugin; taken out of any reason text by
|
|
`price.scrub()`; the real transport reports only a status or an exception's
|
|
name. validate.py refuses `price_service` with no token, a token with a
|
|
space or line break, and one under 16 characters (so `scrub()`, which skips
|
|
values under 4 characters, always has something to take out). Round 2: the
|
|
token is in no page and no JSON answer of the web app. Every `password`
|
|
option of the schema is shown as `********` (set) or empty, in
|
|
`/config/model` (`options`, `running`, `ui`) and in a stale refusal; the
|
|
classic page draws it as a password field; a save or a validate that sends
|
|
`********` or nothing keeps the stored value, any other value replaces it.
|
|
A token can therefore be changed but not cleared from the page.
|
|
7. **A fixed tariff beside `price_service` is said, not refused.** It no longer
|
|
drives the cheap window or the gate, so the add-on warns once at start
|
|
(notification *Fixed tariff not used* and a log line).
|
|
8. **The no-price words show only with the maintenance charge on** (owner,
|
|
2026-10-14): the status row, the Home line and the notification say
|
|
'maintenance waits', so with `maintenance_enabled` off none is shown.
|
|
|
|
## Where the gate equals the Home Assistant pair, and where it does not
|
|
|
|
Equal: `test_price_service.py` section 8 feeds the same day both ways, to the
|
|
rules in `price_service.yaml` (price at the quarter, average of today's
|
|
quarters rounded to 0.00001 EUR/kWh, `<=`), over 6 days including both
|
|
clock-change days, at three instants in every period, and through a real
|
|
`Controller` for a whole day. Every answer is the same.
|
|
|
|
Not equal, by design or by arithmetic:
|
|
|
|
- The comparison is made on all-in prices. With a flat taxes figure and a
|
|
markup and taxes of at most five decimals the offset cancels and the answer
|
|
is the same. With `price_taxes_profile` (an hour-varying levy) the all-in
|
|
prices differ by hour, so the answer can differ from the wholesale comparison
|
|
the HA sensors made. A site on that profile should check before moving.
|
|
- A quarter within 0.00001 EUR/kWh of the average can differ if markup or taxes
|
|
have more than five decimals.
|
|
- The plan is retired when no refresh has succeeded for 90 minutes
|
|
(`TARIFF_MAX_AGE_S`) and the gate shuts then. How long the HA pair kept an
|
|
answer after its REST sensor stopped updating was not measured here.
|
|
- The average is over the firm prices the provider holds for today. After a
|
|
restart in the middle of a day with only the saved part of today, it is the
|
|
average of that part.
|
|
|
|
## Not covered
|
|
|
|
- **No real answer of the server was seen.** No token was available. The
|
|
layout is the design's; `test_price_service.py` uses a mock and, for the
|
|
parser, the real energy-charts capture reshaped with the design's extra keys.
|
|
The first capture from the live server (rig test) should replace the mock.
|
|
- The 429 limit (about 60 calls an hour) is not approached: one fetch per
|
|
30 minutes, and at most two requests when the server fails.
|
|
- Battery modes read the plan as for `generic_dayahead` and were not changed.
|
|
- The `price_service` REST and template sensors in Home Assistant are not
|
|
touched; they stop being read when the pair is cleared.
|