Compare commits

...
8 Commits
Author SHA1 Message Date
adminandClaude Opus 5.5 6862dea52a PRICE-01 review 3: tests for the round 3 note and dismiss; doc wording
checks / unit (pull_request) Successful in 39m4s
infra-pin / fresh (pull_request) Successful in 2s
- 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
glenn schrooyenandClaude c68fd275cc PRICE-01 round 3: replace the fixed-tariff notification with a log line and a settings note
checks / unit (pull_request) Successful in 39m6s
infra-pin / fresh (pull_request) Successful in 1s
G1: remove the false 'Fixed tariff not used' notification. One INFO line at
start and an info note beside the setting (EN/NL) when price_service has a
non-blank static_profile; the old notification id is dismissed at start.
DOCS, migration step 5 and DECISIONS rule 7 now say what the code does.
S05: whitespace-only static_profile counts as not set.
G2: maintenance_enabled read through MaintConfig.from_options().

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-14 20:27:15 +02:00
glenn schrooyenandClaude Opus 5.5 2f261f8ba4 PRICE-01 review: spaced-token check passes the length rule; F3 default pinned
checks / unit (pull_request) Successful in 39m51s
infra-pin / fresh (pull_request) Successful in 1s
The spaced-token check used a 5-character token, which the new 16-character
rule refuses on its own, so a validator that stopped checking spaces passed
(reviewer mutation R06). It now uses a long token and checks the reason.

Also pin F3 with no maintenance_enabled key: the default (off) holds and no
no-price words show (reviewer mutation S04). Tests only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-14 19:58:50 +02:00
glenn schrooyenandClaude 0801776645 PRICE-01 round 2: the token on no page, fixed-tariff note, token length, F3 words, test gaps
checks / unit (pull_request) Successful in 39m0s
infra-pin / fresh (pull_request) Successful in 2s
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
2026-10-14 18:56:51 +02:00
glenn schrooyenandClaude 4eb88f7fda PRICE-01: migration note, and the tests drive the real control cycle
checks / unit (pull_request) Successful in 39m36s
infra-pin / fresh (pull_request) Successful in 1s
- DOCS: the migration note said markup and taxes do not matter; with the
  provider they are added to the wholesale price. Step 4 and 5 now say what
  they set and what static_profile does while no source has a price.
- The gap tests run the notification through cycle(), where the check is
  called, so a check that is never called goes red.
- test header: the real-transport checks are section 11.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-14 17:03:05 +02:00
glenn schrooyenandClaude 87ad958842 PRICE-01: Dutch dates, no redirects with the token, more checks
checks / unit (pull_request) Successful in 39m2s
infra-pin / fresh (pull_request) Successful in 1s
- The notification, status row and Home line name an earlier day in the
  language of the text ("13 okt om 21:00"); the Dutch title is "Prijzen
  ontbreken", so the body no longer repeats it.
- The token request does not follow redirects: the host is fixed in code.
- A step back up to the saved prices is one INFO line.
- Tests: whole status payload free of the token, a redirect, the 5-decimal
  edge of the old average, modes plan equal to generic_dayahead's.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-14 16:54:11 +02:00
