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
This commit is contained in:
glenn schrooyen
2026-10-14 18:56:51 +02:00
co-authored by Claude
parent 4eb88f7fda
commit 0801776645
12 changed files with 767 additions and 17 deletions
@@ -55,8 +55,21 @@ energy only; the framework adds markup and taxes, as for `generic_dayahead`.
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, and a token with a
space or line break.
name. validate.py refuses `price_service` with no token, a token with a
space or line break, and one under 16 characters (so `scrub()`, which skips
values under 4 characters, always has something to take out). Round 2: the
token is in no page and no JSON answer of the web app. Every `password`
option of the schema is shown as `********` (set) or empty, in
`/config/model` (`options`, `running`, `ui`) and in a stale refusal; the
classic page draws it as a password field; a save or a validate that sends
`********` or nothing keeps the stored value, any other value replaces it.
A token can therefore be changed but not cleared from the page.
7. **A fixed tariff beside `price_service` is said, not refused.** It no longer
drives the cheap window or the gate, so the add-on warns once at start
(notification *Fixed tariff not used* and a log line).
8. **The no-price words show only with the maintenance charge on** (owner,
2026-10-14): the status row, the Home line and the notification say
'maintenance waits', so with `maintenance_enabled` off none is shown.
## Where the gate equals the Home Assistant pair, and where it does not
+16 -4
View File
@@ -291,7 +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, shown masked on the Configuration tab and never drawn on the add-on page. 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).
- **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.
@@ -2294,7 +2294,8 @@ 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. "Since" is the moment the last price ran out.
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
@@ -2327,7 +2328,9 @@ Do this on the Configuration tab, in one save, and restart once:
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 still the tariff used, marked as estimated and without trading, while no
source has a price.
source has a price. It no longer decides *Cheap window* or the maintenance
charge: the provider's prices do. The add-on says so once at start (*Fixed
tariff not used*) and does not refuse the setting.
6. Remove the `price_service` REST and template sensors from Home Assistant
when you are satisfied. They are no longer read.
@@ -3372,7 +3375,15 @@ 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.
is back. It is shown only while the monthly battery cycle is switched on.
### Fixed tariff not used
A fixed or day/night tariff (`static_profile`) is set, and so is the price
service. The price service gives the prices, so the fixed tariff does not
decide *Cheap window* or the monthly battery cycle's charge. The setting is
kept and not refused. Clear it, or choose another price source, to remove the
message. It is shown once each time the add-on starts.
### For installers: notifications
@@ -3395,6 +3406,7 @@ default `info` hides it.
| 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 |
| Fixed tariff not used | warning | `goodwe_fixed_tariff_unused` | 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
+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.
+21 -2
View File
@@ -90,7 +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, gate_for, uses_chain
from .price_chain import (PriceWatch, fixed_tariff_unused, 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
@@ -1213,6 +1214,7 @@ class Controller:
self.price_gap = False
self.price_gap_since = None
self._price_gap_announced = False
self._fixed_unused_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:
@@ -4030,7 +4032,10 @@ class Controller:
gate = gate_for(published, now)
self.cheap, self._price_gated = gate.cheap, True
self.tariff_words = gate.words
self.price_gap = (not gate.covered) and self.tariff_at is not None
# 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 self.o.get("maintenance_enabled") is True)
last = self._last_published
if gate.covered or last is None:
self.price_gap_since = None
@@ -5586,6 +5591,7 @@ class Controller:
# 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_unused()
# --- SAFETY-31's alarm, and its boundary is the LATCH, not a number ---
# Unconstrained where the two above are not: it grades
@@ -6223,6 +6229,19 @@ class Controller:
await self.hass.call("persistent_notification", "dismiss",
messages.prices_back().data())
async def check_fixed_unused(self) -> None:
"""PRICE-01 F2. A `static_profile` beside `price_service` no longer
drives the cheap window or the maintenance gate. Not refused: said once,
at the first cycle, in the log and as one notification."""
if self._fixed_unused_said:
return
self._fixed_unused_said = True
if fixed_tariff_unused(self.o):
_LOG.warning("The fixed tariff is not used, because the price "
"service gives the prices.")
await self.hass.call("persistent_notification", "create",
messages.fixed_tariff_unused().data())
def _gap_args(self) -> tuple:
"""`(since, now)` in Brussels time for the gap's words. `since` is None
when no price was ever published."""
+15
View File
@@ -131,6 +131,8 @@ 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"
T_FIXED_UNUSED = "Fixed tariff not used"
T_FIXED_UNUSED_NL = "Vast tarief niet gebruikt"
ID_LOOP_BOUND = "goodwe_integrator_bound"
ID_OUTPUT_CLAMP = "goodwe_output_clamp"
@@ -141,6 +143,7 @@ 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"
ID_FIXED_UNUSED = "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)".
@@ -351,6 +354,17 @@ def no_prices(since=None, now=None) -> Notice:
body_nl=f"{T_NO_PRICES_NL}. {nl}")
def fixed_tariff_unused() -> Notice:
"""PRICE-01: a fixed tariff is set beside the price service, which wins.
Said once, at start."""
return Notice(
ID_FIXED_UNUSED, WARNING, T_FIXED_UNUSED,
"The fixed tariff is not used, because the price service gives the "
"prices.",
body_nl=f"{T_FIXED_UNUSED_NL}. Het vaste tarief wordt niet gebruikt, "
"want de prijsdienst levert de prijzen.")
@dataclass(frozen=True)
class Dismiss:
"""The payload that takes one notification down again."""
@@ -1287,6 +1301,7 @@ def samples() -> list:
no_prices(*_SINCE_SAMPLE),
no_prices(*_SINCE_OLDER),
no_prices(),
fixed_tariff_unused(),
]
+8 -1
View File
@@ -28,7 +28,7 @@ from dataclasses import dataclass
from datetime import datetime
from .price import (AUTH_REFUSED, PROVIDERS, PriceRead, brussels,
provider_id, scrub)
price_option, provider_id, scrub)
_LOG = logging.getLogger("goodwe.price.chain")
@@ -50,6 +50,13 @@ def uses_chain(opts: dict) -> bool:
return bool(cls is not None and (cls.FALLBACKS or cls.KEEPS_LAST_GOOD))
def fixed_tariff_unused(opts: dict) -> bool:
"""PRICE-01 F2: a fixed tariff is set beside `price_service`, which then
decides the cheap window and the gate. Accepted, so it is said, not refused."""
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
+9
View File
@@ -1721,6 +1721,10 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
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
@@ -1778,6 +1782,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
+26 -8
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)
@@ -339,9 +352,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 +373,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 +392,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
+373
View File
@@ -0,0 +1,373 @@
"""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. 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")
+229
View File
@@ -622,6 +622,7 @@ print("\n6. no price for now: one status line, one notification, cleared")
OPTS_C = {"setpoint_entity": "number.sp", "max_w": 5000,
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "meter_entity": "sensor.grid",
"maintenance_enabled": True,
"maintenance_charge_w": 2000, "maintenance_discharge_w": 2000}
@@ -1366,6 +1367,234 @@ check("the chain logs under goodwe.price.chain",
if "price source" in r.getMessage() or "price service"
in r.getMessage()))
# =========================================================================== #
print("\n13. round 2: the no-price words only with the maintenance charge on (F3)")
async def _scenario_maint(on):
"""Every source down, six ticks, then the prices come back. `on` is the
maintenance switch."""
from app import homeview
with Wired(NOW, maintenance_enabled=on) as W:
c = W.ctl
W.net.down()
await W.refresh()
for k in range(6):
await W.tick(NOW + timedelta(seconds=30 * k))
mid = {"gap": c.price_gap, "notes": list(W.notes()),
"row": dict(c.status_payload()["rows"]).get("Prices"),
"home": [x for x in homeview.build_plan(
c, W.clock.now, False, True)["lines"]
if "No prices" in x],
"gate": c._charge_gate_open(c.inverters[0]),
"cheap": c.cheap}
W.net.up()
await W.refresh(NOW + timedelta(minutes=30))
await W.tick(NOW + timedelta(minutes=30))
return mid, list(W.notes())
_on_mid, _on_all = run(_scenario_maint(True))
_off_mid, _off_all = run(_scenario_maint(False))
check("maintenance on: the row, the Home line and ONE notification appear",
_on_mid["gap"] is True and len(_on_mid["notes"]) == 1
and _on_mid["row"] == "No prices yet, maintenance waits."
and _on_mid["home"] == ["No prices yet, maintenance waits."])
check("...and the notification is taken down once when prices return",
[n[0] for n in _on_all] == ["create", "dismiss"])
check("maintenance off: no row, no Home line, no notification, at any tick",
_off_mid["gap"] is False and _off_mid["notes"] == []
and _off_mid["row"] is None and _off_mid["home"] == []
and _off_all == [])
check("...and the gate itself does not change with the switch: still shut "
"with no price",
_off_mid["gate"] is False and _on_mid["gate"] is False
and _off_mid["cheap"] is False)
async def _scenario_switched_off():
"""A notification raised with the cycle on is cleared when it is off."""
with Wired(NOW) as W:
c = W.ctl
W.net.down()
await W.refresh()
await W.tick(NOW)
raised = list(W.notes())
c.o["maintenance_enabled"] = False
await W.tick(NOW + timedelta(seconds=30))
await W.tick(NOW + timedelta(seconds=60))
return raised, list(W.notes()), c.price_gap
_r, _n, _g = run(_scenario_switched_off())
check("a notification raised while the cycle was on is cleared, once, when "
"it is off",
len(_r) == 1 and [x[0] for x in _n] == ["create", "dismiss"]
and _g is False)
async def _modes_no_price():
"""Battery modes on, maintenance off, no price anywhere: what happens."""
from app import packmode, homeview
with Wired(NOW, battery_modes_enabled=True, capacity_kwh=10,
maintenance_enabled=False) as W:
c = W.ctl
W.net.down()
await W.refresh()
c.store = FakeStore()
c.store.data = {packmode.unit_key("inverter-a", packmode.MODE_KEY):
packmode.CHEAP_HOURS}
c.inverters[0].soc, c.inverters[0].capacity_kwh = 40.0, 10.0
await W.tick(NOW)
runs = c._price_runs(NOW, c._mode_packs(), 20, 95)
plan = homeview.build_plan(c, NOW, False, True)["lines"]
return runs, c.price_gap, list(W.notes()), plan
_mr, _mg, _mn, _ml = run(_modes_no_price())
check("battery modes with no price and maintenance off: no price run is "
"made (so no battery holds or trades on price), and nothing about "
"'maintenance waits' is shown or notified",
_mr is None and _mg is False and _mn == []
and not [x for x in _ml if "maintenance waits" in x])
print(" Home plan lines in that state:", _ml)
# =========================================================================== #
print("\n14. round 2: a fixed tariff beside price_service is said once (F2)")
_PROFILE = json.dumps({"periods": [
{"name": "all", "days": "all", "from": "00:00", "to": "24:00",
"eur_per_kwh": 0.25}]})
async def _scenario_fixed(**over):
with Wired(NOW, **over) as W:
await W.refresh()
for k in range(4):
await W.tick(NOW + timedelta(seconds=30 * k))
return [x for x in W.notes() if x[1].get("notification_id")
== "goodwe_fixed_tariff_unused"], W.ctl
_before = mark()
_f, _fc = run(_scenario_fixed(static_profile=_PROFILE))
_logged = [m for m in since_mark(_before, logging.WARNING)
if "fixed tariff" in m]
check("price_service with a static_profile: ONE notification over four "
"ticks, and the setting is not refused",
len(_f) == 1 and _f[0][0] == "create"
and problems(static_profile=_PROFILE) == [])
check("...in English and Dutch, plain and short",
_f[0][1]["title"] == "Fixed tariff not used"
and "The fixed tariff is not used, because the price service gives the "
"prices." in _f[0][1]["message"]
and "Vast tarief niet gebruikt. Het vaste tarief wordt niet gebruikt, "
"want de prijsdienst levert de prijzen." in _f[0][1]["message"])
check("...and one WARNING line in the log",
_logged == ["The fixed tariff is not used, because the price service "
"gives the prices."])
check("no static_profile: nothing is said",
run(_scenario_fixed())[0] == [])
check("another provider with a static_profile: nothing is said (there it is "
"the fallback)",
run(_scenario_fixed(price_provider="generic_dayahead",
static_profile=_PROFILE))[0] == [])
# =========================================================================== #
print("\n15. round 2: a token shorter than 16 characters is refused (F5)")
_short = problems(price_service_token="a" * 15)
check(f"15 characters: refused with one short plain sentence ({_short})",
_short == ["“Price service token” (price_service_token) is too short. "
"Paste the whole token."])
check("...16 characters start", problems(price_service_token="a" * 16) == [])
check("...the refusal does not echo the value",
"a" * 15 not in "".join(_short))
# =========================================================================== #
print("\n16. round 2: the guards the reviewer's mutations R01 and R10 got past")
_t0 = local_midnight(date(2026, 10, 14))
_now0 = _t0 + timedelta(minutes=31) # inside period 2
_pts = [P.PricePoint(_t0 + timedelta(minutes=15 * i), v, i == 2)
for i, v in enumerate((0.10, 0.20, 0.05, 0.40))]
_prov = P.PriceRead.series(_pts, PriceProduct.DAY_AHEAD, "price_service", NOW)
_g = C.gate_for(_prov, _now0)
check("R01: if the quarter holding now is an estimate it is not priced as "
"firm, even though it is cheap: the gate is shut and says 'no price'",
_g.cheap is False and _g.covered is False
and _g.words == "No price for now")
_firm = P.PriceRead.series(
[P.PricePoint(p.start, p.eur_per_kwh, False) for p in _pts],
PriceProduct.DAY_AHEAD, "price_service", NOW)
check("...the same series with every quarter firm opens (it is cheap), so "
"only the estimate flag shut it",
C.gate_for(_firm, _now0).cheap is True
and C.gate_for(_firm, _now0).covered is True)
_prov2 = P.PriceRead.series(
[P.PricePoint(_t0 + timedelta(minutes=15 * i), v, i == 3)
for i, v in enumerate((0.10, 0.15, 0.05, 0.40))],
PriceProduct.DAY_AHEAD, "price_service", NOW)
check("an estimated quarter elsewhere in the day stays out of today's "
"average: 0.15 against 0.10, 0.15, 0.05 is above their average (0.10), "
"where with the estimated 0.40 counted it would be cheap (0.175)",
C.gate_for(_prov2, _t0 + timedelta(minutes=16)).cheap is False)
async def _plain_wire():
from aiohttp import web, ClientResponseError
seen, elsewhere = [], []
async def ok(request):
seen.append(dict(request.headers))
return web.Response(body=b'{"ok": 1}')
async def moved(request):
raise web.HTTPFound("/elsewhere")
async def other(request):
elsewhere.append(1)
return web.Response(body=b'{"ok": 2}')
async def missing(request):
return web.Response(status=404, text="no")
async def broken(request):
return web.Response(status=503, text="no")
app = web.Application()
for path, fn in (("/ok", ok), ("/moved", moved), ("/elsewhere", other),
("/missing", missing), ("/broken", broken)):
app.router.add_get(path, fn)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", 0)
await site.start()
base = f"http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}"
res = {}
try:
res["ok"] = await P.https_get(base + "/ok", 3)
res["moved"] = await P.https_get(base + "/moved", 3)
for name in ("missing", "broken"):
try:
await P.https_get(base + "/" + name, 3)
res[name] = "no error"
except ClientResponseError as e:
res[name] = e.status
except Exception as e: # noqa: BLE001
res[name] = "other " + type(e).__name__
finally:
await runner.cleanup()
res["headers"] = seen[0] if seen else {}
res["followed"] = len(elsewhere)
return res
_pw = run(_plain_wire())
check("R10: the plain transport the other providers use returns the body "
"of a 200", _pw["ok"] == b'{"ok": 1}')
check("...follows a redirect, as it always did (the price service's own "
"path does not)",
_pw["moved"] == b'{"ok": 2}' and _pw["followed"] == 1)
check("...raises the library's own error for a 4xx and a 5xx, not the price "
"service's words",
_pw["missing"] == 404 and _pw["broken"] == 503)
check("...and sends no Authorization header of its own",
"Authorization" not in _pw["headers"])
for d in _TMP:
shutil.rmtree(d, ignore_errors=True)
+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"):