PRICE-01: own price server as a price source, with fallbacks #190
@@ -64,9 +64,18 @@ energy only; the framework adds markup and taxes, as for `generic_dayahead`.
|
|||||||
classic page draws it as a password field; a save or a validate that sends
|
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.
|
`********` or nothing keeps the stored value, any other value replaces it.
|
||||||
A token can therefore be changed but not cleared from the page.
|
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
|
7. **A fixed tariff beside `price_service` is allowed and quietly noted.** It is
|
||||||
drives the cheap window or the gate, so the add-on warns once at start
|
still the plan's last fallback (`resolve_tariff` -> `_degrade`); only the
|
||||||
(notification *Fixed tariff not used* and a log line).
|
cheap window and the gate ignore it, because they read the provider's
|
||||||
|
prices. Keeping it set is the recommended setup, and clearing it triggers the
|
||||||
|
"static_profile is empty" warning, so no notification (round 2's *Fixed
|
||||||
|
tariff not used* was false and fired on every sound start; round 3 removed
|
||||||
|
it). Instead one INFO line at start (English, as every log line) and an info
|
||||||
|
note beside the setting (EN and NL), saying "Fixed tariff is only used if no
|
||||||
|
prices are available.", both
|
||||||
|
only with a non-blank `static_profile` (whitespace counts as blank). At
|
||||||
|
start the add-on dismisses the old notification id, in case a pre-release
|
||||||
|
build raised it.
|
||||||
8. **The no-price words show only with the maintenance charge on** (owner,
|
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
|
2026-10-14): the status row, the Home line and the notification say
|
||||||
'maintenance waits', so with `maintenance_enabled` off none is shown.
|
'maintenance waits', so with `maintenance_enabled` off none is shown.
|
||||||
|
|||||||
+14
-14
@@ -2326,11 +2326,15 @@ Do this on the Configuration tab, in one save, and restart once:
|
|||||||
the provider they are added to the wholesale price, so they set the prices
|
the provider they are added to the wholesale price, so they set the prices
|
||||||
shown on the page and, if battery modes or trading are on, the prices those
|
shown on the page and, if battery modes or trading are on, the prices those
|
||||||
decide on. The maintenance charge gate does not depend on them.
|
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
|
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
|
is fine to leave it set. It is the plan's last fallback: it is used,
|
||||||
source has a price. It no longer decides *Cheap window* or the maintenance
|
marked as estimated and without trading, only when no source has a price.
|
||||||
charge: the provider's prices do. The add-on says so once at start (*Fixed
|
*Cheap window* and the maintenance charge never read it: the provider's
|
||||||
tariff not used*) and does not refuse the setting.
|
prices decide them. The add-on logs one INFO line at start ("Fixed tariff
|
||||||
|
is only used if no prices are available.") and shows the same note beside
|
||||||
|
the setting. Nothing needs clearing, and clearing it only removes the
|
||||||
|
fallback (the settings page then warns that the site has no tariff while the
|
||||||
|
price feed is down).
|
||||||
6. Remove the `price_service` REST and template sensors from Home Assistant
|
6. Remove the `price_service` REST and template sensors from Home Assistant
|
||||||
when you are satisfied. They are no longer read.
|
when you are satisfied. They are no longer read.
|
||||||
|
|
||||||
@@ -3377,16 +3381,13 @@ 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
|
the price service token is right. The notification goes by itself when a price
|
||||||
is back. It is shown only while the monthly battery cycle is switched on.
|
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
|
### For installers: notifications
|
||||||
|
|
||||||
|
A fixed tariff beside the price service raises no notification: it is the
|
||||||
|
plan's last fallback, not a fault. An earlier pre-release build raised a card
|
||||||
|
with the id `goodwe_fixed_tariff_unused`; the add-on takes that id down at every
|
||||||
|
start, which does nothing when it is absent.
|
||||||
|
|
||||||
This part is for installers. The texts live in `app/messages.py` and follow
|
This part is for installers. The texts live in `app/messages.py` and follow
|
||||||
`STYLE.md`. Every notification keeps a fixed `notification_id`, so a repeat
|
`STYLE.md`. Every notification keeps a fixed `notification_id`, so a repeat
|
||||||
replaces the card. The ERROR or WARNING line of the add-on log names the
|
replaces the card. The ERROR or WARNING line of the add-on log names the
|
||||||
@@ -3406,7 +3407,6 @@ default `info` hides it.
|
|||||||
| Meter reading may be stuck | alarm | `goodwe_meter_frozen` | SAFETY-18 |
|
| Meter reading may be stuck | alarm | `goodwe_meter_frozen` | SAFETY-18 |
|
||||||
| Battery cycle did not finish | warning | `goodwe_maint_failed_<name>` | MAINT-13 |
|
| Battery cycle did not finish | warning | `goodwe_maint_failed_<name>` | MAINT-13 |
|
||||||
| No prices | warning | `goodwe_no_prices` | PRICE-01 |
|
| 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
|
**The Details link.** Home Assistant 2026.2 and newer show add-on pages at
|
||||||
`/config/app/<slug>/documentation`; 2026.5 removed the old
|
`/config/app/<slug>/documentation`; 2026.5 removed the old
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ from .p1 import P1Ingest, build_source, is_enabled
|
|||||||
from .price import Absence, Uncertainty, brussels, price_option, provider_configured
|
from .price import Absence, Uncertainty, brussels, price_option, provider_configured
|
||||||
from .price import required_spread as price_required_spread
|
from .price import required_spread as price_required_spread
|
||||||
from .price import uncertainty_of as price_uncertainty_of
|
from .price import uncertainty_of as price_uncertainty_of
|
||||||
from .price_chain import (PriceWatch, fixed_tariff_unused, gate_for,
|
from .price_chain import (PriceWatch, fixed_tariff_beside_service, gate_for,
|
||||||
uses_chain)
|
uses_chain)
|
||||||
from .price_history import PriceHistory
|
from .price_history import PriceHistory
|
||||||
from .static_tariff import resolve_tariff, static_gated, static_status
|
from .static_tariff import resolve_tariff, static_gated, static_status
|
||||||
@@ -1214,7 +1214,7 @@ class Controller:
|
|||||||
self.price_gap = False
|
self.price_gap = False
|
||||||
self.price_gap_since = None
|
self.price_gap_since = None
|
||||||
self._price_gap_announced = False
|
self._price_gap_announced = False
|
||||||
self._fixed_unused_said = False
|
self._fixed_note_said = False
|
||||||
self._gated_key = None # MAINT-13: (options, hour) of the cache
|
self._gated_key = None # MAINT-13: (options, hour) of the cache
|
||||||
# TARIFF-03. One line of prose for the status page saying WHICH tariff
|
# 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:
|
# window we are in and why it is or is not cheap - so "Cheap window:
|
||||||
@@ -4035,7 +4035,7 @@ class Controller:
|
|||||||
# Owner, 2026-10-14: the words say 'maintenance waits', so they show
|
# Owner, 2026-10-14: the words say 'maintenance waits', so they show
|
||||||
# only while the maintenance charge is on.
|
# only while the maintenance charge is on.
|
||||||
self.price_gap = ((not gate.covered) and self.tariff_at is not None
|
self.price_gap = ((not gate.covered) and self.tariff_at is not None
|
||||||
and self.o.get("maintenance_enabled") is True)
|
and MaintConfig.from_options(self.o).enabled)
|
||||||
last = self._last_published
|
last = self._last_published
|
||||||
if gate.covered or last is None:
|
if gate.covered or last is None:
|
||||||
self.price_gap_since = None
|
self.price_gap_since = None
|
||||||
@@ -5591,7 +5591,7 @@ class Controller:
|
|||||||
# Not a safety check, so it is one of the cheap ones: an attribute test
|
# Not a safety check, so it is one of the cheap ones: an attribute test
|
||||||
# and, on a change only, one Home Assistant call.
|
# and, on a change only, one Home Assistant call.
|
||||||
await self.check_price_gap()
|
await self.check_price_gap()
|
||||||
await self.check_fixed_unused()
|
await self.check_fixed_note()
|
||||||
|
|
||||||
# --- SAFETY-31's alarm, and its boundary is the LATCH, not a number ---
|
# --- SAFETY-31's alarm, and its boundary is the LATCH, not a number ---
|
||||||
# Unconstrained where the two above are not: it grades
|
# Unconstrained where the two above are not: it grades
|
||||||
@@ -6229,18 +6229,21 @@ class Controller:
|
|||||||
await self.hass.call("persistent_notification", "dismiss",
|
await self.hass.call("persistent_notification", "dismiss",
|
||||||
messages.prices_back().data())
|
messages.prices_back().data())
|
||||||
|
|
||||||
async def check_fixed_unused(self) -> None:
|
async def check_fixed_note(self) -> None:
|
||||||
"""PRICE-01 F2. A `static_profile` beside `price_service` no longer
|
"""PRICE-01 G1. At the first cycle only: one INFO line when a
|
||||||
drives the cheap window or the maintenance gate. Not refused: said once,
|
`static_profile` is set beside `price_service` (it is the plan's last
|
||||||
at the first cycle, in the log and as one notification."""
|
fallback, not a fault, so no notification), and a dismiss of the card an
|
||||||
if self._fixed_unused_said:
|
earlier build raised, which is harmless when absent."""
|
||||||
|
if self._fixed_note_said:
|
||||||
return
|
return
|
||||||
self._fixed_unused_said = True
|
self._fixed_note_said = True
|
||||||
if fixed_tariff_unused(self.o):
|
if fixed_tariff_beside_service(self.o):
|
||||||
_LOG.warning("The fixed tariff is not used, because the price "
|
_LOG.info(messages.FIXED_TARIFF_LOG)
|
||||||
"service gives the prices.")
|
try:
|
||||||
await self.hass.call("persistent_notification", "create",
|
await self.hass.call("persistent_notification", "dismiss",
|
||||||
messages.fixed_tariff_unused().data())
|
messages.fixed_tariff_card_gone().data())
|
||||||
|
except Exception: # noqa: BLE001 - housekeeping never stops a cycle
|
||||||
|
_LOG.debug("Could not take down the old fixed-tariff card.")
|
||||||
|
|
||||||
def _gap_args(self) -> tuple:
|
def _gap_args(self) -> tuple:
|
||||||
"""`(since, now)` in Brussels time for the gap's words. `since` is None
|
"""`(since, now)` in Brussels time for the gap's words. `since` is None
|
||||||
|
|||||||
@@ -131,8 +131,6 @@ T_MAINT_FAILED = "Battery cycle did not finish"
|
|||||||
T_MAINT_FAILED_NL = "Batterijcyclus niet afgerond"
|
T_MAINT_FAILED_NL = "Batterijcyclus niet afgerond"
|
||||||
T_NO_PRICES = "No prices"
|
T_NO_PRICES = "No prices"
|
||||||
T_NO_PRICES_NL = "Prijzen ontbreken"
|
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_LOOP_BOUND = "goodwe_integrator_bound"
|
||||||
ID_OUTPUT_CLAMP = "goodwe_output_clamp"
|
ID_OUTPUT_CLAMP = "goodwe_output_clamp"
|
||||||
@@ -143,7 +141,9 @@ ID_NOT_DELIVERING = "goodwe_not_delivering_{slug}"
|
|||||||
ID_METER_FROZEN = "goodwe_meter_frozen"
|
ID_METER_FROZEN = "goodwe_meter_frozen"
|
||||||
ID_MAINT_FAILED = "goodwe_maint_failed_{slug}"
|
ID_MAINT_FAILED = "goodwe_maint_failed_{slug}"
|
||||||
ID_NO_PRICES = "goodwe_no_prices"
|
ID_NO_PRICES = "goodwe_no_prices"
|
||||||
ID_FIXED_UNUSED = "goodwe_fixed_tariff_unused"
|
# PRICE-01: an earlier build raised a card with this id. Nothing raises it now;
|
||||||
|
# the add-on only dismisses it at start.
|
||||||
|
OLD_FIXED_CARD_ID = "goodwe_fixed_tariff_unused"
|
||||||
|
|
||||||
# Which limit cut a command (clamp.ClampResult.bound, plus the loop's own).
|
# Which limit cut a command (clamp.ClampResult.bound, plus the loop's own).
|
||||||
# The setting is named by its label, "Inverter rating (W)".
|
# The setting is named by its label, "Inverter rating (W)".
|
||||||
@@ -354,17 +354,6 @@ def no_prices(since=None, now=None) -> Notice:
|
|||||||
body_nl=f"{T_NO_PRICES_NL}. {nl}")
|
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)
|
@dataclass(frozen=True)
|
||||||
class Dismiss:
|
class Dismiss:
|
||||||
"""The payload that takes one notification down again."""
|
"""The payload that takes one notification down again."""
|
||||||
@@ -379,6 +368,22 @@ def prices_back() -> Dismiss:
|
|||||||
return Dismiss(ID_NO_PRICES)
|
return Dismiss(ID_NO_PRICES)
|
||||||
|
|
||||||
|
|
||||||
|
# PRICE-01: a fixed tariff beside the price service. One true sentence, for the
|
||||||
|
# start-up log and the settings page (neither has a Dutch layer).
|
||||||
|
FIXED_TARIFF_LOG = "Fixed tariff is only used if no prices are available."
|
||||||
|
FIXED_TARIFF_NOTE = ("With the price service, the fixed tariff is only used if "
|
||||||
|
"no prices are available.")
|
||||||
|
FIXED_TARIFF_NOTE_NL = ("Met de prijsdienst wordt het vaste tarief alleen "
|
||||||
|
"gebruikt als er geen prijzen zijn.")
|
||||||
|
# Settings-page notes that have a Dutch twin (uipage.anchors).
|
||||||
|
NOTE_NL = {FIXED_TARIFF_NOTE: FIXED_TARIFF_NOTE_NL}
|
||||||
|
|
||||||
|
|
||||||
|
def fixed_tariff_card_gone() -> Dismiss:
|
||||||
|
"""PRICE-01: takes down the card an earlier build raised."""
|
||||||
|
return Dismiss(OLD_FIXED_CARD_ID)
|
||||||
|
|
||||||
|
|
||||||
def row_no_prices(since=None, now=None) -> str:
|
def row_no_prices(since=None, now=None) -> str:
|
||||||
"""PRICE-01: the status row while no price covers now."""
|
"""PRICE-01: the status row while no price covers now."""
|
||||||
if since is not None:
|
if since is not None:
|
||||||
@@ -1301,7 +1306,6 @@ def samples() -> list:
|
|||||||
no_prices(*_SINCE_SAMPLE),
|
no_prices(*_SINCE_SAMPLE),
|
||||||
no_prices(*_SINCE_OLDER),
|
no_prices(*_SINCE_OLDER),
|
||||||
no_prices(),
|
no_prices(),
|
||||||
fixed_tariff_unused(),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -50,9 +50,10 @@ def uses_chain(opts: dict) -> bool:
|
|||||||
return bool(cls is not None and (cls.FALLBACKS or cls.KEEPS_LAST_GOOD))
|
return bool(cls is not None and (cls.FALLBACKS or cls.KEEPS_LAST_GOOD))
|
||||||
|
|
||||||
|
|
||||||
def fixed_tariff_unused(opts: dict) -> bool:
|
def fixed_tariff_beside_service(opts: dict) -> bool:
|
||||||
"""PRICE-01 F2: a fixed tariff is set beside `price_service`, which then
|
"""PRICE-01: a fixed tariff is set beside `price_service`. Not a fault: it
|
||||||
decides the cheap window and the gate. Accepted, so it is said, not refused."""
|
stays the plan's last fallback (`resolve_tariff`), and only the cheap window
|
||||||
|
and the gate ignore it. A blank or whitespace-only value is not set."""
|
||||||
return (provider_id(opts) == "price_service"
|
return (provider_id(opts) == "price_service"
|
||||||
and bool(str(price_option(opts, "static_profile")).strip()))
|
and bool(str(price_option(opts, "static_profile")).strip()))
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import copy
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from . import messages
|
||||||
from . import uilayout as L
|
from . import uilayout as L
|
||||||
from . import uitext
|
from . import uitext
|
||||||
from . import uimodel as U
|
from . import uimodel as U
|
||||||
@@ -247,7 +248,7 @@ def derive_messages(errors, problems, notes, model, keys, lang="en"):
|
|||||||
kept = [p for p in problems if p in kept]
|
kept = [p for p in problems if p in kept]
|
||||||
notes = [n for n in notes if not set(n.keys or ()) & failed]
|
notes = [n for n in notes if not set(n.keys or ()) & failed]
|
||||||
return ([derive_message(err, lang) for err in errors]
|
return ([derive_message(err, lang) for err in errors]
|
||||||
+ anchors(kept, notes, model, keys))
|
+ anchors(kept, notes, model, keys, lang))
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -292,9 +293,10 @@ def targets(key: str, text: str, model: dict) -> list:
|
|||||||
return [(None, None)]
|
return [(None, None)]
|
||||||
|
|
||||||
|
|
||||||
def anchors(problems, notes, model, keys) -> list:
|
def anchors(problems, notes, model, keys, lang="en") -> list:
|
||||||
"""[{field, card, tier, text}] for the refusals and notes. `field` is
|
"""[{field, card, tier, text}] for the refusals and notes. `field` is
|
||||||
None for a sentence that names no setting on the page."""
|
None for a sentence that names no setting on the page. A note with a Dutch
|
||||||
|
twin in `messages.NOTE_NL` is shown in Dutch when `lang` is "nl"."""
|
||||||
out, seen = [], set()
|
out, seen = [], set()
|
||||||
|
|
||||||
def add(field, card, tier, text):
|
def add(field, card, tier, text):
|
||||||
@@ -312,10 +314,11 @@ def anchors(problems, notes, model, keys) -> list:
|
|||||||
add(None, None, "bad", text)
|
add(None, None, "bad", text)
|
||||||
for note in notes:
|
for note in notes:
|
||||||
tier = note.tier if note.tier in _TIERS else "info"
|
tier = note.tier if note.tier in _TIERS else "info"
|
||||||
|
text = messages.NOTE_NL.get(note.text, note.text) if lang == "nl" else note.text
|
||||||
for key in note.keys or (None,):
|
for key in note.keys or (None,):
|
||||||
for field, card in (targets(key, note.text, model) if key
|
for field, card in (targets(key, note.text, model) if key
|
||||||
else [(None, None)]):
|
else [(None, None)]):
|
||||||
add(field, card, tier, note.text)
|
add(field, card, tier, text)
|
||||||
# A sentence placed on one inverter's field is not also put on the list.
|
# A sentence placed on one inverter's field is not also put on the list.
|
||||||
roster = _roster()
|
roster = _roster()
|
||||||
placed = {m["text"] for m in out
|
placed = {m["text"] for m in out
|
||||||
|
|||||||
@@ -1659,6 +1659,12 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
|||||||
f"{S('static_profile')} is empty, so the site has no tariff "
|
f"{S('static_profile')} is empty, so the site has no tariff "
|
||||||
"at all while the price feed is down.")
|
"at all while the price feed is down.")
|
||||||
|
|
||||||
|
# PRICE-01: not a fault, so an info note and no log line here (the add-on
|
||||||
|
# logs it once at start). With `price_service` the fixed tariff is only the
|
||||||
|
# plan's last fallback; the cheap window and the gate do not read it.
|
||||||
|
if pid == "price_service" and str(opts.get("static_profile", "") or "").strip():
|
||||||
|
_advise(notes, "info", ("static_profile",), messages.FIXED_TARIFF_NOTE)
|
||||||
|
|
||||||
# ⚠️ OPT-04. THE THREE WAYS `arbitrage_enabled: true` CAN BE A LIE, refused
|
# ⚠️ OPT-04. THE THREE WAYS `arbitrage_enabled: true` CAN BE A LIE, refused
|
||||||
# rather than logged, because a strategy that is armed in the UI and cannot
|
# rather than logged, because a strategy that is armed in the UI and cannot
|
||||||
# fire is indistinguishable from one that fires and finds no opportunity -
|
# fire is indistinguishable from one that fires and finds no opportunity -
|
||||||
|
|||||||
@@ -312,7 +312,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
|||||||
_keys_of(options),
|
_keys_of(options),
|
||||||
uitext.lang_of(_body.get("lang")))
|
uitext.lang_of(_body.get("lang")))
|
||||||
if errors
|
if errors
|
||||||
else uipage.anchors(problems, notes, model, _keys_of(options)))
|
else uipage.anchors(problems, notes, model, _keys_of(options),
|
||||||
|
uitext.lang_of(_body.get("lang"))))
|
||||||
payload["steps"] = uipage.step_problems(payload["messages"], model)
|
payload["steps"] = uipage.step_problems(payload["messages"], model)
|
||||||
if _body.get("setup"):
|
if _body.get("setup"):
|
||||||
payload["setup"] = [n._asdict() for n in await _setup_checks(options)]
|
payload["setup"] = [n._asdict() for n in await _setup_checks(options)]
|
||||||
|
|||||||
@@ -415,7 +415,9 @@ def put_in_charge(c, which, hours_ago, open_min):
|
|||||||
|
|
||||||
|
|
||||||
def notes(h):
|
def notes(h):
|
||||||
return [x for x in h.calls if x[0] == "persistent_notification"]
|
# Not the dismiss of PRICE-01's old fixed-tariff card, which every start sends.
|
||||||
|
return [x for x in h.calls if x[0] == "persistent_notification"
|
||||||
|
and x[2].get("notification_id") != "goodwe_fixed_tariff_unused"]
|
||||||
|
|
||||||
|
|
||||||
for cheap in (True, False):
|
for cheap in (True, False):
|
||||||
|
|||||||
@@ -742,10 +742,16 @@ class Wired:
|
|||||||
await self.ctl.read_inputs()
|
await self.ctl.read_inputs()
|
||||||
await self.ctl.cycle()
|
await self.ctl.cycle()
|
||||||
|
|
||||||
def notes(self):
|
def all_notes(self):
|
||||||
return [(s, d) for dom, s, d in self.ctl.hass.calls
|
return [(s, d) for dom, s, d in self.ctl.hass.calls
|
||||||
if dom == "persistent_notification"]
|
if dom == "persistent_notification"]
|
||||||
|
|
||||||
|
def notes(self):
|
||||||
|
"""Without the dismiss of the old fixed-tariff card that every start
|
||||||
|
makes (section 14 looks at it through `all_notes`)."""
|
||||||
|
return [x for x in self.all_notes()
|
||||||
|
if x != ("dismiss", {"notification_id": "goodwe_fixed_tariff_unused"})]
|
||||||
|
|
||||||
|
|
||||||
async def _scenario_gap():
|
async def _scenario_gap():
|
||||||
out = {}
|
out = {}
|
||||||
@@ -1477,44 +1483,130 @@ check("battery modes with no price and maintenance off: no price run is "
|
|||||||
print(" Home plan lines in that state:", _ml)
|
print(" Home plan lines in that state:", _ml)
|
||||||
|
|
||||||
# =========================================================================== #
|
# =========================================================================== #
|
||||||
print("\n14. round 2: a fixed tariff beside price_service is said once (F2)")
|
print("\n14. round 3: a fixed tariff beside price_service is a log line and a "
|
||||||
|
"settings note, no notification (G1, S05)")
|
||||||
_PROFILE = json.dumps({"periods": [
|
_PROFILE = json.dumps({"periods": [
|
||||||
{"name": "all", "days": "all", "from": "00:00", "to": "24:00",
|
{"name": "all", "days": "all", "from": "00:00", "to": "24:00",
|
||||||
"eur_per_kwh": 0.25}]})
|
"eur_per_kwh": 0.25}]})
|
||||||
|
_LINE = "Fixed tariff is only used if no prices are available."
|
||||||
|
_NOTE = ("With the price service, the fixed tariff is only used if no prices "
|
||||||
|
"are available.")
|
||||||
|
_OLD_ID = "goodwe_fixed_tariff_unused"
|
||||||
|
|
||||||
|
|
||||||
async def _scenario_fixed(**over):
|
async def _scenario_fixed(**over):
|
||||||
with Wired(NOW, **over) as W:
|
with Wired(NOW, **over) as W:
|
||||||
|
before = mark()
|
||||||
await W.refresh()
|
await W.refresh()
|
||||||
for k in range(4):
|
for k in range(4):
|
||||||
await W.tick(NOW + timedelta(seconds=30 * k))
|
await W.tick(NOW + timedelta(seconds=30 * k))
|
||||||
return [x for x in W.notes() if x[1].get("notification_id")
|
said = [m for m in since_mark(before, logging.INFO) if "only used if no prices" in m]
|
||||||
== "goodwe_fixed_tariff_unused"], W.ctl
|
return said, W.all_notes(), W.ctl
|
||||||
|
|
||||||
|
|
||||||
_before = mark()
|
def _settings_notes(**over):
|
||||||
_f, _fc = run(_scenario_fixed(static_profile=_PROFILE))
|
base = dict(OPTS_C)
|
||||||
_logged = [m for m in since_mark(_before, logging.WARNING)
|
base.update(price_provider="price_service", price_service_token=TOKEN)
|
||||||
if "fixed tariff" in m]
|
base.update(over)
|
||||||
check("price_service with a static_profile: ONE notification over four "
|
out = []
|
||||||
|
validate_startup_config(base, None, None, notes=out)
|
||||||
|
return [n.text for n in out if n.keys == ("static_profile",)
|
||||||
|
and "fixed tariff" in n.text.lower()]
|
||||||
|
|
||||||
|
|
||||||
|
_said, _fn, _fc = run(_scenario_fixed(static_profile=_PROFILE))
|
||||||
|
check("price_service + a set profile: the INFO line appears once over four "
|
||||||
"ticks, and the setting is not refused",
|
"ticks, and the setting is not refused",
|
||||||
len(_f) == 1 and _f[0][0] == "create"
|
_said == [_LINE] and problems(static_profile=_PROFILE) == [])
|
||||||
and problems(static_profile=_PROFILE) == [])
|
check("...no notification is raised, only the old id is dismissed",
|
||||||
check("...in English and Dutch, plain and short",
|
[x for x in _fn if x[1].get("notification_id") == _OLD_ID]
|
||||||
_f[0][1]["title"] == "Fixed tariff not used"
|
== [("dismiss", {"notification_id": _OLD_ID})]
|
||||||
and "The fixed tariff is not used, because the price service gives the "
|
and not [x for x in _fn if x[0] == "create"])
|
||||||
"prices." in _f[0][1]["message"]
|
for _label, _kw in (("no profile", {}),
|
||||||
and "Vast tarief niet gebruikt. Het vaste tarief wordt niet gebruikt, "
|
("an empty profile", {"static_profile": ""}),
|
||||||
"want de prijsdienst levert de prijzen." in _f[0][1]["message"])
|
("a spaces-only profile", {"static_profile": " "}),
|
||||||
check("...and one WARNING line in the log",
|
("a line-break-only profile", {"static_profile": "\n \t"}),
|
||||||
_logged == ["The fixed tariff is not used, because the price service "
|
("another provider", {"price_provider": "generic_dayahead",
|
||||||
"gives the prices."])
|
"static_profile": _PROFILE})):
|
||||||
check("no static_profile: nothing is said",
|
_s, _n, _c = run(_scenario_fixed(**_kw))
|
||||||
run(_scenario_fixed())[0] == [])
|
check(f"{_label}: no log line", _s == [])
|
||||||
check("another provider with a static_profile: nothing is said (there it is "
|
check(f"{_label}: the old id is still dismissed at start (harmless)",
|
||||||
"the fallback)",
|
[x for x in _n if x[1].get("notification_id") == _OLD_ID]
|
||||||
run(_scenario_fixed(price_provider="generic_dayahead",
|
== [("dismiss", {"notification_id": _OLD_ID})])
|
||||||
static_profile=_PROFILE))[0] == [])
|
_off_s, _, _ = run(_scenario_fixed(price_provider="off",
|
||||||
|
static_profile=_PROFILE))
|
||||||
|
check("provider off with a profile: no log line", _off_s == [])
|
||||||
|
check("the log line is the same words as the note's second half",
|
||||||
|
_NOTE.endswith(_LINE[0].lower() + _LINE[1:]))
|
||||||
|
|
||||||
|
check("settings page: price_service + a set profile shows the one note, "
|
||||||
|
"beside static_profile", _settings_notes(static_profile=_PROFILE) == [_NOTE])
|
||||||
|
check("...not with no, empty, spaces-only or line-break-only profile",
|
||||||
|
all(_settings_notes(**kw) == [] for kw in (
|
||||||
|
{}, {"static_profile": ""}, {"static_profile": " "},
|
||||||
|
{"static_profile": "\n \t"})))
|
||||||
|
check("...not with another provider",
|
||||||
|
_settings_notes(price_provider="generic_dayahead",
|
||||||
|
static_profile=_PROFILE) == []
|
||||||
|
and _settings_notes(price_provider="off",
|
||||||
|
static_profile=_PROFILE) == [])
|
||||||
|
_nn = []
|
||||||
|
validate_startup_config(dict(OPTS_C, price_provider="price_service",
|
||||||
|
price_service_token=TOKEN,
|
||||||
|
static_profile=_PROFILE), None, None, notes=_nn)
|
||||||
|
_NOTE_NL = ("Met de prijsdienst wordt het vaste tarief alleen gebruikt als er "
|
||||||
|
"geen prijzen zijn.")
|
||||||
|
_en = _UP.anchors([], _nn, {"price_provider": "price_service"},
|
||||||
|
["price_provider", "static_profile"], "en")
|
||||||
|
_nl = _UP.anchors([], _nn, {"price_provider": "price_service"},
|
||||||
|
["price_provider", "static_profile"], "nl")
|
||||||
|
check("the page puts the note beside the fixed-tariff field, in English and "
|
||||||
|
f"Dutch ({_en})",
|
||||||
|
[m["text"] for m in _en] == [_NOTE] and [m["text"] for m in _nl]
|
||||||
|
== [_NOTE_NL] and _en[0]["tier"] == "info" and _en[0]["field"]
|
||||||
|
and _en[0]["field"] == _nl[0]["field"])
|
||||||
|
check("...and the note names no refusal: the options are still accepted",
|
||||||
|
problems(static_profile=_PROFILE) == [])
|
||||||
|
|
||||||
|
|
||||||
|
async def _scenario_profile_roles(profile):
|
||||||
|
"""Prices up: what the cheap window and the gate say. Then every source
|
||||||
|
down: what the plan falls back to."""
|
||||||
|
out = {}
|
||||||
|
over = {"static_profile": profile} if profile else {}
|
||||||
|
with Wired(NOW, **over) as W:
|
||||||
|
c = W.ctl
|
||||||
|
await W.refresh()
|
||||||
|
await W.tick()
|
||||||
|
out["cheap"], out["gate"] = c.cheap, c._charge_gate_open(c.inverters[0])
|
||||||
|
out["words"] = c.tariff_words
|
||||||
|
out["origin_up"] = c.tariff_plan.origin
|
||||||
|
W.net.down()
|
||||||
|
later = NOW + timedelta(days=5) # saved prices run out
|
||||||
|
await W.refresh(later)
|
||||||
|
await W.tick(later)
|
||||||
|
out["origin_down"] = c.tariff_plan.origin
|
||||||
|
out["gate_down"] = c._charge_gate_open(c.inverters[0])
|
||||||
|
out["points"] = c.tariff_plan.read.match(
|
||||||
|
series=lambda r: [pt.eur_per_kwh for pt in r.points][:2],
|
||||||
|
no_data=lambda r: [])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_with = run(_scenario_profile_roles(_PROFILE))
|
||||||
|
_without = run(_scenario_profile_roles(None))
|
||||||
|
check("with prices up, the cheap window and the gate are the same with or "
|
||||||
|
"without a fixed tariff set",
|
||||||
|
(_with["cheap"], _with["gate"], _with["words"])
|
||||||
|
== (_without["cheap"], _without["gate"], _without["words"])
|
||||||
|
and _with["origin_up"] == "provider")
|
||||||
|
check("with every source down, the plan falls back to the fixed tariff "
|
||||||
|
f"({_with['origin_down']}, {_with['points']}) and without it there is none",
|
||||||
|
_with["origin_down"] == "fallback" and _with["points"]
|
||||||
|
and set(_with["points"]) == {0.25}
|
||||||
|
and _without["origin_down"] == "none")
|
||||||
|
check("...and the gate stays shut on that fallback (it is not a firm price)",
|
||||||
|
_with["gate_down"] is False)
|
||||||
|
|
||||||
# =========================================================================== #
|
# =========================================================================== #
|
||||||
print("\n15. round 2: a token shorter than 16 characters is refused (F5)")
|
print("\n15. round 2: a token shorter than 16 characters is refused (F5)")
|
||||||
|
|||||||
@@ -454,7 +454,9 @@ check("the saved blob passes the normal gate: no refusal, not setup-only",
|
|||||||
|
|
||||||
print("\n after the restart, on the saved settings:")
|
print("\n after the restart, on the saved settings:")
|
||||||
rc, ev, err, files = run_child("after", SAVED or {}, SAVED_STATE)
|
rc, ev, err, files = run_child("after", SAVED or {}, SAVED_STATE)
|
||||||
services = [e for e in ev if e[0] == "SERVICE"]
|
# Not the dismiss of PRICE-01's old fixed-tariff card, sent at every start.
|
||||||
|
services = [e for e in ev if e[0] == "SERVICE"
|
||||||
|
and e[3].get("notification_id") != "goodwe_fixed_tariff_unused"]
|
||||||
writes = [e[3].get("value") for e in services
|
writes = [e[3].get("value") for e in services
|
||||||
if e[1:3] == ["number", "set_value"]]
|
if e[1:3] == ["number", "set_value"]]
|
||||||
print(f" after: exit {rc}, {len(services)} service calls, set_value "
|
print(f" after: exit {rc}, {len(services)} service calls, set_value "
|
||||||
|
|||||||
@@ -1111,8 +1111,11 @@ check("...carrying the reason with it, so the operator who has to decide "
|
|||||||
check("...and it is still commanding 0 W on the far side of the restart",
|
check("...and it is still commanding 0 W on the far side of the restart",
|
||||||
_b.target == 0.0 and "circulation" in _b.reason)
|
_b.target == 0.0 and "circulation" in _b.reason)
|
||||||
|
|
||||||
_notes1 = [c for c in _h1.calls if c[0] == "persistent_notification"]
|
# Not the dismiss of PRICE-01's old fixed-tariff card, sent at every start.
|
||||||
_notes2 = [c for c in _h2.calls if c[0] == "persistent_notification"]
|
_notes1 = [c for c in _h1.calls if c[0] == "persistent_notification"
|
||||||
|
and c[2].get("notification_id") != "goodwe_fixed_tariff_unused"]
|
||||||
|
_notes2 = [c for c in _h2.calls if c[0] == "persistent_notification"
|
||||||
|
and c[2].get("notification_id") != "goodwe_fixed_tariff_unused"]
|
||||||
print(f" notifications raised: {len(_notes1)} before the restart, "
|
print(f" notifications raised: {len(_notes1)} before the restart, "
|
||||||
f"{len(_notes2)} after it")
|
f"{len(_notes2)} after it")
|
||||||
check("the alarm was raised in the first process", len(_notes1) >= 1)
|
check("the alarm was raised in the first process", len(_notes1) >= 1)
|
||||||
|
|||||||
Reference in New Issue
Block a user