glenn schrooyenandClaude 9ac3a9d2bc 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
2026-10-14 16:22:24 +02:00
admin 0ce7d35f80 Merge pull request 'MAINT-11: start or stop the maintenance cycle per battery' (#189) from MAINT-11 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-14 01:18:42 +02:00
29 changed files with 3282 additions and 49 deletions
@@ -0,0 +1,117 @@
# 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.
+111 -5
View File
@@ -222,6 +222,7 @@ The label and description each setting shows on the Configuration tab, in tab or
| Tariff | Your own injection formula | `price_export_formula` | Optional. The injection formula from your tariff card, in the same shape as your own tariff formula. Leave it empty to use the flat injection price. |
| Tariff | Price CSV file | `price_csv_path` | For manual_csv only. Where in /share the add-on reads your price file. |
| Tariff | Price feed URL override | `price_feed_url` | Leave it empty to use the source's own feed. An override is sent exactly as typed and often returns only one day. |
| Tariff | Price service token | `price_service_token` | For the price service only. The token you were given for it. Leave it empty with any other price source. |
| Tariff | Price look-ahead (hours) | `price_horizon_h` | How many hours of prices to ask the source for. |
| Tariff | Price request timeout (s) | `price_timeout_s` | How long one request to the price source may take before it is given up. |
| Tariff | Current price entity (older) | `price_now_entity` | The older way to get a price, used only to charge in a cheap window during maintenance. Do not combine it with a dynamic price source. |
@@ -290,6 +291,7 @@ When the add-on refuses to start, each reason names the setting by its label and
- **Fixed or day/night tariff** (`static_profile`): the periods must cover every day type exactly once with no gap and no overlap, and a gap is refused rather than filled. A window 22:00-07:00 on mon-fri does not cover Saturday morning, so a weekend row is required. See [Static day/night and fully-fixed tariffs](#static-daynight-and-fully-fixed-tariffs-static_profile).
- **Your own tariff and injection formula** (`price_formula`, `price_export_formula`): type the card's numbers in the card's units. The `card` block is required on the tariff formula: the add-on recomputes the card's example price and refuses to start if it does not come out. Injection is VAT-exempt in Belgium, so `vat_pct` there is normally 0. See [Your own tariff formula](#your-own-tariff-formula-custom_formula) and [`price_export_formula`, field by field](#price_export_formula-field-by-field).
- **Price feed URL override** (`price_feed_url`): only a host the chosen source declares is accepted. Nothing is added to the URL, not even the date range, so an override often returns one day and the rest of the plan is carried over and excluded from trading. The log warns on every refresh.
- **Price service token** (`price_service_token`): a password field. Every page of the add-on, and every answer its web app gives, shows only `********` when a token is set and nothing when not; the token itself is never sent to a page. A save that leaves the field empty or as `********` keeps the stored token, and a new value replaces it. It must be at least 16 characters. It is needed with `price_provider: price_service`, and the add-on refuses to start without it. It never appears in the log, the status page, a refusal or a notification. See [`price_service`](#price_service--your-own-price-server-and-what-it-falls-back-to).
- **Current and average price entities** (`price_now_entity`, `price_avg_entity`): kept because removing them would take the maintenance cycle's only price signal away from a live site.
- **Trade on the price spread** (`arbitrage_enabled`): off as shipped, on purpose, because it moves energy against the meter by design. It needs a price source and a P1 meter, and the add-on refuses combinations that cannot work.
- **Battery cycle cost** (`cycle_cost_threshold_eur`): wear plus conversion losses. It is not derived from a measurement: 0.10 is a product decision still open with the owner (OPT-08). At this value a fixed day/night site does not trade, since a realistic Belgian day/night spread is about 0.08 EUR/kWh.
@@ -2198,20 +2200,24 @@ does, and only after you have read why.
> `static_profile` below needs no network at all, so its cheap window is
> computed on every cycle and reaches the maintenance cycle's grid top-up
> whether or not arbitrage is armed. ⚠️ **And it is still the only source of
> that window.** A provider-backed plan now exists on every configured site,
> but *Cheap window* and the maintenance cycle's grid top-up read
> `static_profile` and nothing else — a deliberate limit, recorded with what it
> would take to change in `DECISIONS/TARIFF-10-fetching-is-not-trading.md`.
> that window, with one exception.** A provider-backed plan exists on every
> configured site, but *Cheap window* and the maintenance cycle's grid top-up
> read `static_profile` and nothing else — a deliberate limit, recorded with
> what it would take to change in
> `DECISIONS/TARIFF-10-fetching-is-not-trading.md`. The exception is
> `price_service` (PRICE-01): with it, the window comes from the provider's own
> prices, as described under [`price_service`](#price_service--your-own-price-server-and-what-it-falls-back-to).
| option | meaning |
|---|---|
| `price_provider` | `off` (default) or one of the three plugins below |
| `price_provider` | `off` (default) or one of the plugins below |
| `price_markup_eur_kwh` | Your supplier's margin, EUR/kWh. **Added only when the chosen plugin's feed does not already contain it** |
| `price_taxes_eur_kwh` | Distribution, levies and VAT, EUR/kWh. Same rule. Defaults to **0.11736** — a real Flemish figure, and **yours is probably different**: see *What `price_taxes_eur_kwh` is made of* below |
| `export_price_eur_kwh` | The **flat injection** price, EUR/kWh. May be negative — see below |
| `price_export_formula` | The **injection formula**, if your contract has one. Same shape as `price_formula`, its own coefficients, its own `vat_pct` — see below |
| `price_csv_path` | Where `manual_csv` reads its file |
| `price_feed_url` | Override the plugin's default feed. Used **exactly as written** — the add-on appends nothing to it, including the date range, so an override gets whatever horizon that URL returns. Only a host the plugin itself declares is accepted |
| `price_service_token` | The token for `price_service`. A password field. Empty with any other source |
| `price_horizon_h` | How far ahead to ask for, hours |
| `price_timeout_s` | Per-request timeout |
| `static_profile` | A static day/night or fully-fixed tariff. Empty by default — see below |
@@ -2224,6 +2230,7 @@ arbitrage decision wrong by that amount, in a direction nothing reports.
| plugin | product | the published figure contains | the add-on adds |
|---|---|---|---|
| `generic_dayahead` | day-ahead | energy only (wholesale EPEX BE) | markup **and** taxes |
| `price_service` | day-ahead | energy only (wholesale, from your own price server) | markup **and** taxes |
| `custom_formula` | day-ahead | energy **and** your contract's markup — your card's `Tariefformule` line | taxes |
| `manual_csv` | day-ahead | everything, all-in | nothing |
@@ -2253,6 +2260,86 @@ from. Any alias would have to invent the numbers your battery is priced against.
Eneco's dynamic product is *Zon & Wind Dynamisch*, and it is indexed to
Belpex/EPEX **day-ahead**, not to imbalance.)
#### `price_service` — your own price server, and what it falls back to
*For installers: PRICE-01. The decision is in
`DECISIONS/PRICE-01-own-price-server.md`.*
`price_provider: price_service` reads the day-ahead prices for Belgium from the
owner's price server, `https://prices.kammenstraatha.duckdns.org/v1/dayahead/BE`.
The host is fixed in the add-on and only HTTPS is used. The token goes in
`price_service_token` and is sent as `Authorization: Bearer <token>`. The server
answers the energy-charts layout, so the same parser reads it. The price is the
wholesale energy price and nothing else, so the add-on adds your markup and
taxes exactly as for `generic_dayahead`.
**Where the prices come from.** Each step is used only if it has a price for
now. A server that answers with last night's prices is treated as down.
| Step | Source | When it is used | What the log says (once per change) |
|---|---|---|---|
| 1 | The price server | It answers, with a price for now | `The price service answers again.` on the way back. |
| 2 | energy-charts.info | Step 1 fails: down, timeout, refused token (401), error (5xx), a damaged answer, or no price for now | `The price service does not answer, so prices come from energy-charts.` Or `The price service refused the token, so prices come from energy-charts.` |
| 3 | The prices saved in `/data/price_history.json` | Steps 1 and 2 fail | `No price source answers, so the add-on uses the prices it saved.` |
| none | Nothing | No saved price covers now either | `No price source answers, and no saved price covers now.` |
The saved prices are the last answers that worked, kept on disk, so a site
whose server and energy-charts are both down for a day goes on planning from
what it has. It keeps the days the history holds (today, tomorrow once it was
fetched, and a day back). They are the all-in prices as stored: a change of
markup or taxes shows from the next fetch that works. Going back up the chain
is logged once as well: `Prices come from energy-charts again.` and `The price
service answers again.` The refresh runs every 30 minutes.
**When there is no price for now (step none).** One status line, `Prices: No
prices since 00:00, maintenance waits.` (on the add-on page; the Home screen says
the same in English and Dutch), and one notification, *No prices*. Both go when a
source has a price for now again. They show only while **Run the monthly battery cycle**
(`maintenance_enabled`) is on; with it off, neither is shown. "Since" is the moment the last price ran out.
Before the first refresh has finished there is no line, so a start-up is not a
gap. Control is not touched: the battery follows the meter as ever, battery
modes and trading read what prices there are, and only the maintenance charge
waits.
**The maintenance charge and the cheap window.** With this provider the
maintenance charge gate reads the provider's prices and no Home Assistant
entity. It keeps the rule the Home Assistant pair gave: the charge may run
when the **current 15-minute price is at or below today's average**, today
being the Brussels day. Moving a site from the pair to `price_service` does not
change when the charge runs. Where the two can differ is listed in the decision
record: the Home Assistant average was rounded to 0.00001 EUR/kWh, and so is
this one before it is compared. With no price for now the gate is shut, as it
was when an entity was unavailable. *Cheap window* on the status page shows this
answer. This is not the *cheap hours* rule of battery modes and not
`cheap_now()`.
**Moving a site from the Home Assistant price sensors to `price_service`.**
Do this on the Configuration tab, in one save, and restart once:
1. Set **Dynamic price source** (`price_provider`) to `price_service`.
2. Paste the token into **Price service token** (`price_service_token`).
3. **Clear** **Current price entity** (`price_now_entity`) and **Average price
entity** (`price_avg_entity`). The add-on refuses to start with a price
source and these two together.
4. Check **Supplier markup** and **Distribution, levies and VAT**. With the
Home Assistant pair they decided nothing about the maintenance charge. With
the provider they are added to the wholesale price, so they set the prices
shown on the page and, if battery modes or trading are on, the prices those
decide on. The maintenance charge gate does not depend on them.
5. Keep **Fixed or day/night tariff** (`static_profile`) if you have one; it
is fine to leave it set. It is the plan's last fallback: it is used,
marked as estimated and without trading, only when no source has a price.
*Cheap window* and the maintenance charge never read it: the provider's
prices decide them. The add-on logs one INFO line at start ("Fixed tariff
is only used if no prices are available.") and shows the same note beside
the setting. Nothing needs clearing, and clearing it only removes the
fallback (the settings page then warns that the site has no tariff while the
price feed is down).
6. Remove the `price_service` REST and template sensors from Home Assistant
when you are satisfied. They are no longer read.
To go back: put the two entities back, set the source to `off`, and restart.
#### So what should a Belgian dynamic-tariff customer do today?
**Honestly: `generic_dayahead` gets you close, and you should know exactly how
@@ -2809,6 +2896,9 @@ uses the result as a cheap-window hint for the maintenance charge. They are
— configure two of them and the add-on refuses to start, naming the conflict,
because the add-on reads exactly one and the loser is silent. Use one.
To move a site off them onto the owner's price server, see [`price_service`](#price_service--your-own-price-server-and-what-it-falls-back-to):
it lists which options to set and which to clear.
### The three buckets — which price components can move a decision
*For installers: card TARIFF-17.*
@@ -3282,8 +3372,22 @@ days before it tries again, so it does not empty the battery every night. To
try sooner, start a cycle on the add-on page. If it keeps happening, check that
the battery follows its commands and that the cheap hours leave time to charge.
### No prices
No price source has a price for the present moment: not the price server,
not energy-charts, and nothing saved. The monthly battery cycle waits, because
it will not charge from the grid without a price to judge by. Control of the
battery goes on as before. Check that the add-on can reach the internet and that
the price service token is right. The notification goes by itself when a price
is back. It is shown only while the monthly battery cycle is switched on.
### For installers: notifications
A fixed tariff beside the price service raises no notification: it is the
plan's last fallback, not a fault. An earlier pre-release build raised a card
with the id `goodwe_fixed_tariff_unused`; the add-on takes that id down at every
start, which does nothing when it is absent.
This part is for installers. The texts live in `app/messages.py` and follow
`STYLE.md`. Every notification keeps a fixed `notification_id`, so a repeat
replaces the card. The ERROR or WARNING line of the add-on log names the
@@ -3302,6 +3406,7 @@ default `info` hides it.
| *name* is not delivering | alarm | `goodwe_not_delivering_<name>` | SAFETY-42 |
| Meter reading may be stuck | alarm | `goodwe_meter_frozen` | SAFETY-18 |
| Battery cycle did not finish | warning | `goodwe_maint_failed_<name>` | MAINT-13 |
| No prices | warning | `goodwe_no_prices` | PRICE-01 |
**The Details link.** Home Assistant 2026.2 and newer show add-on pages at
`/config/app/<slug>/documentation`; 2026.5 removed the old
@@ -3499,6 +3604,7 @@ for a new install*.
| Direction check, Site direction check | Whether each inverter's power goes the way the add-on asks. *Opposite for 12 of 60 seconds* counts toward a stop. When it stops, the inverter, or every inverter, is set to 0 W until you start control. |
| Check for inverters working against each other, Inverters worked against each other | Only with several inverters. See *Inverters working against each other*. |
| Delivery check, *name* is not delivering | Only with several inverters. See *An inverter is not delivering*. |
| Prices | Only while no price source has a price for now: *No prices since 00:00, maintenance waits.* See *No prices* under Notifications. |
| Solar production | Your solar production. Sources are numbered in the order of “Solar production entities, per inverter”. A strongly negative reading by day means that entity reads the wrong way round: add it to “Reversed-sign solar entities”. |
### For installers: status page
+5
View File
@@ -221,6 +221,11 @@ function makeInput(field, value, onchange){
input.step = field.type === 'integer' ? '1' : 'any';
if(field.min !== undefined) input.min = field.min;
if(field.max !== undefined) input.max = field.max;
} else if(field.type === 'password'){
input.type = 'password';
input.autocomplete = 'new-password';
input.autocapitalize = 'none';
input.spellcheck = false;
} else {
input.type = 'text';
input.autocapitalize = 'none';
+48
View File
@@ -206,6 +206,8 @@ def _one_field(node, translations, lang, path=()):
"multiple": bool(node.get("multiple")),
"unit": unit_of(key),
}
if node.get("format") == "password":
field["type"] = "password"
if node.get("lengthMin") is not None:
field["min"] = node["lengthMin"]
if node.get("lengthMax") is not None:
@@ -306,6 +308,52 @@ def problems_by_field(problems, keys):
return by_key, unanchored
# --------------------------------------------------------------------------- #
# SECRETS (PRICE-01 F1). A `password` option never leaves this process in a page
# or a JSON answer. The pages get SECRET_SHOWN when one is set and "" when not.
# A save that sends SECRET_SHOWN or nothing keeps the stored value; any other
# value replaces it. The names come from the Supervisor's own schema, so a
# password option added later is covered without an edit here.
# --------------------------------------------------------------------------- #
SECRET_SHOWN = "********"
def secret_keys(schema, extra=()):
"""Names of the options whose schema type is `password`, plus `extra`."""
# The Supervisor may spell it `type: password` or `type: string` with
# `format: password`; both count.
return frozenset(str(n.get("name")) for n in schema or ()
if isinstance(n, dict) and (
n.get("type") == "password"
or n.get("format") == "password")
) | frozenset(extra)
def hide_secrets(value, keys):
"""A copy of `value` with every secret key's value replaced by
SECRET_SHOWN (set) or "" (empty), at any depth."""
if isinstance(value, dict):
return {k: ((SECRET_SHOWN if v else "") if k in keys
else hide_secrets(v, keys))
for k, v in value.items()}
if isinstance(value, list):
return [hide_secrets(v, keys) for v in value]
return value
def keep_secrets(proposed, stored, keys):
"""`proposed` with each secret the page did not change put back from
`stored`: absent, empty or SECRET_SHOWN means 'leave it'."""
out = dict(proposed)
for key in keys:
if out.get(key) in (None, "", SECRET_SHOWN):
if isinstance(stored, dict) and key in stored:
out[key] = stored[key]
elif key in out and out[key] == SECRET_SHOWN:
out[key] = ""
return out
def changed_keys(left, right):
"""Sorted keys whose values differ between two option blobs.
+23
View File
@@ -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,17 @@ 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")
local, today = brussels(since), brussels(now)
if local.date() == today.date():
return _t("no_prices_since", at=f"{local:%H:%M}")
return _t("no_prices_since_day", day=local.day, month=month_name(local),
at=f"{local:%H:%M}")
def line_peak_limit(limit_w, shadow) -> str:
return _w(shadow, "step_in", kw=_kw(limit_w))
@@ -1387,6 +1400,16 @@ 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, 14, 0, 0,
tzinfo=timezone.utc),
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)),
+105 -1
View File
@@ -90,6 +90,8 @@ 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, fixed_tariff_beside_service, 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 +1204,17 @@ 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._fixed_note_said = 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 +3448,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 +4017,38 @@ 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
# Owner, 2026-10-14: the words say 'maintenance waits', so they show
# only while the maintenance charge is on.
self.price_gap = ((not gate.covered) and self.tariff_at is not None
and MaintConfig.from_options(self.o).enabled)
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 +5587,12 @@ 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()
await self.check_fixed_note()
# --- 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 +6209,50 @@ 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_args()).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())
async def check_fixed_note(self) -> None:
"""PRICE-01 G1. At the first cycle only: one INFO line when a
`static_profile` is set beside `price_service` (it is the plan's last
fallback, not a fault, so no notification), and a dismiss of the card an
earlier build raised, which is harmless when absent."""
if self._fixed_note_said:
return
self._fixed_note_said = True
if fixed_tariff_beside_service(self.o):
_LOG.info(messages.FIXED_TARIFF_LOG)
try:
await self.hass.call("persistent_notification", "dismiss",
messages.fixed_tariff_card_gone().data())
except Exception: # noqa: BLE001 - housekeeping never stops a cycle
_LOG.debug("Could not take down the old fixed-tariff card.")
def _gap_args(self) -> tuple:
"""`(since, now)` in Brussels time for the gap's words. `since` is None
when no price was ever published."""
now = brussels(datetime.now(timezone.utc))
if self.price_gap_since is None:
return None, now
return brussels(self.price_gap_since), now
async def check_meter_frozen(self) -> None:
"""SAFETY-18. Alarm on a meter that answers forever without changing.
@@ -6286,7 +6387,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 +7464,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_args())),)
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
+89
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 = "Prijzen ontbreken"
ID_LOOP_BOUND = "goodwe_integrator_bound"
ID_OUTPUT_CLAMP = "goodwe_output_clamp"
@@ -138,6 +140,10 @@ 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"
# PRICE-01: an earlier build raised a card with this id. Nothing raises it now;
# the add-on only dismisses it at start.
OLD_FIXED_CARD_ID = "goodwe_fixed_tariff_unused"
# 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 +320,77 @@ def maint_failed(kind: str, days: int, unit: str = "", slug: str = "") -> Notice
T_MAINT_FAILED, en, body_nl=f"{T_MAINT_FAILED_NL}. {nl}")
NL_MONTHS = ("jan", "feb", "mrt", "apr", "mei", "jun", "jul", "aug", "sep",
"okt", "nov", "dec")
def since_text(since, now, lang="en") -> str:
"""`00:00` for a moment today, `13 Oct at 21:00` (`13 okt om 21:00` in
Dutch) for an earlier day. Both arguments are already in the site's zone.
Empty for no moment."""
if since is None:
return ""
if since.date() == now.date():
return f"{since:%H:%M}"
if lang == "nl":
return f"{since.day} {NL_MONTHS[since.month - 1]} om {since:%H:%M}"
return when(since)
def no_prices(since=None, now=None) -> Notice:
"""PRICE-01: no source has a price for now. Said once, gone with the gap.
`since` is the moment the last price ran out, or None."""
if since is not None:
en = (f"No prices since {since_text(since, now)}, so the monthly "
f"battery cycle waits until they return.")
nl = (f"Sinds {since_text(since, now, 'nl')} zijn er geen prijzen, dus "
f"de maandelijkse 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)
# PRICE-01: a fixed tariff beside the price service. One true sentence, for the
# start-up log (English only, as every log line) and the settings page (EN, NL).
FIXED_TARIFF_LOG = "Fixed tariff is only used if no prices are available."
FIXED_TARIFF_NOTE = ("With the price service, the fixed tariff is only used if "
"no prices are available.")
FIXED_TARIFF_NOTE_NL = ("Met de prijsdienst wordt het vaste tarief alleen "
"gebruikt als er geen prijzen zijn.")
# Settings-page notes that have a Dutch twin (uipage.anchors).
NOTE_NL = {FIXED_TARIFF_NOTE: FIXED_TARIFF_NOTE_NL}
def fixed_tariff_card_gone() -> Dismiss:
"""PRICE-01: takes down the card an earlier build raised."""
return Dismiss(OLD_FIXED_CARD_ID)
def row_no_prices(since=None, now=None) -> str:
"""PRICE-01: the status row while no price covers now."""
if since is not None:
return f"No prices since {since_text(since, now)}, 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."
@@ -1058,6 +1135,11 @@ def hint_samples() -> list:
RED, AMBER, GREEN = "bad", "warn", "ok"
# PRICE-01 samples: a gap that began today, and one that began the day before.
_SINCE_SAMPLE = (datetime(2026, 10, 14, 0, 0), datetime(2026, 10, 14, 9, 0))
_SINCE_OLDER = (datetime(2026, 10, 13, 21, 0), datetime(2026, 10, 14, 9, 0))
def status_samples() -> list:
"""`(function name, colour, text)` for every row and banner, for the test.
@@ -1186,6 +1268,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(*_SINCE_SAMPLE)),
("row_no_prices", AMBER, row_no_prices(*_SINCE_OLDER)),
("row_no_prices", AMBER, row_no_prices()),
]
@@ -1218,6 +1303,9 @@ def samples() -> list:
meter_frozen(90),
maint_failed("charge", 7),
maint_failed("drain", 7, "inverter-b", "inverter_b"),
no_prices(*_SINCE_SAMPLE),
no_prices(*_SINCE_OLDER),
no_prices(),
]
@@ -1250,6 +1338,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",
+175 -7
View File
@@ -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,58 @@ 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:
# No redirects: the host is fixed in code, and a redirect would
# take the request (and the token) somewhere the allow-list never
# saw. A 3xx is reported like any other status.
async with s.get(url, headers=headers,
allow_redirects=False) 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 +1952,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 +2336,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,
+228
View File
@@ -0,0 +1,228 @@
"""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,
price_option, 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))
def fixed_tariff_beside_service(opts: dict) -> bool:
"""PRICE-01: a fixed tariff is set beside `price_service`. Not a fault: it
stays the plan's last fallback (`resolve_tariff`), and only the cheap window
and the gate ignore it. A blank or whitespace-only value is not set."""
return (provider_id(opts) == "price_service"
and bool(str(price_option(opts, "static_profile")).strip()))
@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:
if old is not None and old > step:
_LOG.info("The add-on uses the prices it saved again.")
else:
_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}")
+43 -3
View File
@@ -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 "
+35 -4
View File
@@ -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))
+4 -1
View File
@@ -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,9 @@
"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.",
"no_prices_since_day": "No prices since {day} {month} at {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}.",
+4 -1
View File
@@ -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,9 @@
},
"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.",
"no_prices_since_day": "Geen prijzen sinds {day} {month} om {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}.",
+3 -1
View File
@@ -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():
+11 -6
View File
@@ -12,14 +12,17 @@ import copy
import os
import re
from . import messages
from . import uilayout as L
from . import uitext
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")
@@ -245,7 +248,7 @@ def derive_messages(errors, problems, notes, model, keys, lang="en"):
kept = [p for p in problems if p in kept]
notes = [n for n in notes if not set(n.keys or ()) & failed]
return ([derive_message(err, lang) for err in errors]
+ anchors(kept, notes, model, keys))
+ anchors(kept, notes, model, keys, lang))
# --------------------------------------------------------------------------- #
@@ -290,9 +293,10 @@ def targets(key: str, text: str, model: dict) -> list:
return [(None, None)]
def anchors(problems, notes, model, keys) -> list:
def anchors(problems, notes, model, keys, lang="en") -> list:
"""[{field, card, tier, text}] for the refusals and notes. `field` is
None for a sentence that names no setting on the page."""
None for a sentence that names no setting on the page. A note with a Dutch
twin in `messages.NOTE_NL` is shown in Dutch when `lang` is "nl"."""
out, seen = [], set()
def add(field, card, tier, text):
@@ -310,10 +314,11 @@ def anchors(problems, notes, model, keys) -> list:
add(None, None, "bad", text)
for note in notes:
tier = note.tier if note.tier in _TIERS else "info"
text = messages.NOTE_NL.get(note.text, note.text) if lang == "nl" else note.text
for key in note.keys or (None,):
for field, card in (targets(key, note.text, model) if key
else [(None, None)]):
add(field, card, tier, note.text)
add(field, card, tier, text)
# A sentence placed on one inverter's field is not also put on the list.
roster = _roster()
placed = {m["text"] for m in out
+31
View File
@@ -1659,6 +1659,12 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
f"{S('static_profile')} is empty, so the site has no tariff "
"at all while the price feed is down.")
# PRICE-01: not a fault, so an info note and no log line here (the add-on
# logs it once at start). With `price_service` the fixed tariff is only the
# plan's last fallback; the cheap window and the gate do not read it.
if pid == "price_service" and str(opts.get("static_profile", "") or "").strip():
_advise(notes, "info", ("static_profile",), messages.FIXED_TARIFF_NOTE)
# ⚠️ OPT-04. THE THREE WAYS `arbitrage_enabled: true` CAN BE A LIE, refused
# rather than logged, because a strategy that is armed in the UI and cannot
# fire is indistinguishable from one that fires and finds no opportunity -
@@ -1706,6 +1712,26 @@ 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.")
elif len(token) < PRICE_TOKEN_MIN:
problems.append(
f"{S('price_service_token')} is too short. Paste the whole "
"token.")
return problems
@@ -1762,6 +1788,11 @@ def _entities_read(opts: dict) -> list[tuple[str, str, str]]:
return out
# PRICE-01 F5. A shorter value is not a token; it also could not be scrubbed
# from a log (price.scrub skips values under 4 characters).
PRICE_TOKEN_MIN = 16
def config_notes(opts: dict, *, entities: dict | None = None,
month_peak_w=None) -> list[Note]:
"""UX-13: warnings that need live data, for the settings page. Never a
+28 -9
View File
@@ -48,12 +48,13 @@ from datetime import datetime, timezone
from aiohttp import web
from . import messages, selfconfig, uipage, uitext
from . import messages, price, selfconfig, uipage, uitext
from .haentities import EntityCache
from .homeview import region_of
from .validate import config_notes, setup_checks
from .config_page import CONFIG_PAGE
from .configui import changed_keys, field_model, problems_by_field
from .configui import (changed_keys, field_model, hide_secrets, keep_secrets,
problems_by_field, secret_keys)
from .uimodel import DeriveError, options_to_ui
_LOG = logging.getLogger("goodwe.web")
@@ -209,6 +210,11 @@ def build_app(controller, selfcfg=None) -> web.Application:
raise web.HTTPBadRequest(text="expected an options object")
return body, options, None
def _secrets(info):
# PRICE-01 F1: every `password` option of the schema, and the price
# token by name in case a schema is missing.
return secret_keys((info or {}).get("schema"), price.SECRET_OPTIONS)
async def config_model(request):
"""Everything the page needs to draw itself, from ONE Supervisor call.
@@ -227,13 +233,14 @@ def build_app(controller, selfcfg=None) -> web.Application:
_LOG.error("cannot read the add-on's own options: %s", err)
return web.json_response({"error": str(err)}, status=502)
options = info.get("options") or {}
secrets = _secrets(info)
lang = str(request.query.get("lang", "en") or "en")[:8]
model = field_model(info.get("schema") or [],
info.get("translations") or {}, lang)
model.update({
"options": options,
"options": hide_secrets(options, secrets),
"fingerprint": hand_out(options),
"running": controller.o,
"running": hide_secrets(controller.o, secrets),
"running_differs": changed_keys(controller.o, options),
"lang": lang,
# UX-13 P1: the levies preset and its year, for the P2 Settings
@@ -242,7 +249,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
# UX-13 P2: the Installer view draws from these two.
"layout": uipage.layout(info.get("schema") or [],
info.get("translations") or {}, lang),
"ui": options_to_ui(options),
"ui": hide_secrets(options_to_ui(options), secrets),
# UX-13 P4: the setup wizard, and why it opens by itself.
"setup": uipage.setup_spec(),
"setup_needed": uipage.setup_needed(
@@ -281,6 +288,12 @@ def build_app(controller, selfcfg=None) -> web.Application:
model, options = None, _body.get("options")
if not isinstance(options, dict):
raise web.HTTPBadRequest(text="expected an options object")
try:
info_now = await selfcfg.info()
options = keep_secrets(options, info_now.get("options"),
_secrets(info_now))
except Exception: # noqa: BLE001 - a check never fails for this
pass
notes = []
problems, unread = await selfconfig.problems_and_ranges(
options, controller.hass, notes=notes)
@@ -299,7 +312,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
_keys_of(options),
uitext.lang_of(_body.get("lang")))
if errors
else uipage.anchors(problems, notes, model, _keys_of(options)))
else uipage.anchors(problems, notes, model, _keys_of(options),
uitext.lang_of(_body.get("lang"))))
payload["steps"] = uipage.step_problems(payload["messages"], model)
if _body.get("setup"):
payload["setup"] = [n._asdict() for n in await _setup_checks(options)]
@@ -339,9 +353,12 @@ def build_app(controller, selfcfg=None) -> web.Application:
except ValueError:
raise web.HTTPBadRequest(text="expected JSON")
stored_now = None
if isinstance(posted, dict) and isinstance(posted.get("model"), dict):
secrets = frozenset(price.SECRET_OPTIONS)
if isinstance(posted, dict):
try:
stored_now = (await selfcfg.info()).get("options") or {}
info_now = await selfcfg.info()
stored_now = info_now.get("options") or {}
secrets = _secrets(info_now)
except selfconfig.SupervisorUnavailable as err:
_LOG.error("saving options failed: %s", err)
return web.json_response({"error": str(err)}, status=502)
@@ -357,6 +374,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
"unanchored": [], "messages": messages_,
"steps": uipage.step_problems(messages_, model)},
status=422)
# PRICE-01 F1: a secret the page did not change is the stored one.
options = keep_secrets(options, stored_now, secrets)
expect = str(body.get("fingerprint") or "")
try:
result = await selfconfig.save(selfcfg, options, expect,
@@ -374,7 +393,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
"moved": refused.moved,
"changed_fields": (uipage.fields_of(moved, model)
if model is not None else []),
"stored": stored,
"stored": hide_secrets(stored, secrets),
# The page needs the CURRENT token to be able to save
# again after the operator has read the difference. That
# is not a force door: it takes a second, deliberate press
+6 -1
View File
@@ -556,6 +556,10 @@ options:
# naming that consequence on every fetch that uses an override; clearing this
# option is what restores the full horizon.
price_feed_url: ""
# PRICE-01. The bearer token for the owner's price server
# (`price_provider: price_service`). A password field. Empty with any other
# source. Never printed: not in the log, the status page or a notification.
price_service_token: ""
price_horizon_h: 48
price_timeout_s: 10
@@ -1085,7 +1089,7 @@ schema:
# this line offers exactly PROVIDERS + RETIRED_PROVIDERS + 'off' - derived, not
# typed - and register() refuses to re-register a retired id, so the two
# cannot both claim one.
price_provider: list(off|generic_dayahead|custom_formula|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
price_provider: list(off|generic_dayahead|price_service|custom_formula|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
price_markup_eur_kwh: float(0,1)
price_taxes_eur_kwh: float(0,1)
@@ -1119,6 +1123,7 @@ schema:
price_formula: str?
price_csv_path: str?
price_feed_url: str?
price_service_token: password?
price_horizon_h: int(1,72)
price_timeout_s: int(2,60)
price_now_entity: str?
+2
View File
@@ -81,6 +81,7 @@ from datetime import datetime, timedelta, timezone
from app import arbitrage as _arb
from named_absence import first
from app import main as _main
from app.price_chain import PriceWatch
from app import peakguard as _pg
from app.arbiter import (
P_ARBITRAGE, P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim,
@@ -2311,6 +2312,7 @@ class _T12Controller:
self.tariff_plan = None
self.tariff_at = None
self.price_history = NoHistory()
self.price_watch = PriceWatch() # PRICE-01: the refresh passes it on
self.stop_event = asyncio.Event()
self.stop_event.set() # so the inter-refresh wait returns
self._ticks = 0
+5 -1
View File
@@ -415,7 +415,11 @@ def put_in_charge(c, which, hours_ago, open_min):
def notes(h):
return [x for x in h.calls if x[0] == "persistent_notification"]
# Not the dismiss of PRICE-01's old fixed-tariff card, which every start sends.
# Only that dismiss: a `create` with the old id would still be counted.
return [x for x in h.calls if x[0] == "persistent_notification"
and not (x[1] == "dismiss"
and x[2].get("notification_id") == "goodwe_fixed_tariff_unused")]
for cheap in (True, False):
+20 -4
View File
@@ -534,11 +534,15 @@ FIXTURES = {
[v / 1000 for v in EPEX_EUR_MWH]),
"custom_formula": (columnar_feed(EPEX_EUR_MWH), FORMULA_EUR_KWH),
"manual_csv": (None, [0.1834, -0.0210, 0.3115]),
# PRICE-01: the owner's price server answers the same columnar layout.
"price_service": (columnar_feed(EPEX_EUR_MWH),
[v / 1000 for v in EPEX_EUR_MWH]),
}
check(f"the plugins that ship after TARIFF-07 and TARIFF-15 are registered "
f"{sorted(PROVIDERS)}",
set(PROVIDERS) == {"generic_dayahead", "custom_formula", "manual_csv"})
set(PROVIDERS) == {"generic_dayahead", "custom_formula", "manual_csv",
"price_service"})
check(f"...and the three withdrawn ids resolve to NOTHING "
f"{sorted(RETIRED_PROVIDERS)}",
set(RETIRED_PROVIDERS) == {"engie_dynamic", "totalenergies_dynamic",
@@ -571,7 +575,7 @@ def canned(body):
"""A transport that serves recorded bytes and records that it was used."""
calls = []
async def t(url, timeout_s):
async def t(url, timeout_s, headers=None):
calls.append((url, timeout_s))
return body
t.calls = calls
@@ -603,6 +607,9 @@ def load_any(pid):
# is exactly what section 3 grades.
return load(pid, {"price_formula": json.dumps(ENECO_CARD)},
transport=canned(body))
if pid == "price_service":
return load(pid, {"price_service_token": "test-token-1234"},
transport=canned(body))
return load(pid, transport=canned(body))
@@ -898,7 +905,16 @@ _networked = sorted(pid for pid, cls in PROVIDERS.items()
# SAME CAPTURED BYTES (section 13c), because it reads the same endpoint with
# the same parser. A networked plugin with no capture is what this line exists
# to forbid.
_replayed = ["custom_formula", "generic_dayahead"]
#
# ⚠️ PRICE-01 PUT A THIRD ON IT, AND ITS CAPTURE IS A RESHAPE, SAID PLAINLY. The
# price server answers only to a token this session does not hold, so no real
# answer of its own exists to replay. Its answer is the energy-charts layout
# (design, API section), so test_price_service.py replays the REAL energy-charts
# two-day capture with the three extra keys the design lists (`covers`,
# `source`, `fetched_at`). The prices and instants are real; the wrapper is the
# design's. The first answer captured from the live server on the rig test
# replaces it - that is the open item, and it is in the PRICE-01 report.
_replayed = ["custom_formula", "generic_dayahead", "price_service"]
check(f"the networked plugins are {_networked}, and EVERY one of them is "
f"replayed from a captured real body ({_replayed}) - no shipped plugin "
f"reaches a network this file has never seen answer (TARIFF-07)",
@@ -1252,7 +1268,7 @@ check(f"a negative day-ahead price passes through, all-in {_neg}",
# (TARIFF-15): it reads the SAME columnar document, which has no per-point
# flag, and applies arithmetic to it - arithmetic cannot invent a provenance
# the document never carried.
_SETTLED_FEED = {"generic_dayahead", "custom_formula"}
_SETTLED_FEED = {"generic_dayahead", "custom_formula", "price_service"}
for pid in sorted(set(READS) - _SETTLED_FEED):
read = READS[pid]
flags = derived(lambda r=read: [p.provisional for p in r.points])
+7 -2
View File
@@ -1133,6 +1133,11 @@ _T17_KEYS = ["price_taxes_profile"]
# `export_price_eur_kwh` is untouched, every site on the scalar is unaffected,
# and it lives in this dict and in config.yaml and nowhere else.
_T16_KEYS = ["price_export_formula"]
# PRICE-01 added the eighth: `price_service_token`, the bearer token of the
# owner's price server. An ADDITION, like the three above: nothing was renamed,
# every other provider ignores it, and it lives in this dict and in config.yaml
# (as a password field) and nowhere else.
_P01_KEYS = ["price_service_token"]
check(f"the in-code mirror holds exactly TARIFF-02's, TARIFF-03's, OPT-04's, "
f"TARIFF-15's, TARIFF-17's and TARIFF-16's keys - FCST-03 still added "
f"none of its own {_defaults}",
@@ -1142,7 +1147,7 @@ check(f"the in-code mirror holds exactly TARIFF-02's, TARIFF-03's, OPT-04's, "
"price_horizon_h", "price_timeout_s",
"static_profile"]
+ _OPT04_KEYS + _T15_KEYS + _T17_KEYS
+ _T16_KEYS))
+ _T16_KEYS + _P01_KEYS))
# ...and the later keys are genuinely NEW, not a rename of something that was
# already there, or the line above would pass while the mirror silently lost a
# key.
@@ -1151,7 +1156,7 @@ check("...and OPT-04's four, TARIFF-15's one, TARIFF-17's one and TARIFF-16's "
"are all still present",
isinstance(_defaults, list)
and len(set(_defaults) - set(_OPT04_KEYS) - set(_T15_KEYS)
- set(_T17_KEYS) - set(_T16_KEYS)) == 9)
- set(_T17_KEYS) - set(_T16_KEYS) - set(_P01_KEYS)) == 9)
# ⚠️ AND THE ONE TARIFF-17 IS THE HOUR-VARYING TWIN OF IS STILL THERE, BY NAME.
# The line above counts nine, and a rename of `price_taxes_eur_kwh` to
# `price_taxes_profile` would also count nine. The scalar path being unchanged
+412
View File
@@ -0,0 +1,412 @@
"""PRICE-01 round 2 (F1): a `password` option never reaches a page.
`python3 test_price_secret.py`
The real web app (`web.build_app`) runs over a fake Supervisor that holds a
site on `price_service` with a token. The token string must be absent from
`/config`, `/config/model` (`options`, `running`, `ui`), `/config/validate`,
a good save's answer and every refused save's answer. A save that sends the
placeholder, an empty field or no key keeps the stored token; a new value
replaces it. The same holds for any option the schema calls a password, not
only this one name.
⚠️ MUTATING A MODULE THIS FILE GRADES? Purge `__pycache__` first, or use
`tools/mutation_run.sh`. See MUTATION.md.
"""
import asyncio
import copy
import json
import logging
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
from aiohttp import ClientSession, web as aioweb # noqa: E402
import translations_lint as TL # noqa: E402
import ux13_demo as D # noqa: E402
from app import configui, price, web # noqa: E402
from app.configui import SECRET_SHOWN # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
TOKEN = "tk-SECRET-0123456789-abcdef"
NEW_TOKEN = "tk-REPLACED-9876543210-zyxw"
SCHEMA = D.schema_ui(D.CONFIG)
TRANSLATIONS = {"en": TL.parse_translation_file(
os.path.join(HERE, "translations", "en.yaml"))}
SITE = dict(D.defaults(D.CONFIG), setpoint_entity="number.goodwe_setpoint",
soc_entity="sensor.soc", batt_entity="sensor.batt",
price_provider="price_service", price_service_token=TOKEN)
class Sup:
def __init__(self, options, schema=SCHEMA):
self.options, self.schema, self.writes = copy.deepcopy(options), schema, []
async def info(self):
return {"options": copy.deepcopy(self.options), "schema": self.schema,
"translations": TRANSLATIONS}
async def write_options(self, o):
self.writes.append(copy.deepcopy(o))
self.options = copy.deepcopy(o)
async def restart(self, c):
raise AssertionError
class Hass:
session = None
token = ""
async def limits(self, _e):
return (-5000.0, 5000.0, 1.0)
async def states(self):
return []
class Ctl:
def __init__(self, o):
self.o, self.hass, self.inverters = dict(o), Hass(), []
def log_event(self, *a, **k):
pass
def status_payload(self):
return {"banner": "", "level": "ok", "rows": [], "checks": []}
async def serve(sup, ctl_options=None):
runner = aioweb.AppRunner(web.build_app(Ctl(ctl_options or sup.options), sup))
await runner.setup()
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
return runner, f"http://127.0.0.1:{port}/"
async def scenario():
out = {}
sup = Sup(SITE)
runner, root = await serve(sup)
seen = [] # every answer body, as text
try:
async with ClientSession() as s:
async def get(path):
async with s.get(root + path) as r:
t = await r.text()
seen.append(t)
return r.status, t
async def post(path, body):
async with s.post(root + path, json=body) as r:
t = await r.text()
seen.append(t)
return r.status, t
_, page = await get("config")
out["page"] = page
_, raw = await get("config/model?lang=en")
m = json.loads(raw)
out["model"] = m
out["raw_model"] = raw
out["field"] = [f for g in m["groups"] for f in g["fields"]
if f["key"] == "price_service_token"]
# a model of a site with NO token: shown empty, not as 'set'
sup0 = Sup(dict(SITE, price_service_token=""))
r0, root0 = await serve(sup0)
try:
async with s.get(root0 + "config/model?lang=en") as r:
out["model_unset"] = await r.json()
finally:
await r0.cleanup()
# --- classic page save: the page posts `options`, placeholder in
opts = copy.deepcopy(m["options"])
opts["peak_cap_w"] = 4100
st, body = await post("config/save",
{"options": opts, "fingerprint": m["fingerprint"]})
out["classic_placeholder"] = (st, sup.options["price_service_token"],
sup.options["peak_cap_w"], body)
# --- classic save with the field emptied
_, raw2 = await get("config/model?lang=en")
m2 = json.loads(raw2)
opts = copy.deepcopy(m2["options"])
opts["price_service_token"] = ""
opts["peak_cap_w"] = 4200
st, body = await post("config/save",
{"options": opts, "fingerprint": m2["fingerprint"]})
out["classic_empty"] = (st, sup.options["price_service_token"],
sup.options["peak_cap_w"])
# --- classic save with the key left out
_, raw2 = await get("config/model?lang=en")
m2 = json.loads(raw2)
opts = copy.deepcopy(m2["options"])
del opts["price_service_token"]
opts["peak_cap_w"] = 4250
st, body = await post("config/save",
{"options": opts, "fingerprint": m2["fingerprint"]})
out["classic_absent"] = (st, sup.options["price_service_token"])
# --- the new settings page: model in, with and without base
_, raw3 = await get("config/model?lang=en")
m3 = json.loads(raw3)
ui = copy.deepcopy(m3["ui"])
ui["peak_cap_w"] = 4300
st, body = await post("config/save", {
"model": ui, "base": copy.deepcopy(m3["ui"]),
"fingerprint": m3["fingerprint"]})
out["ui_base"] = (st, sup.options["price_service_token"])
_, raw3 = await get("config/model?lang=en")
m3 = json.loads(raw3)
ui = copy.deepcopy(m3["ui"])
ui["peak_cap_w"] = 4400
st, body = await post("config/save",
{"model": ui, "fingerprint": m3["fingerprint"]})
out["ui_nobase"] = (st, sup.options["price_service_token"])
# --- validate with the placeholder in: no 'too short', no echo
_, raw4 = await get("config/model?lang=en")
m4 = json.loads(raw4)
st, body = await post("config/validate", {"options": m4["options"]})
out["validate_placeholder"] = (st, json.loads(body)["problems"])
st, body = await post("config/validate", {"model": m4["ui"]})
out["validate_ui_placeholder"] = (st, json.loads(body)["problems"])
# --- a refused save (token too short) and a stale one
opts = copy.deepcopy(m4["options"])
opts["price_service_token"] = "short"
st, body = await post("config/save",
{"options": opts, "fingerprint": m4["fingerprint"]})
out["refused"] = (st, json.loads(body), sup.options["price_service_token"])
opts = copy.deepcopy(m4["options"])
opts["peak_cap_w"] = 4500
st, body = await post("config/save",
{"options": opts, "fingerprint": "not-current"})
out["stale"] = (st, json.loads(body))
# --- a refusal that is not about the token
opts = copy.deepcopy(m4["options"])
opts["max_w"] = -5
st, body = await post("config/save",
{"options": opts, "fingerprint": m4["fingerprint"]})
out["refused_other"] = (st, sup.options["price_service_token"],
sup.options["max_w"])
# --- a new value replaces
_, raw5 = await get("config/model?lang=en")
m5 = json.loads(raw5)
opts = copy.deepcopy(m5["options"])
opts["price_service_token"] = NEW_TOKEN
st, body = await post("config/save",
{"options": opts, "fingerprint": m5["fingerprint"]})
out["replaced"] = (st, sup.options["price_service_token"], body)
out["writes"] = len(sup.writes)
finally:
await runner.cleanup()
out["seen"] = seen
return out
S = asyncio.run(scenario())
print("1. the token is in no page and no JSON answer")
check("GET /config (the classic page) has no token",
TOKEN not in S["page"] and NEW_TOKEN not in S["page"])
check("GET /config/model has no token anywhere in its JSON",
TOKEN not in S["raw_model"])
for key in ("options", "running", "ui"):
check(f"...and {key} shows the placeholder, not the token",
json.dumps(S["model"][key]).count(SECRET_SHOWN) >= 1
and TOKEN not in json.dumps(S["model"][key]))
check("...the placeholder stands in the token's own place",
S["model"]["options"]["price_service_token"] == SECRET_SHOWN
and S["model"]["running"]["price_service_token"] == SECRET_SHOWN
and S["model"]["ui"]["price_service_token"] == SECRET_SHOWN)
check("a site with no token shows it empty, so the page can say 'not set'",
S["model_unset"]["options"]["price_service_token"] == ""
and S["model_unset"]["ui"]["price_service_token"] == "")
check("no answer the app gave in the whole run (page, model, validate, saves, "
"refusals, the stale one) holds the stored token",
not [t for t in S["seen"] if TOKEN in t])
check("...nor the new token, after it was saved",
not [t for t in S["seen"] if NEW_TOKEN in t])
print("\n2. the classic page draws it as a password field")
check("the field model types it as a password",
[f["type"] for f in S["field"]] == ["password"])
check("the classic page's script makes a password input for that type",
"'password'" in S["page"] and "input.type = 'password'" in S["page"])
print("\n3. saves keep the stored token unless given a new one")
st, tok, cap, body = S["classic_placeholder"]
check("a classic save with the placeholder keeps the token and saves the "
"other edit", st == 200 and tok == TOKEN and cap == 4100)
st, tok, cap = S["classic_empty"]
check("a classic save with the field emptied keeps the token",
st == 200 and tok == TOKEN and cap == 4200)
check("a classic save without the key keeps the token",
S["classic_absent"] == (200, TOKEN))
check("a save from the new settings page keeps it (with a base)",
S["ui_base"] == (200, TOKEN))
check("...and without a base", S["ui_nobase"] == (200, TOKEN))
st, tok, body = S["replaced"]
check("a new value replaces the token", st == 200 and tok == NEW_TOKEN)
check("...and the answer to that save does not echo it",
NEW_TOKEN not in body)
st, probs = S["validate_placeholder"]
check("validate with the placeholder in sees the stored token: nothing about "
"the token is refused", st == 200 and not [p for p in probs if "token" in p])
st, probs = S["validate_ui_placeholder"]
check("...also from the new page's model",
st == 200 and not [p for p in probs if "token" in p])
st, body, tok = S["refused"]
check("a refused save (token too short) is 422, says why in short plain "
"words, and writes nothing",
st == 422 and tok == TOKEN
and any("too short" in p for p in body["problems"]))
st, body = S["stale"]
check("a stale refusal is 409 and its `stored` shows the placeholder",
st == 409 and body["stored"]["price_service_token"] == SECRET_SHOWN)
check("a refusal about something else (422) writes nothing, so the token and "
"the rating stay",
S["refused_other"][0] == 422 and S["refused_other"][1] == TOKEN
and S["refused_other"][2] != -5)
print("\n4. every password option, not only this name")
other = [{"name": "mqtt_secret", "type": "string", "format": "password"},
{"name": "other_pw", "type": "password"},
{"name": "plain", "type": "string"}]
keys = configui.secret_keys(other)
check("a schema password is found in both spellings, a plain string is not",
keys == {"mqtt_secret", "other_pw"})
check("the price token is counted by name too, in case a schema is missing",
configui.secret_keys([], price.SECRET_OPTIONS)
== frozenset(price.SECRET_OPTIONS) and "price_service_token" in
price.SECRET_OPTIONS)
blob = {"mqtt_secret": "hunter2hunter2", "other_pw": "", "plain": "x",
"nested": [{"mqtt_secret": "deep-secret-value"}]}
shown = configui.hide_secrets(blob, keys)
check("hide_secrets: set -> placeholder, empty -> empty, other keys alone, "
"at any depth",
shown == {"mqtt_secret": SECRET_SHOWN, "other_pw": "", "plain": "x",
"nested": [{"mqtt_secret": SECRET_SHOWN}]}
and blob["mqtt_secret"] == "hunter2hunter2")
back = configui.keep_secrets({"mqtt_secret": SECRET_SHOWN, "other_pw": "new",
"plain": "y"}, blob, keys)
check("keep_secrets: the placeholder gives the stored value, a new value "
"stays",
back["mqtt_secret"] == "hunter2hunter2" and back["other_pw"] == "new"
and back["plain"] == "y")
check("keep_secrets: a placeholder with nothing stored is never saved as "
"the placeholder",
configui.keep_secrets({"mqtt_secret": SECRET_SHOWN}, {}, keys)
["mqtt_secret"] == "")
async def other_option_scenario():
"""A second password option, found from the schema, is hidden and kept."""
schema = SCHEMA + [{"name": "extra_secret", "type": "string",
"format": "password", "required": False,
"optional": True}]
sup = Sup(dict(SITE, extra_secret="extra-secret-value-12345"), schema)
runner, root = await serve(sup)
try:
async with ClientSession() as s:
async with s.get(root + "config/model?lang=en") as r:
raw = await r.text()
m = json.loads(raw)
opts = copy.deepcopy(m["options"])
opts["peak_cap_w"] = 4100
async with s.post(root + "config/save", json={
"options": opts, "fingerprint": m["fingerprint"]}) as r:
st = r.status
return raw, st, sup.options.get("extra_secret")
finally:
await runner.cleanup()
_raw, _st, _kept = asyncio.run(other_option_scenario())
check("a password option the add-on has no name for is hidden from the model",
"extra-secret-value-12345" not in _raw)
check("...and kept by a save that sends the placeholder",
_st == 200 and _kept == "extra-secret-value-12345")
async def named_scenario():
"""A schema that does not call the token a password (a Supervisor that
spells it another way): the add-on still hides it, by name."""
schema = [dict(n, format=None) if n["name"] == "price_service_token" else n
for n in SCHEMA]
sup = Sup(SITE, schema)
runner, root = await serve(sup)
try:
async with ClientSession() as s:
async with s.get(root + "config/model?lang=en") as r:
return await r.text()
finally:
await runner.cleanup()
check("the price token is hidden by name even when the schema does not mark "
"it as a password", TOKEN not in asyncio.run(named_scenario()))
print("\n5. PRICE-01 round 3: the fixed-tariff note on the real validate route")
_PROFILE = json.dumps({"periods": [
{"name": "all", "days": "all", "from": "00:00", "to": "24:00",
"eur_per_kwh": 0.25}]})
_NOTE = {"en": ("With the price service, the fixed tariff is only used if no "
"prices are available."),
"nl": ("Met de prijsdienst wordt het vaste tarief alleen gebruikt als "
"er geen prijzen zijn.")}
async def note_scenario(site):
runner, root = await serve(Sup(site))
out = {}
try:
async with ClientSession() as s:
for lang in ("en", "nl"):
async with s.get(root + f"config/model?lang={lang}") as r:
m = await r.json()
async with s.post(root + "config/validate",
json={"model": m["ui"], "lang": lang}) as r:
b = await r.json()
out[lang] = [x for x in b.get("messages", [])
if x["text"] in _NOTE.values()]
finally:
await runner.cleanup()
return out
_with = asyncio.run(note_scenario(dict(SITE, static_profile=_PROFILE)))
check("an English page gets the note in English, as info, beside a field",
[(x["text"], x["tier"]) for x in _with["en"]] == [(_NOTE["en"], "info")]
and _with["en"][0]["field"])
check("...and a Dutch page gets it in Dutch, beside the same field",
[(x["text"], x["tier"]) for x in _with["nl"]] == [(_NOTE["nl"], "info")]
and _with["nl"][0]["field"] == _with["en"][0]["field"])
_blank = asyncio.run(note_scenario(dict(SITE, static_profile=" ")))
check("a spaces-only fixed tariff: no note on either page",
_blank == {"en": [], "nl": []})
print("\n6. the shipped config.yaml")
cfg = open(os.path.join(HERE, "config.yaml"), encoding="utf-8").read()
schema_text = cfg[cfg.index("\nschema:"):]
pw = {line.split(":")[0].strip() for line in schema_text.splitlines()
if line.split(":", 1)[-1].strip().rstrip("?") == "password"}
check(f"every password option of config.yaml is counted as a secret ({sorted(pw)})",
pw and pw <= configui.secret_keys(SCHEMA, price.SECRET_OPTIONS))
print()
if fails:
print(f"{len(fails)} FAILED: " + "; ".join(fails))
sys.exit(1)
print("all checks passed")
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -454,7 +454,11 @@ check("the saved blob passes the normal gate: no refusal, not setup-only",
print("\n after the restart, on the saved settings:")
rc, ev, err, files = run_child("after", SAVED or {}, SAVED_STATE)
services = [e for e in ev if e[0] == "SERVICE"]
# Not the dismiss of PRICE-01's old fixed-tariff card, sent at every start.
# Only that dismiss: a `create` with the old id would still be counted.
services = [e for e in ev if e[0] == "SERVICE"
and not (e[1:3] == ["persistent_notification", "dismiss"]
and e[3].get("notification_id") == "goodwe_fixed_tariff_unused")]
writes = [e[3].get("value") for e in services
if e[1:3] == ["number", "set_value"]]
print(f" after: exit {rc}, {len(services)} service calls, set_value "
+11 -2
View File
@@ -1111,8 +1111,17 @@ check("...carrying the reason with it, so the operator who has to decide "
check("...and it is still commanding 0 W on the far side of the restart",
_b.target == 0.0 and "circulation" in _b.reason)
_notes1 = [c for c in _h1.calls if c[0] == "persistent_notification"]
_notes2 = [c for c in _h2.calls if c[0] == "persistent_notification"]
# Not the dismiss of PRICE-01's old fixed-tariff card, sent at every start.
# Only that dismiss: a `create` with the old id would still be counted.
def _not_old_dismiss(c):
return not (c[1] == "dismiss"
and c[2].get("notification_id") == "goodwe_fixed_tariff_unused")
_notes1 = [c for c in _h1.calls if c[0] == "persistent_notification"
and _not_old_dismiss(c)]
_notes2 = [c for c in _h2.calls if c[0] == "persistent_notification"
and _not_old_dismiss(c)]
print(f" notifications raised: {len(_notes1)} before the restart, "
f"{len(_notes2)} after it")
check("the alarm was raised in the first process", len(_notes1) >= 1)
+2
View File
@@ -141,6 +141,8 @@ def _node(name, spec):
lengthMin=float(m.group(2)), lengthMax=float(m.group(3)))
elif spec.startswith("list("):
out.update(type="select", options=spec[5:-1].split("|"))
elif spec == "password":
out.update(type="string", format="password")
elif spec in ("bool",):
out["type"] = "boolean"
elif spec in ("int", "float"):
+3
View File
@@ -199,6 +199,9 @@ configuration:
price_feed_url:
name: "Tariff · Price feed URL override"
description: "Leave it empty to use the source's own feed. An override is sent exactly as typed and often returns only one day."
price_service_token:
name: "Tariff · Price service token"
description: "For the price service only. The token you were given for it. Leave it empty with any other price source."
price_horizon_h:
name: "Tariff · Price look-ahead (hours)"
description: "How many hours of prices to ask the source for."
+3
View File
@@ -180,6 +180,9 @@ configuration:
price_feed_url:
name: "Tarief · Afwijkende URL voor de prijsfeed"
description: "Laat leeg om de eigen feed van de bron te gebruiken. Een afwijkende URL wordt letterlijk verstuurd en geeft vaak maar één dag terug."
price_service_token:
name: "Tarief · Token van de prijsdienst"
description: "Alleen voor de prijsdienst. Het token dat u ervoor kreeg. Laat leeg bij een andere prijsbron."
price_horizon_h:
name: "Tarief · Prijzen vooruit (uren)"
description: "Hoeveel uur aan prijzen de add-on bij de bron opvraagt."