PRICE-01 round 2: the token on no page, fixed-tariff note, token length, F3 words, test gaps
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:
co-authored by
Claude
parent
4eb88f7fda
commit
0801776645
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user