Files
goodwe-addon/goodwe_controller/DECISIONS/PRICE-01-own-price-server.md
T
adminandClaude Opus 5.5 6862dea52a
checks / unit (pull_request) Successful in 39m4s
infra-pin / fresh (pull_request) Successful in 2s
PRICE-01 review 3: tests for the round 3 note and dismiss; doc wording
- test_price_secret.py: the fixed-tariff note on the real /config/validate
  route, EN and NL, and none for a spaces-only profile (web.py lang wiring).
- test_price_service.py: the start line is INFO, not WARNING; a dismiss that
  raises does not stop the cycle.
- test_maint13/test_setup_only/test_watchdog: ignore only the dismiss of the
  old card, so a create with that id is still counted.
- DECISIONS rule 7 quotes the settings note as it is; messages.py comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-14 22:07:09 +02:00

118 lines
7.4 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 allowed and quietly noted.** It is
still the plan's last fallback (`resolve_tariff` -> `_degrade`); only the
cheap window and the gate ignore it, because they read the provider's
prices. Keeping it set is the recommended setup, and clearing it triggers the
"static_profile is empty" warning, so no notification (round 2's *Fixed
tariff not used* was false and fired on every sound start; round 3 removed
it). Instead one INFO line at start (English, as every log line) and an info
note beside the setting (EN and NL, "With the price service, the fixed
tariff is only used if no prices are available."), both
only with a non-blank `static_profile` (whitespace counts as blank). At
start the add-on dismisses the old notification id, in case a pre-release
build raised it.
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.