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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user