Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 5.5 df6bc0f76b SAFETY-112 review: grade the waiting flag where hass.py sets it
checks / unit (pull_request) Successful in 36m27s
infra-pin / fresh (pull_request) Successful in 1s
- a 502 or refused read turns waiting on, the next good read off, and a
  read timeout never (two mutants survived without this)
- the flag changes no write with live readings, not only with 502 ones
- the page script's amber dot and batteries-tab banner for "waiting"
- DECISIONS: about 5 minutes per start stage, not "at most"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:10:27 +02:00
adminandClaude Opus 5.5 d94cb0c263 SAFETY-112: say plainly that control waits for Home Assistant to start
checks / unit (pull_request) Successful in 36m38s
infra-pin / fresh (pull_request) Successful in 2s
The Supervisor proxy answers 502 to every add-on read, write and websocket
until Core is RUNNING (proxy.py api()/websocket() -> check_api_state()), so
the add-on cannot control while a slow integration holds HA's start, even
with its entities live. Measured on a safety112 repro stack with the
2026.08.0 proxy code: first write 1.0-4.4 s after RUNNING, 238-300 s after
live readings. Not routed around (DECISIONS/SAFETY-112-...).

While the API does not answer, the first status row is red and is the
banner ("Waiting for Home Assistant to finish starting"), Home shows a
"waiting" state in EN/NL instead of "Needs you now", and the log adds a
"still waiting" line every 5 minutes and the wait's length at the end.
A safety stop, a stalled loop or shadow mode keeps its own headline.
Control, writes, heartbeat and the stand-down are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:07:51 +02:00
admin 3050fa5208 OPT-17: the solar forecast can come from several entities, summed (#187)
infra-pin / fresh (push) Successful in 1s
2026-10-11 05:49:37 +02:00
adminandClaude Opus 5.5 92a80c09b2 OPT-17 review: title the forecast row on Home; test the hint and blank rows
checks / unit (pull_request) Successful in 36m20s
infra-pin / fresh (pull_request) Successful in 2s
The new status row's id `solar_forecast` had no Home title, so Home showed
it under the fallback "Inverter connection". Add "Solar forecast" /
"Zonneprognose" to ui_text, and checks for the title, the tomorrow-gap hint
and blank list rows (review mutations R7, R9, R14 now killed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-11 05:12:35 +02:00
glenn schrooyenandClaude Opus 5.5 9b1fa758ad OPT-17: the solar forecast can come from several entities, summed
checks / unit (pull_request) Successful in 36m13s
infra-pin / fresh (pull_request) Successful in 1s
New list options pv_forecast_entities and pv_forecast_tomorrow_entities
(same shape as pv_entities); the single-entity keys are unchanged.

- Each member is read in its own unit (Wh/kWh/MWh) in one gather and the
  day is summed. Hourly splits are merged when every member has one, else
  the total goes on the bell.
- A member missing, unavailable, stale, non-energy or bad makes that
  day's forecast unusable (no partial sum). A caution row names it by
  position; the action log names the entity once per change.
- Single key and list both set for one day: refused at start.
- A one-member list behaves exactly like the single key (test_opt17.py);
  the single-key path is byte-identical to e36565f over 24 scenarios.
- Supervisor shape measured: tools/supervisor_shape_probe.py section J.

DECISIONS/OPT-17-several-forecast-entities.md has the rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-11 02:55:58 +02:00
21 changed files with 1518 additions and 67 deletions
@@ -0,0 +1,79 @@
# OPT-17 - the solar forecast from several entities, summed
**Status: for review.** Tests: `test_opt17.py`. Two options added.
A site whose forecast comes as several Home Assistant sensors (one per
inverter or roof side) had to build a template sensor to sum them. Now each
day can list them.
## Options
- `pv_forecast_entities` and `pv_forecast_tomorrow_entities`: lists of
sensors, default `[]`, the same shape as `pv_entities`.
- `pv_forecast_entity` and `pv_forecast_tomorrow_entity` are unchanged. An
install that uses them needs no edit.
- **Why new keys, not "string or list" in the old ones.** Measured on
Supervisor 2026.08.0 by `tools/supervisor_shape_probe.py` §J, as TEL-12's
§G did for `pv_entity`. Declared as a list, each forecast key refuses the
string an install already stores (`Invalid list for option
'pv_forecast_entity'`), and a `[]` default does not rescue it. The one
spelling that takes both, `match(.*)?`, also takes the integer 417 and
shows a plain text box, so a second sensor cannot be added in the UI.
- §J also shows: a 0.17.0 blob validates unchanged with the lists at `[]`;
the lists render as repeatable fields; a downgrade drops them and keeps
the single keys.
- `[]` in `options:` is load-bearing, as for `pv_entities`.
## Rules
1. **One key per day.** Setting the single key and the list for the same day
is refused at start, with one message: keep the list, clear the single one.
The list can hold that entity too. (TEL-12 lets `pv_entities` replace
`pv_entity` with a caution row. Here the brief asked for a refusal: a
doubled or dropped forecast changes what a battery buys, and no install
has both keys yet, so the refusal breaks nothing.)
2. **Each member in its own unit.** Every member is read as one forecast
sensor is today (`solarfc._day()`): Wh, kWh or MWh, never guessed. Then the
day's kWh are added up. A repeated id counts once.
3. **One member not usable means the day is not usable.** Missing,
unavailable, unknown, stale, not an energy unit, or a bad number: that
day's sum is not used, exactly as when the single sensor reads that way.
No today means no forecast (P3 option A). No tomorrow means today only.
4. **Hourly split.** If every member has a split, the splits are joined
(overlapping periods add up). If any member has none, the sum is spread
over the bell, as for a state-only sensor.
5. **Time.** The forecast is as old as its oldest member.
6. **One read.** All members of both days are read in one gather, inside the
gather that reads the inverters, so a slow sensor does not delay control.
7. **Shown and logged once.** With two or more members, each one not usable
is named on the status page by its place in the list (STYLE.md: no entity
ids on the page), as a caution row titled "Solar forecast" on Home. The
action log names the entity id, once when the set of failing members
changes.
8. **One member is the single key.** A one-member list reads, plans, shows
and logs exactly as the single key with that entity.
## Why
- A partial sum is a smaller number that looks real. Two of three inverters'
forecasts read as a duller day, and Leftover solar would then buy grid
energy the sun was going to give. TEL-12 took the same rule for the same
reason. No forecast is a known state with a tested fallback.
- The bell for a mixed list: joining one member's hours with another's total
has no honest shape, and the bell is the existing state-only rule.
## Cost
- One flaky member costs the whole day's forecast. A site with three
sensors loses the plan more often than one with a template sum that hides
gaps.
- Two more options (85).
## What this does not cover
- No weighting or calibration per member; FCST-01 is still the own forecast.
- Members on different integrations with different refresh times: each must
be fresh (3 h) on its own; there is no wait for a lagging one.
- The day plan itself, the trust and the house profile are unchanged.
- The Batteries screen still says only "no solar forecast now". The member
is named on the status page.
@@ -0,0 +1,98 @@
# SAFETY-112 - control waits for Home Assistant to finish starting
**Status: for review.** Tests: `test_safety112.py`, `test_messages.py`.
Control is unchanged. The add-on now says plainly that it is waiting.
## Cause
The Supervisor's proxy refuses every add-on request with 502 until Core's
state is `RUNNING`. The add-on reads every battery reading and writes every
power request through that proxy, so it cannot control until then. Readings
that are already live inside Home Assistant do not help.
Source, Supervisor 2026.08.0 (the image the live site ran at CFG-04):
- `supervisor/api/proxy.py`, `APIProxy.api()` (REST, used for reads and
`number.set_value`) and `APIProxy.websocket()` both start with
`if not await self.sys_homeassistant.api.check_api_state(): raise HTTPBadGateway`.
- `supervisor/homeassistant/api.py`, `check_api_state()` returns
`state.core_state == "RUNNING" or state.offline_db_migration`. The state
comes from Core's `/api/core/state`.
- Upstream `main` has the same gate in both handlers (read 2026-10-13).
- A Core restart restarts only the Core container
(`homeassistant/core.py`, `restart()`). The add-on keeps running.
The add-on itself has no wait. Nothing in `app/` reads Core's state or waits
for an event. The loop reads its inputs every second, a 502 gives `None`,
and missing inputs give 0 W. P1 backoff is not involved: the live site reads
the meter straight from the HomeWizard (`homewizard_local`), not through Home
Assistant.
## Measured
Repro stack `safety112-*`, all removed afterwards:
- Home Assistant 2026.9.4, as live.
- A test integration whose `async_setup` sleeps, so Core stays `NOT_RUNNING`.
- A fake meter and two fake inverters that update every 2 s from load.
- The Supervisor's own `APIProxy` and `HomeAssistantAPI` from the 2026.08.0
image. Only the add-on token lookup, Core's access token and the container
check are stubbed.
- The add-on on `meter_source: homewizard_local` against a fake HomeWizard.
Times are seconds after `docker restart` of Home Assistant:
| run | Core `NOT_RUNNING` | entities live in HA | Core `RUNNING` | proxy 502 | first write | wait from live readings |
|---|---|---|---|---|---|---|
| base 3050fa5, N=2 | 13.1 | 17.1 | 253.1 | 0.9-252.7 (1 132 requests) | 254.7 | 237.6 |
| base 3050fa5, N=1 | 13.1 | 18.1 | 256.1 | 1.5-255.1 (573 requests) | 257.2 | 239.1 |
| this change, N=2 | 12.2 | 17.2 | 313.2 | 1.4-312.5 (1 406 requests) | 317.6 | 300.4 |
| this change, N=1 | 12.2 | 17.2 | 255.2 | 1.2-254.1 (571 requests) | 257.1 | 240.0 |
The first write comes 1.0-4.4 s after `RUNNING`, so the whole wait is the
proxy's. With a 400 s sleep Core moved on at 300.88 s on its own (bootstrap's
stage timeout), so one slow integration holds a start stage for about 5
minutes. The whole start can take longer: the live one took 414 s.
Before this change the page said "Not ready — Battery level: no usable
reading", which sends the reader to Developer Tools for an entity that is
fine.
## Rule
1. No way around the Supervisor. The add-on still talks to Home Assistant
only through the proxy.
2. While the proxy does not answer on the API (`hass.OUTAGE.waiting()`), the
first status row is red: "Home Assistant is not answering, perhaps it is
still starting. Control resumes by itself when it answers." The banner
reads "Not ready — Waiting for Home Assistant to finish starting". The Home
pill reads "Waiting for Home Assistant", in English and Dutch.
3. A safety stop, a stalled control loop or shadow mode keeps its own banner
and state over the wait, because none of them clears by itself.
4. The log has one WARNING when Home Assistant stops answering, an INFO line
every 5 minutes while it is down, and an INFO line with the length of the
wait when it answers again.
5. Only the API counts. The meter websocket alone does not, because the meter
has its own row.
## Why it is safe
The change is text only. `cycle()`, the writes, the heartbeat, the stand-down
on shutdown and every watchdog are untouched. `test_safety112.py` part 3 shows
the same targets and writes with the flag on and off, for N=1 and N=2.
## Open, for the owner
The add-on cannot control while Home Assistant starts. These ways around it
were not taken:
- **Talk to Core directly with a long-lived token** (`http://homeassistant:8123`).
Core's own API answers while `NOT_RUNNING`. In the repro `/api/core/state`
answered from 12-13 s, and a direct `number.set_value` 10 s into
`NOT_RUNNING` returned 200 and set the value. The cost is a secret stored
at every client site, which `hass.py` exists to avoid.
- **Talk to the control board directly** (ESPHome native API), so control
does not need Home Assistant at all. This is a large change.
- **Remove the slow start at the site.** On 2026-10-12 the Solis
integration's discovery held the start. Fixing or disabling it shortens the
wait there, but not on a client site with another slow integration.
+13
View File
@@ -186,6 +186,8 @@ The label and description each setting shows on the Configuration tab, in tab or
| Hardware | Battery modes | `battery_modes_enabled` | Give each battery an on and off switch and a mode, set on the Batteries screen. |
| Hardware | Solar forecast, today (kWh) | `pv_forecast_entity` | Optional. A forecast sensor with today's remaining solar energy, such as Forecast.Solar or Solcast. A battery then buys only what the sun will not give. |
| Hardware | Solar forecast, tomorrow (kWh) | `pv_forecast_tomorrow_entity` | Optional. The same forecast for tomorrow. It helps plan a cheap hour before midnight. |
| Hardware | Several solar forecasts, today (kWh) | `pv_forecast_entities` | Optional. One row per forecast sensor, such as one per inverter, used instead of the single one above. They are added up. |
| Hardware | Several solar forecasts, tomorrow (kWh) | `pv_forecast_tomorrow_entities` | Optional. The same list for tomorrow. If one sensor has no reading, that day's forecast is not used. |
| Hardware | Trust in the forecast (%) | `solar_trust_pct` | Forecasts are often too sunny. At 70 % the add-on plans with 7 of every 10 kWh forecast. |
| Hardware | Solar production entity (W) | `pv_entity` | Optional. Your solar inverter's power sensor, positive while producing. The add-on records it and does not use it for control. |
| Hardware | Solar production entities, per inverter (W) | `pv_entities` | Optional. Use one row per solar inverter instead of “Solar production entity” when you have several. If one stops reporting, no total is recorded. |
@@ -539,6 +541,9 @@ With battery modes on, these choices replace the *Charging and selling on price*
- With no prices, or too small a gap, every battery follows the house.
**Solar forecast** (`pv_forecast_entity`, optional). A Home Assistant sensor with the solar energy still to come today, in kWh. These work: Forecast.Solar (`sensor.energy_production_today_remaining`), Solcast PV Forecast (`sensor.solcast_pv_forecast_forecast_remaining_today`) and Open-Meteo Solar Forecast (`sensor.energy_production_today_remaining`). Set `pv_forecast_tomorrow_entity` to the matching *tomorrow* sensor to plan a cheap run before midnight.
- **Several forecasts.** If your forecast comes as several sensors (one per inverter or roof side), list them in `pv_forecast_entities` and, for tomorrow, `pv_forecast_tomorrow_entities`, instead of the single key. They are added up, each in its own unit (Wh, kWh or MWh). Set the list or the single key for a day, not both.
- If one of them has no reading, is stale or is not in an energy unit, that day's forecast is not used at all. A part of the sun is not counted as all of it. The status page names the source by its place in the list.
- The hourly shape is kept only if every sensor has one. Otherwise the total is spread over the day as for Forecast.Solar.
- Before each cheap run the add-on asks: will the sun fill the batteries before the expensive hours? It counts on `solar_trust_pct` of the forecast (70 %), less what the house uses. Solar first batteries get that sun first.
- *Leftover solar* then charges from the grid only the shortfall: what the house will draw from it in the expensive hours, less its share of the sun. Not up to the top level.
- *Cheap hours only* ignores the forecast and fills to the top level.
@@ -3473,6 +3478,7 @@ for a new install*.
| Row starts with | What it tells you |
|---|---|
| Home Assistant is not answering | Home Assistant is not passing readings or power requests on, usually because it is still starting. The add-on cannot control until then. It resumes by itself. Nothing to do unless it lasts. |
| Control | When control last ran. Red when it has stopped running: the control board then sets the inverter to 0 W. |
| Meter | The grid reading, its age, how many readings were refused, and how long its value has not changed. Just after a start it is amber and *waiting for the first reading*. Red means no reading for longer than the limit. |
| Stuck-meter check | Whether the add-on can warn you about a stuck meter. |
@@ -3531,6 +3537,13 @@ reason code, and its full figures are on the DEBUG line right after it and in
(TARIFF-11), in words.
- **Solar production** reads `pv_entity` or `pv_entities` (TEL-11/12). The
*Reversed-sign solar entities* setting is `pv_invert_entities` (TEL-14).
- **Home Assistant is not answering** (SAFETY-112): the Supervisor answers
502 to every add-on read, write and websocket until Home Assistant is
RUNNING, even when its entities are already live. A slow integration at
start-up therefore holds control for as long as it holds Home Assistant's
start. The log has one WARNING, a "Still waiting" INFO line every 5
minutes and "answers again after …" at the end. See
`DECISIONS/SAFETY-112-waiting-for-home-assistant.md`.
- **Shadow mode** (SAFETY-10): the banner turns red when a latch is up
(circulating power, or a site or per-inverter sign trip), and that latch keeps
its own red row.
+26 -2
View File
@@ -11,6 +11,8 @@ import time
import aiohttp
from . import messages
_LOG = logging.getLogger("goodwe.hass")
CORE_API = "http://supervisor/core/api"
@@ -19,6 +21,7 @@ BAD_STATES = ("unknown", "unavailable", "none", "")
# What the Supervisor's proxy answers while Home Assistant restarts.
UNAVAILABLE = (502, 503, 504)
STATES_TIMEOUT_S = 10 # UX-13's entity list, like every other read here
STILL_WAITING_S = 300 # SAFETY-112: one reminder line per 5 minutes down
class Outage:
@@ -29,15 +32,30 @@ class Outage:
("502, message='Bad Gateway', url=URL(...)", "Received message 8:1000 is
not str"). Now the first channel to fail says it once at WARNING, the raw
text goes to DEBUG, and the last channel to recover says so at INFO.
SAFETY-112: the Supervisor answers 502 for every read, write and
websocket until Home Assistant has finished starting, however live its
entities already are (DECISIONS/SAFETY-112-waiting-for-home-assistant.md).
So an outage also says, every STILL_WAITING_S, that it is still on, and
`waiting()` puts it on the status page.
"""
def __init__(self):
self.down_on = set()
self.since = None # monotonic start of this outage
self._told = None # when the last "still waiting" line went
def down(self, channel: str, what: str, detail) -> None:
now = time.monotonic()
if not self.down_on:
_LOG.warning("Home Assistant is not answering (%s), perhaps it is "
"restarting. The add-on keeps trying.", what)
"still starting. Control waits and resumes by itself.",
what)
self.since = self._told = now
elif now - self._told >= STILL_WAITING_S:
self._told = now
_LOG.info("Still waiting for Home Assistant to finish starting, "
"%s so far.", messages.duration(now - self.since))
self.down_on.add(channel)
_LOG.debug("Home Assistant not answering on %s: %s", channel, detail)
@@ -45,7 +63,13 @@ class Outage:
if channel in self.down_on:
self.down_on.discard(channel)
if not self.down_on:
_LOG.info("Home Assistant answers again.")
_LOG.info("Home Assistant answers again after %s.",
messages.duration(time.monotonic() - self.since))
def waiting(self) -> bool:
"""True while Home Assistant's API does not answer. The API, not the
meter websocket: every battery reading and every write rides on it."""
return "api" in self.down_on
OUTAGE = Outage()
+11 -8
View File
@@ -909,13 +909,14 @@ def month_peak(c, now):
return out
def state_of(c, level, shadow):
def state_of(c, level, shadow, waiting=False):
"""One word for the pill and the banner's colour: running, shadow,
stopped, alarm or warning."""
stopped, alarm, waiting or warning. `waiting`: the red headline is
Home Assistant still starting (SAFETY-112), which needs nobody."""
if shadow:
return "shadow"
if level == "bad":
return "alarm"
return "waiting" if waiting else "alarm"
if not c.auto:
return "stopped"
if level == "warn":
@@ -924,20 +925,22 @@ def state_of(c, level, shadow):
def build(c, *, level: str, checks: list, now: datetime | None = None,
lang: str = "en", held: bool | None = None) -> dict:
lang: str = "en", held: bool | None = None,
waiting: bool = False) -> dict:
"""The structured half of `/status`, in `lang` (UX-13 P5). Pure reads of
the controller."""
_LANG[0] = uitext.lang_of(lang)
try:
return _build(c, level=level, checks=checks, now=now, held=held)
return _build(c, level=level, checks=checks, now=now, held=held,
waiting=waiting)
finally:
_LANG[0] = "en"
def _build(c, *, level, checks, now, held=None):
def _build(c, *, level, checks, now, held=None, waiting=False):
now = now or datetime.now(timezone.utc)
shadow = bool(c.shadow)
kind = state_of(c, level, shadow)
kind = state_of(c, level, shadow, waiting)
# A red banner means every inverter is at 0 W (the status cascade), so
# nothing is planned until it clears. Shadow mode still plans: it is
# what the add-on would do. `held` False: an alarm while control runs
@@ -1332,7 +1335,7 @@ def _samples() -> list:
out += [("title", mode_word(k)) for k in words["modes"]]
out += [("title", mode_house()), ("title", attn_shadow()),
("line", attn_shadow_body())]
for k in ("running", "shadow", "stopped", "alarm", "warning"):
for k in ("running", "shadow", "stopped", "alarm", "waiting", "warning"):
out += [("title", pill(k)), ("line", banner_sub(k))]
for sh in (False, True):
out += [("title", tile_solar(v, s)) for v, s in
+58 -17
View File
@@ -69,7 +69,7 @@ from . import messages
from .freeze import (
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
)
from .hass import CORE_API, HomeAssistant
from .hass import CORE_API, OUTAGE, HomeAssistant
# ⚠️ `LINK_STALE_S` IS RE-EXPORTED DELIBERATELY. `live_units()` reasons about it
# and two checks read `app.main.LINK_STALE_S` to build a stale link, so the
# window an operator would look up here is the one the roster actually uses.
@@ -992,12 +992,17 @@ class Controller:
self._charge_blocked_q = None
self._ref_cheap = None
# OPT-11 P4: the solar forecast and the day plan. Only with battery
# modes on AND `pv_forecast_entity` set; otherwise none of this runs
# and every mode acts as in P3 option A.
self.fc_entity = (opts.get("pv_forecast_entity") or None) \
if self.modes_on else None
self.fc_tomorrow_entity = opts.get("pv_forecast_tomorrow_entity") \
or None
# modes on AND a forecast for today set; otherwise none of this runs
# and every mode acts as in P3 option A. OPT-17: each day may name
# several entities, summed (`solarfc.entities()`); `fc_entity` is
# the first, and None means no forecast.
self.fc_entities = (solarfc.entities(opts) if self.modes_on
else ())
self.fc_tomorrow_entities = solarfc.entities(opts, tomorrow=True)
self.fc_entity = self.fc_entities[0] if self.fc_entities else None
# OPT-17: the members that are not usable, as `solarfc.read_sum()`
# gives them, for the status page and the action log.
self.solar_fc_gaps = ()
self.solar_trust_pct = opts.get("solar_trust_pct",
dayplan.DEFAULT_TRUST_PCT)
self.solar_fc = None
@@ -3246,18 +3251,31 @@ class Controller:
async def _read_forecast(self) -> None:
"""OPT-11 P4: the solar forecast sensors (`solarfc.read()`). A
failed or stale read is no forecast, never a zero."""
today = await self.hass.state(self.fc_entity)
tomorrow = (await self.hass.state(self.fc_tomorrow_entity)
if self.fc_tomorrow_entity else None)
# OPT-17: every member of both days in one gather.
ents = self.fc_entities + self.fc_tomorrow_entities
states = await asyncio.gather(*(self.hass.state(e) for e in ents))
n = len(self.fc_entities)
now = datetime.now(timezone.utc)
fc, why = solarfc.read(today, tomorrow, now)
if (fc is None) != (self.solar_fc is None) or why != self.solar_fc_why:
fc, why, gaps = solarfc.read_sum(
list(zip(self.fc_entities, states[:n])),
list(zip(self.fc_tomorrow_entities, states[n:])), now)
# A member is named only in a sum of several; one entity reads as
# before. Logged once per episode: on a change, never every read.
gaps = tuple(g for g in gaps if len(
self.fc_entities if g[0] == "today"
else self.fc_tomorrow_entities) > 1)
if (fc is None) != (self.solar_fc is None) \
or why != self.solar_fc_why or gaps != self.solar_fc_gaps:
self.log_event(
"solar forecast: " + ("read" if fc is not None else
f"none ({why})"),
hint=messages.hint("forecast_read" if fc is not None
else "forecast_none"))
f"none ({why})")
+ "".join(f", {d} {e} not usable ({w})"
for d, _i, e, w in gaps),
hint=messages.hint(
"forecast_none" if fc is None else
"forecast_no_tomorrow" if gaps else "forecast_read"))
self.solar_fc, self.solar_fc_why = fc, why
self.solar_fc_gaps = gaps
async def read_inputs(self, now_mono: float | None = None) -> None:
"""Every input this cycle needs, and SAFETY-37's freshness verdict.
@@ -6572,6 +6590,14 @@ class Controller:
else:
out.append({"ok": True, "warn": False, "id": "controller",
"text": messages.row_controller_ran(_pass_age)})
# SAFETY-112. The headline unless control has stalled: while the
# Supervisor gives 502 (Home Assistant not yet RUNNING) no reading or
# write gets through, and the rows below that need it follow from it.
if OUTAGE.waiting():
out.insert(1 if out[0]["ok"] is False else 0,
{"ok": False, "warn": False, "id": "home_assistant",
"banner": messages.banner_ha_waiting(),
"text": messages.row_ha_waiting()})
if self.p1_enabled:
age = self.p1.published_age_s
if (self.p1.stale and self.p1.age_s() is None
@@ -6998,6 +7024,17 @@ class Controller:
# is both is a check nobody reads.
out.append({"ok": False, "warn": True, "id": "peak_forecast",
"text": messages.row_no_peak_forecast()})
# OPT-17: a forecast summed from several entities names, by position,
# each member with no usable reading. A caution: control goes on.
gaps = getattr(self, "solar_fc_gaps", ())
for day, ents in (("today", getattr(self, "fc_entities", ())),
("tomorrow",
getattr(self, "fc_tomorrow_entities", ()))):
gone = [i for d, i, _e, _w in gaps if d == day]
if gone:
out.append({"ok": False, "warn": True, "id": "solar_forecast",
"text": messages.row_forecast_gap(
day, gone, len(ents))})
# --- TEL-11 ----------------------------------------------------------
# ⚠️ NO ROW AT ALL WHEN THE ROSTER IS EMPTY, AND THAT IS A DELIBERATE
# DIFFERENCE FROM THE PEAK-FORECAST CAUTION DIRECTLY ABOVE. That one
@@ -7070,7 +7107,7 @@ class Controller:
# control goes on, so it neither reads "Not ready" nor holds the page.
runs = [c for c in bad if c.get("runs")]
bad = [c for c in bad if not c.get("runs")]
running_alarm = False
running_alarm = waiting = False
# --- SAFETY-10, AND IT IS FIRST IN THE CASCADE -----------------------
# "The battery is NOT being controlled" is the one sentence on this
# page that may never be displaced by another condition. A banner that
@@ -7121,6 +7158,10 @@ class Controller:
# headline nobody reads. UX-08: without the key, the banner takes
# the row's first sentence, so it stays a headline.
level, banner = "bad", messages.banner_not_ready(bad[0])
# SAFETY-112: nothing to do but wait, so Home says that instead
# of "Needs you now". Only when it IS the headline: a safety stop
# above outranks it and still needs a person.
waiting = bad[0].get("id") == "home_assistant"
elif not self.auto:
level, banner = "warn", messages.banner_stopped()
elif runs:
@@ -7267,7 +7308,7 @@ class Controller:
# fields are served alone (the page then shows the installer details).
try:
out.update(homeview.build(self, level=level, checks=checks,
lang=lang, held=held))
lang=lang, held=held, waiting=waiting))
except Exception: # noqa: BLE001
# Once per process at ERROR: the page polls every 3 s.
if not getattr(self, "_home_failed", False):
+29
View File
@@ -436,6 +436,17 @@ def banner_shadow(latched: bool) -> str:
return "Shadow mode: the battery is not controlled. Inverters stay at 0 W."
def banner_ha_waiting() -> str:
"""SAFETY-112: the Supervisor passes nothing on until Home Assistant has
finished starting, so the add-on can neither read nor write."""
return "Waiting for Home Assistant to finish starting"
def row_ha_waiting() -> str:
return ("Home Assistant is not answering, perhaps it is still starting. "
"Control resumes by itself when it answers.")
# Rows: control and the meter. "Control" is what the add-on does, as in
# "start control" (STYLE.md section 4).
def row_controller_starting() -> str:
@@ -754,6 +765,15 @@ def row_pv_partial(missing, count) -> str:
f"reading")
def row_forecast_gap(day, missing, count) -> str:
"""OPT-17. `day` is "today" or "tomorrow"; `missing` are the 1-based
positions of the forecast entities with no usable reading."""
which = names([str(n) for n in missing])
many = len(missing) > 1
return (f"Solar forecast for {day}: not used, source{'s' if many else ''} "
f"{which} of {count} {'have' if many else 'has'} no reading")
def row_pv_single_ignored() -> str:
return f"Solar production: clear {PV_ONE}, the per-inverter list replaces it"
@@ -953,6 +973,7 @@ HINTS = {
"trade_off": "Trading: waiting for a better price",
"forecast_read": "Solar forecast read, the day plan uses it",
"forecast_none": "No solar forecast, so Leftover solar takes solar only",
"forecast_no_tomorrow": "Tomorrow's solar forecast is not used, a source has no reading",
"split": "The power split between inverters changed",
"clash": "Two limits clash, inverters set to 0 W",
"no_setpoint": "An inverter has no “Setpoint entity”, inverters stay at 0 W",
@@ -1045,6 +1066,10 @@ def status_samples() -> list:
("banner_setup_only", None, banner_setup_only()),
("row_setup_only", AMBER, row_setup_only()),
("banner_shadow", None, banner_shadow(True)),
("banner_not_ready", None, banner_not_ready(
{"text": row_ha_waiting(), "banner": banner_ha_waiting()})),
("banner_ha_waiting", None, banner_ha_waiting()),
("row_ha_waiting", RED, row_ha_waiting()),
("row_controller_starting", GREEN, row_controller_starting()),
("row_controller_ran", GREEN, row_controller_ran(2)),
("row_controller_stalled", RED, row_controller_stalled(45, 30)),
@@ -1114,6 +1139,8 @@ def status_samples() -> list:
"entity - it cannot be read in watts")),
("row_pv_partial", AMBER, row_pv_partial([2, 3], 3)),
("row_pv_partial", AMBER, row_pv_partial([2], 2)),
("row_forecast_gap", AMBER, row_forecast_gap("today", [2], 3)),
("row_forecast_gap", AMBER, row_forecast_gap("tomorrow", [1, 2], 2)),
("row_pv_single_ignored", AMBER, row_pv_single_ignored()),
("row_pv_negative", AMBER, row_pv_negative(2, 3, -1500, False)),
("row_pv_negative", AMBER, row_pv_negative(1, 1, -1500, True)),
@@ -1232,6 +1259,8 @@ LABELS = {
"pv_entities": "Solar production entities, per inverter",
"pv_forecast_entity": "Solar forecast, today",
"pv_forecast_tomorrow_entity": "Solar forecast, tomorrow",
"pv_forecast_entities": "Several solar forecasts, today",
"pv_forecast_tomorrow_entities": "Several solar forecasts, tomorrow",
"solar_trust_pct": "Trust in the forecast",
"p1_phase_import_entities": "Import entities per phase",
"p1_phase_export_entities": "Export entities per phase",
+113 -17
View File
@@ -27,6 +27,11 @@ Without an hourly split, the day's energy is spread over a fixed bell from
A reading older than `FRESH_S`, or from before local midnight, is stale,
and a stale or missing forecast is no forecast: the day plan is off and
every mode acts as in P3 option A.
OPT-17: each day may instead name a list (`pv_forecast_entities`,
`pv_forecast_tomorrow_entities`), summed by `read_sum()`. One member that is
not usable makes that day's sum unusable, never a partial sum.
DECISIONS/OPT-17-several-forecast-entities.md.
"""
import math
@@ -49,6 +54,12 @@ BELL_FROM_H, BELL_TO_H = 6.0, 20.0
NOT_SET, MISSING, UNIT, STALE, BAD = (
"not_set", "missing", "unit", "stale", "bad")
# OPT-17: (single key, list key) per day. A set list replaces the single key;
# the validator refuses both set for the same day.
TODAY_KEYS = ("pv_forecast_entity", "pv_forecast_entities")
TOMORROW_KEYS = ("pv_forecast_tomorrow_entity",
"pv_forecast_tomorrow_entities")
@dataclass(frozen=True)
class Day:
@@ -223,6 +234,52 @@ def _reported(state):
return max(ts) if ts else None
def entities(opts: dict, *, tomorrow=False) -> tuple:
"""The forecast entities one day names, in configured order: the list
if it has any entry, else the single key, else (). A repeated id is
dropped, as `pv.entities()` does: it would count one forecast twice."""
one, many = TOMORROW_KEYS if tomorrow else TODAY_KEYS
listed = [e for e in (str(x or "").strip()
for x in (opts.get(many) or [])) if e]
if listed:
return tuple(dict.fromkeys(listed))
e = str(opts.get(one) or "").strip()
return (e,) if e else ()
def _bounds(now):
here = now.astimezone(BILLING_TZ)
d0 = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ)
nd = d0.date() + timedelta(days=1)
d1 = datetime(nd.year, nd.month, nd.day, tzinfo=BILLING_TZ)
return d0.astimezone(timezone.utc), d1.astimezone(timezone.utc)
def _today(state, day0, now):
"""`(Day, at, None)` or `(None, None, why)` for one today sensor."""
today, why = _day(state, day0, now, today=True)
if today is None:
return None, None, why
at = _reported(state)
# A reading from before local midnight is yesterday's "left today" (0
# after sunset) until the integration refreshes: stale, not a dark day.
if at is None or at < day0 or (now - at).total_seconds() > FRESH_S \
or (at - now).total_seconds() > 300:
return None, None, STALE
return today, at, None
def _tomorrow(state, day0, day1, now):
"""`(Day, None)` or `(None, why)` for one tomorrow sensor."""
t, why = _day(state, day1, now, today=False)
if t is None:
return None, why
t_at = _reported(state)
if t_at is None or t_at < day0 or (now - t_at).total_seconds() > FRESH_S:
return None, STALE
return t, None
def read(today_state, tomorrow_state, now: datetime, *,
configured=True):
"""`(Forecast, None)` or `(None, why)`. `tomorrow_state` may be None
@@ -230,25 +287,64 @@ def read(today_state, tomorrow_state, now: datetime, *,
covers today only."""
if not configured:
return None, NOT_SET
here = now.astimezone(BILLING_TZ)
d0 = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ)
nd = d0.date() + timedelta(days=1)
d1 = datetime(nd.year, nd.month, nd.day, tzinfo=BILLING_TZ)
day0, day1 = d0.astimezone(timezone.utc), d1.astimezone(timezone.utc)
today, why = _day(today_state, day0, now, today=True)
day0, day1 = _bounds(now)
today, at, why = _today(today_state, day0, now)
if today is None:
return None, why
at = _reported(today_state)
# A reading from before local midnight is yesterday's "left today" (0
# after sunset) until the integration refreshes: stale, not a dark day.
if at is None or at < day0 or (now - at).total_seconds() > FRESH_S \
or (at - now).total_seconds() > 300:
return None, STALE
tomorrow = None
if tomorrow_state is not None:
t, _w = _day(tomorrow_state, day1, now, today=False)
t_at = _reported(tomorrow_state)
if t is not None and t_at is not None and t_at >= day0 \
and (now - t_at).total_seconds() <= FRESH_S:
tomorrow = t
tomorrow, _w = _tomorrow(tomorrow_state, day0, day1, now)
return Forecast(today, tomorrow, at), None
def _sum(days):
"""One Day from several members' Days. The hourly split is kept only if
every member has one: the splits are then joined (`_day_between()`
adds overlapping periods). Else the total goes on the bell."""
if len(days) == 1:
return days[0]
periods = ()
if all(d.periods for d in days):
periods = tuple(sorted(p for d in days for p in d.periods))
return Day(days[0].day0, sum(d.kwh for d in days), periods)
def read_sum(today, tomorrow, now: datetime):
"""OPT-17: `(Forecast or None, why, gaps)` over lists of
`(entity, state)`, one per configured member, in configured order.
Each member is read exactly as `read()` reads one sensor, in its own
unit. A member that is missing, unavailable, stale, not in an energy
unit or bad makes that DAY unusable, as one such sensor does today: no
today means no forecast, no tomorrow means today only. A partial sum
would under-count the sun and mislead the day plan.
`gaps` is `((day, position, entity, why), ...)` for every unusable
member (`day` "today" or "tomorrow", `position` 1-based), so the page
and the log can name it. With one member per day the forecast is what
`read()` gives (test_opt17.py proves it)."""
if not today:
return None, NOT_SET, ()
day0, day1 = _bounds(now)
gaps, days, ats, why0 = [], [], [], None
for i, (ent, st) in enumerate(today, 1):
d, at, why = _today(st, day0, now)
if d is None:
gaps.append(("today", i, ent, why))
why0 = why0 or why
else:
days.append(d)
ats.append(at)
tdays = []
for i, (ent, st) in enumerate(tomorrow, 1):
t, why = _tomorrow(st, day0, day1, now)
if t is None:
gaps.append(("tomorrow", i, ent, why))
else:
tdays.append(t)
gaps = tuple(gaps)
if why0 is not None:
return None, why0, gaps
tday = _sum(tdays) if tomorrow and len(tdays) == len(tomorrow) else None
# The oldest member's report is when the sum was last fully known.
return Forecast(_sum(days), tday, min(ats)), None, gaps
+2 -2
View File
@@ -100,7 +100,7 @@
}
function renderPill() {
const st = S.state || null;
const dot = st ? ({ running: "", shadow: " warn", stopped: " warn", warning: " warn", alarm: " bad" }[st.kind] ?? " warn")
const dot = st ? ({ running: "", shadow: " warn", stopped: " warn", warning: " warn", alarm: " bad", waiting: " warn" }[st.kind] ?? " warn")
: (S.level === "ok" ? "" : S.level === "bad" ? " bad" : " warn");
const words = st ? [st.pill, st.ran].filter(Boolean).join(". ") : S.setup_only === true ? t("setup_only.name") : S.banner;
$("#pill").replaceChildren(h("span", { class: "dot" + dot, "aria-hidden": "true" }), h("span", {}, words));
@@ -399,7 +399,7 @@
$("#batt-how").hidden = !modes;
const docs = $("#batt-docs");
if (S.control && S.control.docs_url) { docs.href = S.control.docs_url; docs.hidden = false; } else docs.hidden = true;
if (S.state && ["shadow", "stopped", "alarm"].includes(S.state.kind)) banner($("#batt-banner"), S.level, S.state.pill, S.state.sub, S.level === "bad" ? "alert" : null);
if (S.state && ["shadow", "stopped", "alarm", "waiting"].includes(S.state.kind)) banner($("#batt-banner"), S.level, S.state.pill, S.state.sub, S.level === "bad" ? "alert" : null);
else $("#batt-banner").replaceChildren();
keepFocus(() => {
$("#bcards").replaceChildren(...S.batteries.map(batteryCard));
+4
View File
@@ -267,6 +267,7 @@
"shadow": "Shadow mode",
"stopped": "Stopped",
"alarm": "Needs you now",
"waiting": "Waiting for Home Assistant",
"warning": "Running, with a warning"
},
"banner_sub": {
@@ -274,6 +275,7 @@
"shadow": "The plans below say what the add-on would do.",
"stopped": "Start control below when you are ready.",
"alarm": "See Needs attention below for what to do.",
"waiting": "Control resumes by itself when Home Assistant answers.",
"warning": "See Needs attention below."
},
"tile": {
@@ -431,6 +433,7 @@
"stopped": "Control is stopped. Every inverter is at 0 W."
},
"attn": {
"home_assistant": "Home Assistant is not answering",
"controller": "Control is not running",
"meter": "Meter reading",
"meter_frozen": "Meter reading may be stuck",
@@ -449,6 +452,7 @@
"delivery": "An inverter is not delivering",
"estop": "Emergency stop",
"pv": "Solar reading",
"solar_forecast": "Solar forecast",
"none": "Inverter connection"
},
"months": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
+4 -2
View File
@@ -230,8 +230,8 @@
"zero_held": "Op 0 W gehouden",
"zero_going": "Gaat naar 0 W",
"verdict": {"held": "Op 0 W gehouden", "stopped": "Gestopt", "cycle": "Vandaag maandelijkse cyclus", "trade": "Handelt op prijs", "house": "Volgt het huis"},
"pill": {"running": "Actief", "shadow": "Schaduwmodus", "stopped": "Gestopt", "alarm": "Heeft u nu nodig", "warning": "Actief, met een waarschuwing"},
"banner_sub": {"running": "De batterijen volgen het plan hieronder.", "shadow": "De plannen hieronder tonen wat de add-on zou doen.", "stopped": "Start de regeling hieronder als u klaar bent.", "alarm": "Zie Vraagt aandacht hieronder voor wat u moet doen.", "warning": "Zie Vraagt aandacht hieronder."},
"pill": {"running": "Actief", "shadow": "Schaduwmodus", "stopped": "Gestopt", "alarm": "Heeft u nu nodig", "waiting": "Wacht op Home Assistant", "warning": "Actief, met een waarschuwing"},
"banner_sub": {"running": "De batterijen volgen het plan hieronder.", "shadow": "De plannen hieronder tonen wat de add-on zou doen.", "stopped": "Start de regeling hieronder als u klaar bent.", "alarm": "Zie Vraagt aandacht hieronder voor wat u moet doen.", "waiting": "De regeling gaat vanzelf verder zodra Home Assistant antwoordt.", "warning": "Zie Vraagt aandacht hieronder."},
"tile": {"not_set_up": "Niet ingesteld", "no_reading": "Geen meting", "producing": "Produceert", "not_producing": "Produceert niet", "needs_solar": "Zonnemeting nodig", "not_adding_up": "Metingen kloppen niet", "using": "Verbruikt", "taking": "Neemt af van het net", "feeding": "Injecteert", "balanced": "In evenwicht", "giving": "Levert stroom", "charging": "Laadt", "resting": "Rust"},
"now": {
"no_reading": "Geen meting van deze batterij.",
@@ -354,6 +354,7 @@
"peak_not_covered": "Geen piekgrens: deze meter meet geen kwartieren.",
"control": {"shadow": "Schaduwmodus: elke omvormer blijft op 0 W.", "running": "De regeling is actief.", "stopped": "De regeling is gestopt. Elke omvormer staat op 0 W."},
"attn": {
"home_assistant": "Home Assistant antwoordt niet",
"controller": "Regeling is niet actief",
"meter": "Meting van de meter",
"meter_frozen": "Meter staat mogelijk vast",
@@ -372,6 +373,7 @@
"delivery": "Een omvormer levert niet",
"estop": "Noodstop",
"pv": "Meting van de zonnepanelen",
"solar_forecast": "Zonneprognose",
"none": "Verbinding met de omvormer"
},
"months": ["januari", "februari", "maart", "april", "mei", "juni", "juli", "augustus", "september", "oktober", "november", "december"]
+4
View File
@@ -135,6 +135,10 @@ GROUPS = (
Field("pv_forecast_entity", ENTITY, kind="energy", simple=True,
note="forecast"),
Field("pv_forecast_tomorrow_entity", ENTITY, kind="energy"),
# OPT-17: several forecast sensors per day, summed.
Field("pv_forecast_entities", ENTITY_LIST, kind="energy"),
Field("pv_forecast_tomorrow_entities", ENTITY_LIST,
kind="energy"),
Field("solar_trust_pct", SLIDER, "%", step=5),
)),
)),
+29 -11
View File
@@ -1802,12 +1802,13 @@ def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
_advise(notes, "warn", (key,),
f"{entity} does not read in kWh, so pick a solar "
"forecast sensor in kWh.")
if any(opts.get(k) for k in FORECAST_KEYS) and \
today = solarfc.entities(opts)
tomorrow = solarfc.entities(opts, tomorrow=True)
if (today or tomorrow) and \
opts.get("battery_modes_enabled") is not True:
_advise(notes, "info", ("pv_forecast_entity",),
"The solar forecast is used only with battery modes on.")
if opts.get("pv_forecast_tomorrow_entity") and \
not opts.get("pv_forecast_entity"):
if tomorrow and not today:
_advise(notes, "warn", ("pv_forecast_tomorrow_entity",),
"Tomorrow's forecast is used only with today's, so set "
f"{S('pv_forecast_entity')} too.")
@@ -2069,12 +2070,13 @@ def _margin_problems(opts: dict) -> list[str]:
TRUST_RANGE_PCT = (30, 100)
# A Home Assistant sensor id; it goes into the /api/states/<id> path.
_SENSOR_ID = re.compile(r"sensor\.[a-z0-9_]+")
FORECAST_KEYS = ("pv_forecast_entity", "pv_forecast_tomorrow_entity")
FORECAST_KEYS = solarfc.TODAY_KEYS + solarfc.TOMORROW_KEYS
def _forecast_problems(opts: dict) -> list[str]:
"""Rule 13 (OPT-11 P4): the trust is a whole percentage in range, and
each forecast entity set is a sensor."""
each forecast entity set is a sensor. OPT-17: a day names its forecast
in the single key or in the list, not both."""
out = []
raw = opts.get("solar_trust_pct")
trust = _number(opts, "solar_trust_pct", dayplan.DEFAULT_TRUST_PCT)
@@ -2082,14 +2084,30 @@ def _forecast_problems(opts: dict) -> list[str]:
if trust is None or trust != int(trust) or not lo <= trust <= hi:
out.append(f"{S('solar_trust_pct')} must be a whole number from {lo} "
f"to {hi} %. It is {messages.shown(raw)}.")
for key in FORECAST_KEYS:
e = opts.get(key)
if e is None or e == "":
continue
if not isinstance(e, str) or not _SENSOR_ID.fullmatch(e):
out.append(f"{S(key)} must be a sensor, like "
for one, many in (solarfc.TODAY_KEYS, solarfc.TOMORROW_KEYS):
e = opts.get(one)
if e is not None and e != "" and (
not isinstance(e, str) or not _SENSOR_ID.fullmatch(e)):
out.append(f"{S(one)} must be a sensor, like "
f"sensor.energy_production_today_remaining. It is "
f"{messages.shown(e)}.")
raw = opts.get(many)
if raw is None:
raw = []
if not isinstance(raw, list):
out.append(f"{S(many)} must be a list of sensors. It is "
f"{messages.shown(raw)}.")
continue
listed = [x for x in raw if x is not None
and not (isinstance(x, str) and not x.strip())]
for x in listed:
if not isinstance(x, str) or not _SENSOR_ID.fullmatch(x):
out.append(f"{S(many)} must list only sensors, like "
f"sensor.energy_production_today_remaining. "
f"It has {messages.shown(x)}.")
if e and listed:
out.append(f"Set {S(one)} or {S(many)}, not both. Keep the "
f"list and clear the single one.")
return out
+12
View File
@@ -157,6 +157,11 @@ options:
# integrations whose sensors fit.
pv_forecast_entity: ""
pv_forecast_tomorrow_entity: ""
# OPT-17: several forecast sensors per day (one per inverter or string),
# summed. Use the list OR the single key above, not both. `[]` is
# load-bearing, as for `pv_entities`.
pv_forecast_entities: []
pv_forecast_tomorrow_entities: []
solar_trust_pct: 70
# TEL-11 - THE SITE'S PV PRODUCTION, IN WATTS, AND IT IS READ BY NOTHING THAT
@@ -961,6 +966,13 @@ schema:
battery_modes_enabled: bool
pv_forecast_entity: str?
pv_forecast_tomorrow_entity: str?
# OPT-17: new list keys beside the single ones. A list schema on the old
# keys refuses the string an install stores (tools/supervisor_shape_probe.py
# section J); DECISIONS/OPT-17-several-forecast-entities.md.
pv_forecast_entities:
- str
pv_forecast_tomorrow_entities:
- str
solar_trust_pct: int(30,100)
# TEL-11. Optional, and OPTIONAL WITHOUT A COMPANION RULE - unlike
# `price_now_entity`/`price_avg_entity`, which are legal only while
+5 -2
View File
@@ -1469,8 +1469,11 @@ _info = [r.getMessage() for r in _out if r.levelno == logging.INFO]
check(f"a Home Assistant restart is one plain WARNING line, and one INFO line "
f"when both connections are back ({_warn}, {_info})",
_warn[:1] == ["Home Assistant is not answering (error 502), perhaps it "
"is restarting. The add-on keeps trying."]
and _info == ["Home Assistant answers again."] and len(_warn) == 2)
"is still starting. Control waits and resumes by itself."]
and len(_info) == 1
and re.fullmatch(r"Home Assistant answers again after \d+ seconds\.",
_info[0]) is not None and len(_warn) == 2)
# SAFETY-112's reminder and its timing are graded in test_safety112.py.
check("...and the library's own text is kept, at DEBUG",
any("Received message 8:1000" in r.getMessage() for r in _out
if r.levelno == logging.DEBUG)
+8 -6
View File
@@ -113,19 +113,21 @@ check("the harness bites: Leftover solar grid-charging with no plan moves "
lambda: setattr(modetrade, "GRID_CHARGE_MODES",
(packmode.LEFTOVER_SOLAR, packmode.CHEAP_HOURS)),
lambda: setattr(modetrade, "GRID_CHARGE_MODES", _gcm)))
_read = solarfc.read
# OPT-17: the controller reads through `read_sum()`, so that is the seam.
_read = solarfc.read_sum
def _as_dark(today, tomorrow, now, **kw):
return _read({"state": "0", "attributes": {"unit_of_measurement":
"kWh"},
"last_updated": now.isoformat()}, None, now)
return _read([("sensor.f", {"state": "0", "attributes": {
"unit_of_measurement": "kWh"}, "last_updated": now.isoformat()})],
[], now)
check("...and an unavailable forecast read as a dark one moves "
"n2_sf_ls_fc_down (Leftover solar would buy its shortfall)",
_moved("n2_sf_ls_fc_down", lambda: setattr(solarfc, "read", _as_dark),
lambda: setattr(solarfc, "read", _read)))
_moved("n2_sf_ls_fc_down",
lambda: setattr(solarfc, "read_sum", _as_dark),
lambda: setattr(solarfc, "read_sum", _read)))
# ============================================================================ #
print("2. the forecast sensors")
+498
View File
@@ -0,0 +1,498 @@
"""Runnable check for OPT-17, the solar forecast summed from several entities.
`python3 test_opt17.py`
1. `solarfc.read_sum()`: sums in mixed units, refusals, a member down or
stale makes that day unusable (never a partial sum), hourly splits, today
and tomorrow, an empty list, and one member equal to `read()`.
2. `solarfc.entities()`: which key a day uses.
3. Options: the validator, the settings notes and config.yaml.
4. The REAL controller (tools/opt11p3_replay.py): a one-member list runs
exactly like the single key, two halves like one whole, a member down
like a forecast down; the status row and the action log name the member
once; every member is read in one gather; modes off reads nothing.
DECISIONS/OPT-17-several-forecast-entities.md has the rules. Mutations
(MUTATION.md, purged copies) are listed in the pull request.
"""
import asyncio
import logging
import os
import re
import sys
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402
from app import (dayplan, homeview, messages, solarfc, uitext, # noqa: E402
validate)
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
L = timezone(timedelta(hours=2))
UTC0 = datetime(2026, 9, 21, tzinfo=L).astimezone(timezone.utc)
TOM = UTC0 + timedelta(days=1)
NOW = datetime(2026, 9, 21, 9, 0, tzinfo=L).astimezone(timezone.utc)
END = UTC0 + timedelta(days=1)
OLD = NOW - timedelta(seconds=solarfc.FRESH_S + 1)
def st(state, unit="kWh", at=NOW, **attrs):
a = dict(attrs)
if unit is not None:
a["unit_of_measurement"] = unit
return {"state": state, "attributes": a,
"last_updated": at.isoformat(), "last_reported": at.isoformat()}
def members(*states):
return [(f"sensor.fc_{i}", s) for i, s in enumerate(states, 1)]
def near(a, b, eps=1e-6):
return a is not None and b is not None and abs(a - b) < eps
# ============================================================================ #
print("1. read_sum")
fc, why, gaps = solarfc.read_sum(members(st("5"), st("7500", "Wh")), [], NOW)
check("two members, kWh + Wh: 5 + 7.5 = 12.5 kWh",
fc is not None and near(fc.today.kwh, 12.5) and why is None
and gaps == ())
check("...and the rest of the day adds up to the sum",
near(fc.between(NOW, END), 12.5))
fc, why, gaps = solarfc.read_sum(
members(st("5"), st("2500", "Wh"), st("0.005", "MWh")), [], NOW)
check("three members, kWh + Wh + MWh: 5 + 2.5 + 5 = 12.5 kWh",
fc is not None and near(fc.today.kwh, 12.5))
for unit in ("W", None, "kW", "%"):
fc, why, gaps = solarfc.read_sum(members(st("5"), st("5", unit)), [],
NOW)
check(f"a member in {unit or 'no unit'} makes today unusable (unit), "
f"and is named",
fc is None and why == solarfc.UNIT
and gaps == (("today", 2, "sensor.fc_2", solarfc.UNIT),))
for bad, w in (("unavailable", solarfc.MISSING), ("unknown", solarfc.MISSING),
(None, solarfc.MISSING), ("-1", solarfc.BAD),
("lots", solarfc.BAD)):
s2 = None if bad is None else st(bad)
fc, why, gaps = solarfc.read_sum(members(st("5"), s2, st("4")), [], NOW)
check(f"a member that reads {bad!r}: no forecast at all ({w}), not "
f"the 9 kWh of the others",
fc is None and why == w
and gaps == (("today", 2, "sensor.fc_2", w),))
fc, why, gaps = solarfc.read_sum(members(st("5"), st("4", at=OLD)), [], NOW)
check("a stale member (older than 3 h): no forecast (stale), named",
fc is None and why == solarfc.STALE
and gaps == (("today", 2, "sensor.fc_2", solarfc.STALE),))
EVE = datetime(2026, 9, 20, 23, 30, tzinfo=L).astimezone(timezone.utc)
MID = datetime(2026, 9, 21, 0, 10, tzinfo=L).astimezone(timezone.utc)
fc, why, gaps = solarfc.read_sum(members(st("5", at=MID), st("0", at=EVE)),
[], MID)
check("a member from before local midnight is stale, not a dark member",
fc is None and why == solarfc.STALE)
fc, why, gaps = solarfc.read_sum(members(st("unavailable"), st("4", at=OLD)),
[], NOW)
check("two members down: both named, the first reason is the day's",
fc is None and why == solarfc.MISSING and [g[1] for g in gaps] == [1, 2])
check("an empty list is not set", solarfc.read_sum([], [], NOW)
== (None, solarfc.NOT_SET, ())
and solarfc.read_sum([], members(st("5")), NOW)[1] == solarfc.NOT_SET)
fc, _w, _g = solarfc.read_sum(members(st("5", at=NOW - timedelta(hours=2)),
st("4")), [], NOW)
check("the forecast's time is the oldest member's report",
fc.at == NOW - timedelta(hours=2))
# Tomorrow.
fc, why, gaps = solarfc.read_sum(members(st("5"), st("4")),
members(st("12"), st("8000", "Wh")), NOW)
check("tomorrow: 12 + 8 = 20 kWh, and today is 9",
fc is not None and near(fc.tomorrow.kwh, 20.0) and near(fc.today.kwh,
9.0)
and near(fc.between(TOM, TOM + timedelta(days=1)), 20.0))
for name, t2, w in (("unavailable", st("unavailable"), solarfc.MISSING),
("stale", st("8", at=OLD), solarfc.STALE),
("in W", st("8", "W"), solarfc.UNIT)):
fc, why, gaps = solarfc.read_sum(members(st("5"), st("4")),
members(st("12"), t2), NOW)
check(f"a tomorrow member {name}: tomorrow is not used (not 12 kWh), "
f"today is kept, the member is named",
fc is not None and fc.tomorrow is None and near(fc.today.kwh, 9.0)
and why is None
and gaps == (("tomorrow", 2, "sensor.fc_2", w),)
and fc.between(NOW, TOM + timedelta(hours=9)) is None)
fc, _w, gaps = solarfc.read_sum(members(st("5"), st("4")), [], NOW)
check("no tomorrow list: today only, nothing named",
fc.tomorrow is None and gaps == ())
# Hourly splits.
sol = [{"period_start": (UTC0 + timedelta(minutes=30 * i)).isoformat(),
"pv_estimate": 2.0 if 20 <= i < 36 else 0.0} for i in range(48)]
wh = {(UTC0 + timedelta(hours=h)).isoformat(): (1000.0 if 8 <= h < 16
else 0.0)
for h in range(24)}
fc, _w, _g = solarfc.read_sum(members(st("8.0", detailedForecast=sol),
st("7.0", wh_period=wh)), [], NOW)
one_s = solarfc.read(st("8.0", detailedForecast=sol), None, NOW)[0]
one_w = solarfc.read(st("7.0", wh_period=wh), None, NOW)[0]
spans = [(NOW, END)] + [
(datetime(2026, 9, 21, h, tzinfo=L), datetime(2026, 9, 21, h + 1,
tzinfo=L))
for h in range(9, 20)]
check("every member has a split: the splits are merged (hour by hour, the "
"sum of each member's own split)",
fc.today.periods and near(fc.today.kwh, 15.0)
and all(near(fc.between(a, b), one_s.between(a, b)
+ one_w.between(a, b)) for a, b in spans))
check("...so 10:00-12:00 is 2 (Solcast) + 2 (Open-Meteo) = 4 kWh",
near(fc.between(datetime(2026, 9, 21, 10, tzinfo=L),
datetime(2026, 9, 21, 12, tzinfo=L)), 4.0))
fc, _w, _g = solarfc.read_sum(members(st("8.0", detailedForecast=sol),
st("7.0")), [], NOW)
bell = solarfc.read(st("15.0"), None, NOW)[0]
check("one member without a split: the sum goes on the bell, as one "
"state-only sensor of 15 kWh would",
fc.today.periods == () and near(fc.today.kwh, 15.0)
and all(near(fc.between(a, b), bell.between(a, b)) for a, b in spans))
wht = {(TOM + timedelta(hours=h)).isoformat(): (500.0 if 8 <= h < 16 else 0)
for h in range(24)}
fc, _w, _g = solarfc.read_sum(members(st("5")),
members(st("4", wh_period=wht),
st("4", wh_period=wht)), NOW)
check("tomorrow's splits are merged too (8 x 0.5 kWh each, 8 in all)",
fc.tomorrow.periods and near(fc.between(TOM, TOM + timedelta(days=1)),
8.0)
and near(fc.between(TOM + timedelta(hours=8),
TOM + timedelta(hours=10)), 2.0))
fc, _w, _g = solarfc.read_sum(members(st("5")),
members(st("4", wh_period=wht), st("4")), NOW)
check("...and fall back to the bell when one has none",
fc.tomorrow.periods == () and near(fc.tomorrow.kwh, 8.0))
# One member is read().
TODAYS = [st("12.5"), st("12500", "Wh"), st("0.0125", "MWh"), st("12.5", "W"),
st("12.5", None), st("unavailable"), st("unknown"), None, st("-1"),
st("lots"), st("12.5", at=OLD), st("12.5", at=NOW + timedelta(
hours=1)), st("8.0", detailedForecast=sol),
st("7.0", wh_period=wh), st("0")]
TOMS = [None, st("20"), st("20", at=OLD), st("unavailable"), st("20", "W"),
st("4", wh_period=wht)]
WHENS = [NOW, MID, datetime(2026, 9, 21, 19, 30, tzinfo=L)]
same, n = True, 0
for now in WHENS:
for a in TODAYS:
for b in TOMS:
n += 1
one = solarfc.read(a, b, now)
got = solarfc.read_sum([("sensor.f", a)],
[("sensor.t", b)] if b is not None
else [], now)
same = same and got[:2] == one
check(f"one member per day gives exactly what read() gives ({n} cases: "
f"units, refusals, stale, splits, tomorrow)", same and n == 270)
# ============================================================================ #
print("2. entities")
E = solarfc.entities
check("the single key alone", E({"pv_forecast_entity": "sensor.a"})
== ("sensor.a",))
check("the list alone, in order", E({"pv_forecast_entities": [
"sensor.b", "sensor.a"]}) == ("sensor.b", "sensor.a"))
check("a list with an entry replaces the single key",
E({"pv_forecast_entity": "sensor.a",
"pv_forecast_entities": ["sensor.b"]}) == ("sensor.b",))
check("an empty list or blank rows use the single key",
E({"pv_forecast_entity": "sensor.a", "pv_forecast_entities": []})
== ("sensor.a",)
and E({"pv_forecast_entity": "sensor.a",
"pv_forecast_entities": ["", " "]}) == ("sensor.a",))
check("a repeated id counts once (it would double that forecast)",
E({"pv_forecast_entities": ["sensor.a", "sensor.b", "sensor.a"]})
== ("sensor.a", "sensor.b"))
check("tomorrow reads its own keys",
E({"pv_forecast_entities": ["sensor.a"],
"pv_forecast_tomorrow_entities": ["sensor.t1", "sensor.t2"]},
tomorrow=True) == ("sensor.t1", "sensor.t2")
and E({"pv_forecast_tomorrow_entity": "sensor.t"}, tomorrow=True)
== ("sensor.t",))
check("nothing set: ()", E({}) == () and E({}, tomorrow=True) == ())
# ============================================================================ #
print("3. options")
F = validate._forecast_problems
for one, many in (solarfc.TODAY_KEYS, solarfc.TOMORROW_KEYS):
p = F({one: "sensor.a", many: ["sensor.b", "sensor.c"]})
check(f"{one} and {many} both set: refused, one plain message that "
f"says which to keep",
len(p) == 1 and "not both" in p[0] and "Keep the list" in p[0]
and one in p[0] and many in p[0] and len(p[0].split()) <= 30)
check(f"...{many} alone is fine, and so is {one} alone",
not F({many: ["sensor.b", "sensor.c"]}) and not F({one: "sensor.a"})
and not F({one: "sensor.a", many: []})
and not F({one: "", many: ["sensor.b"]}))
check(f"...blank rows in {many} are ignored, alone or beside {one}",
not F({many: ["", None, "sensor.b"]})
and not F({one: "sensor.a", many: ["", None]}))
p = F({many: ["sensor.b", "number.x"]})
check(f"...an entry in {many} that is not a sensor is refused",
len(p) == 1 and "only sensors" in p[0] and "number.x" in p[0])
check(f"...and {many} that is not a list is refused",
F({many: "sensor.b"}) and "list of sensors" in F({many: "x"})[0])
with open(os.path.join(HERE, "translations", "en.yaml"),
encoding="utf-8") as fh:
EN = fh.read()
for k in solarfc.TODAY_KEYS + solarfc.TOMORROW_KEYS:
name = re.search(rf"^ {k}:\n name: \"Hardware · ([^\"(]+?)"
rf"(?: \(kWh\))?\"$", EN, re.M)
check(f"the refusal names {k} by its label on the tab",
name is not None
and f"“{name.group(1)}” ({k})" in validate.S(k))
print(" " + F({"pv_forecast_entity": "sensor.a",
"pv_forecast_entities": ["sensor.b"]})[0])
VBASE = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "maintenance_charge_w": 2000,
"maintenance_discharge_w": 2000, "battery_modes_enabled": True}
check("the start-up check refuses both keys set for a day",
any("not both" in p for p in validate.validate_startup_config(
dict(VBASE, pv_forecast_tomorrow_entity="sensor.a",
pv_forecast_tomorrow_entities=["sensor.b"])))
and validate.validate_startup_config(
dict(VBASE, pv_forecast_entities=["sensor.a", "sensor.b"],
pv_forecast_tomorrow_entities=["sensor.c", "sensor.d"]))
== [])
notes = []
validate._entity_notes(notes, dict(VBASE, pv_forecast_entities=[
"sensor.a", "sensor.b", "sensor.gone"]),
{"sensor.a": {"unit": "kWh"}, "sensor.b": {"unit": "W"}})
check("settings notes: a listed sensor not in kWh, and one Home Assistant "
"does not have, are each named",
any("sensor.b" in n.text and "kWh" in n.text for n in notes)
and any("sensor.gone" in n.text for n in notes)
and not any("sensor.a " in n.text for n in notes))
notes = []
validate._entity_notes(notes, dict(VBASE, pv_forecast_tomorrow_entities=[
"sensor.t"]), {"sensor.t": {"unit": "kWh"}})
check("a tomorrow list without today's forecast gets the same warning as "
"the single key", any("used only with today's" in n.text
for n in notes))
notes = []
validate._entity_notes(notes, {"pv_forecast_entities": ["sensor.a"]},
{"sensor.a": {"unit": "kWh"}})
check("a list with battery modes off gets the 'unused' note",
any("battery modes on" in n.text for n in notes))
with open(os.path.join(HERE, "config.yaml"), encoding="utf-8") as fh:
CFG = fh.read()
for k in ("pv_forecast_entities", "pv_forecast_tomorrow_entities"):
check(f"config.yaml: {k} defaults to [] (load-bearing) and is a list "
f"of str",
re.search(rf"^ {k}: \[\]$", CFG, re.M) is not None
and re.search(rf"^ {k}:\n - str$", CFG, re.M) is not None)
for k in solarfc.TODAY_KEYS[:1] + solarfc.TOMORROW_KEYS[:1]:
check(f"config.yaml: {k} is unchanged (str?, default \"\")",
re.search(rf"^ {k}: \"\"$", CFG, re.M) is not None
and re.search(rf"^ {k}: str\?$", CFG, re.M) is not None)
# ============================================================================ #
print("4. the real controller")
A, B = "inverter-a", "inverter-b"
# Prices as in test_modes_p4: cheap 01:00-03:00, dear 19:00-20:00 local.
DAYW = [0.20] * 96 * 2
for i in list(range(4, 12)) + list(range(100, 108)):
DAYW[i] = 0.02
for i in list(range(76, 80)) + list(range(172, 176)):
DAYW[i] = 0.40
WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p)
for i, p in enumerate(DAYW)]
SF_LS = {"a": (True, "solar_first", False),
"b": (True, "leftover_solar", False)}
# Each fake sensor: (share of the day's sun, has a split, status).
SENSORS = {}
FC_KWH = [1.0]
START = [UTC0]
CALLS = []
async def _sensor(self, e):
CALLS.append(("start", e))
await asyncio.sleep(0)
CALLS.append(("end", e))
now = START[0] + timedelta(seconds=self.t)
share, split, status = SENSORS[e]
if status == "down":
return {"state": "unavailable", "attributes": {},
"last_updated": now.isoformat()}
at = now - timedelta(hours=4) if status == "stale" else now
loc = now.astimezone(L)
d0 = loc.replace(hour=0, minute=0, second=0, microsecond=0)
if e.startswith("sensor.tom"):
d0 += timedelta(days=1)
wh = {(d0 + timedelta(hours=h)).astimezone(timezone.utc).isoformat():
(FC_KWH[0] * 100.0 * share if 8 <= h < 18 else 0.0)
for h in range(24)}
left = sum(v for k, v in wh.items()
if datetime.fromisoformat(k) >= now) / 1000.0
attrs = {"unit_of_measurement": "kWh"}
if split:
attrs["wh_period"] = wh
return {"state": f"{left:.6f}", "attributes": attrs,
"last_updated": at.isoformat(), "last_reported": at.isoformat()}
rp.Hass.state = _sensor
def seed(c):
if c.house_profile is not None:
hp = dayplan.HouseProfile()
t = UTC0 - timedelta(days=1)
while t < UTC0:
h = t.astimezone(L).hour
hp.add(t, 3000.0 if 17 <= h < 22 else 600.0)
t += timedelta(seconds=5)
hp.add(t, None)
c.house_profile = hp
def run(opts, hours=4.0, modes=True):
start = UTC0 + timedelta(hours=0.75)
START[0] = start
rows = []
def cb(c, h, now, pr):
sh = dict(c.allocation.shares) if c.allocation is not None and \
c.allocation.shares else {}
rows.append((now, round(h.grid(), 6), tuple(sorted(sh.items())),
getattr(c.arb, "action", None),
tuple(sorted(c.mode_tick.roles.items()))
if c.mode_tick else (),
tuple(sorted((u, round(p.soc, 6))
for u, p in h.plants.items())),
tuple(sorted((c.plan_targets() or {}).items())),
None if c.day_plan is None else c.day_plan.verdict))
_st, c = rp.run("p3_ls" if modes else "modes_off", [(UTC0.timestamp()
- 10, 800.0)],
WPTS, start, 3600 * hours, check=cb,
soc0={"a": 60.0, "b": 30.0}, choice=SF_LS,
extra=dict({"cycle_cost_threshold_eur": 0.20}, **opts),
patch=seed)
return rows, c
def fc_rows(c):
return [r for r in c.checks() if r["id"] == "solar_forecast"]
def fc_events(c):
return [e for e in c.events if "solar forecast" in e]
SENSORS.update({
"sensor.all": (1.0, True, "ok"), "sensor.h1": (0.5, True, "ok"),
"sensor.h2": (0.5, True, "ok"), "sensor.h2_flat": (0.5, False, "ok"),
"sensor.down": (0.5, True, "down"), "sensor.stale": (0.5, True, "stale"),
"sensor.tom_all": (1.0, True, "ok"), "sensor.tom_h1": (0.5, True, "ok"),
"sensor.tom_down": (0.5, True, "down"), "sensor.none": (1.0, True, "down"),
})
single, c_single = run({"pv_forecast_entity": "sensor.all"})
listed, c_listed = run({"pv_forecast_entities": ["sensor.all"]})
plans = [r for r in single if r[6]]
check(f"the run makes a day plan with a level ({len(plans)} of "
f"{len(single)} cycles), so the forecast steers something",
plans and any(r[7] is not None for r in single))
check("a one-member list runs exactly like the single key: every grid "
"figure, share, action, role, level and plan, cycle by cycle",
single == listed and len(single) > 2000)
check("...and says the same on the status page and in the action log",
fc_rows(c_single) == fc_rows(c_listed) == []
and fc_events(c_single) == fc_events(c_listed))
halves, c_halves = run({"pv_forecast_entities": ["sensor.h1", "sensor.h2"]})
check("two members of half the sun each run like one sensor of all of it",
[r[:6] + (r[7],) for r in halves] == [r[:6] + (r[7],) for r in single]
and all(len(a[6]) == len(b[6]) and all(
k1 == k2 and abs(v1 - v2) < 1e-6 for (k1, v1), (k2, v2)
in zip(a[6], b[6])) for a, b in zip(halves, single)))
none_, c_none = run({"pv_forecast_entity": "sensor.none"})
check("(the comparison bites: no forecast runs differently)",
[r[:6] for r in none_] != [r[:6] for r in single])
for name, mem in (("unavailable", "sensor.down"), ("stale", "sensor.stale")):
rows, c = run({"pv_forecast_entities": ["sensor.h1", mem]})
check(f"a member {name}: the controller runs exactly as with the single "
f"forecast unavailable (no partial sum)", rows == none_)
check(f"...the status page names it by place, in one short line",
[r["text"] for r in fc_rows(c)] == [
"Solar forecast for today: not used, source 2 of 2 has no "
"reading"] and fc_rows(c)[0]["warn"])
ev = fc_events(c)
check(f"...and the action log names it once in {len(single)} cycles",
len(ev) == 1 and mem in ev[0] and "today" in ev[0])
one_down, c_one_down = run({"pv_forecast_entities": ["sensor.none"]})
check("a one-member list that is down runs, shows and logs exactly like the "
"single key down", one_down == none_
and fc_rows(c_one_down) == fc_rows(c_none)
and fc_events(c_one_down) == fc_events(c_none))
# A forecast missing from the start logs nothing (the start state is
# already "missing"); that is release/1.0's behaviour, kept.
check("the single key down: no new row and no new log line",
fc_rows(c_none) == [] and fc_events(c_none) == [])
CALLS.clear()
rows, c = run({"pv_forecast_entities": ["sensor.h1", "sensor.h2"],
"pv_forecast_tomorrow_entities": ["sensor.tom_h1",
"sensor.tom_down"]},
hours=0.2)
check("a tomorrow member down: today is used, tomorrow is not, and the row "
"says so",
c.solar_fc is not None and c.solar_fc.tomorrow is None
and [r["text"] for r in fc_rows(c)] == [
"Solar forecast for tomorrow: not used, source 2 of 2 has no "
"reading"]
and len(fc_events(c)) == 1 and "sensor.tom_down" in fc_events(c)[0])
check("...and the page hint says tomorrow's forecast is not used",
(c.hint or "").endswith(messages.hint("forecast_no_tomorrow")))
check("...and Home titles the row as the solar forecast, in EN and NL",
[a["title"] for a in homeview.attention(fc_rows(c))]
== ["Solar forecast"]
and uitext.words("nl")["home_words"]["attn"]["solar_forecast"]
== "Zonneprognose")
first = CALLS[:8]
check("every member of both days is read in one gather (all four start "
"before any ends)",
[k for k, _e in first] == ["start"] * 4 + ["end"] * 4
and sorted(e for _k, e in first[:4]) == sorted(
["sensor.h1", "sensor.h2", "sensor.tom_h1", "sensor.tom_down"]))
rows, c = run({"pv_forecast_entities": ["sensor.h1"],
"pv_forecast_tomorrow_entities": ["sensor.tom_all"]},
hours=0.2)
check("tomorrow: a one-member list is read and counted",
c.solar_fc is not None and c.solar_fc.tomorrow is not None)
rows, c = run({"pv_forecast_entities": ["sensor.h1", "sensor.h2_flat"]},
hours=0.2)
check("a member without a split: the controller's forecast is on the bell",
c.solar_fc is not None and c.solar_fc.today.periods == ())
CALLS.clear()
run({"pv_forecast_entities": ["sensor.h1", "sensor.h2"]}, hours=0.2,
modes=False)
check("battery modes off: no forecast member is read", CALLS == [])
check("the status row is a STYLE status row (<= 15 words, no entity id)",
all(len(t.split()) <= 15 and "sensor." not in t for t in (
messages.row_forecast_gap("today", [2, 3], 3),
messages.row_forecast_gap("tomorrow", [1], 2))))
print()
if fails:
print(f"FAILED: {len(fails)}")
for f in fails:
print(" -", f)
sys.exit(1)
print("all checks passed")
+440
View File
@@ -0,0 +1,440 @@
"""SAFETY-112: waiting for Home Assistant to finish starting. `python3 -B test_safety112.py`
The Supervisor answers 502 to every add-on read, write and websocket until
Home Assistant is RUNNING (DECISIONS/SAFETY-112-waiting-for-home-assistant.md),
so the add-on cannot control then. This file grades what it says meanwhile:
1. The log: one WARNING when Home Assistant stops answering, one INFO line
every 5 minutes while it stays down, one INFO line with the wait when it
answers again. Only the API channel counts as "waiting": a 502 or a
refused connection turns it on, the next good read off, a timeout never.
2. The status page, N=1 and N=2: the waiting row is first and red, and is the
banner. Home says "Waiting for Home Assistant" in English and Dutch, not
"Needs you now", unless a safety stop outranks it. The page script has it.
3. Control is unchanged: the same readings give the same writes with the
waiting flag on or off, with 502 readings and with live ones.
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
"""
import asyncio
import logging
import os
import re
from types import SimpleNamespace
from app import hass as _hass
from app import messages
from app.main import Controller
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
class _Rec(logging.Handler):
def __init__(self):
super().__init__(logging.DEBUG)
self.records = []
def emit(self, record):
self.records.append(record)
def captured(fn):
rec, lg = _Rec(), logging.getLogger("goodwe.hass")
old = lg.level
lg.addHandler(rec)
lg.setLevel(logging.DEBUG)
try:
fn()
finally:
lg.removeHandler(rec)
lg.setLevel(old)
return [(r.levelno, r.getMessage()) for r in rec.records
if r.levelno >= logging.INFO]
class Clock:
"""`hass.time` stand-in: only `monotonic` is read by Outage."""
def __init__(self):
self.t = 1000.0
def monotonic(self):
return self.t
def with_clock(fn):
clock, real = Clock(), _hass.time
_hass.time = SimpleNamespace(monotonic=clock.monotonic)
try:
return fn(clock)
finally:
_hass.time = real
# --------------------------------------------------------------------------- #
print("1. the log while Home Assistant does not answer")
W, I = logging.WARNING, logging.INFO
def outage(clock):
o = _hass.Outage()
o.down("api", "error 502", "read sensor.x: 502")
for t in (1, 60, 299):
clock.t = 1000 + t
o.down("api", "error 502", "read sensor.x: 502")
clock.t = 1300
o.down("api", "error 502", "") # 5 minutes: the first reminder
clock.t = 1301
o.down("websocket", "the meter connection closed", "")
clock.t = 1599
o.down("api", "error 502", "") # 299 s after the reminder: none
clock.t = 1600
o.down("api", "error 502", "") # 10 minutes: the second
clock.t = 1640
o.up("api")
o.up("websocket") # 640 s in all
clock.t = 2000
o.down("api", "no connection", "") # a new outage starts afresh
clock.t = 2030
o.up("api")
lines = with_clock(lambda c: captured(lambda: outage(c)))
check(f"one WARNING per outage, in plain words ({lines})",
[m for lv, m in lines if lv == W] == [
"Home Assistant is not answering (error 502), perhaps it is still "
"starting. Control waits and resumes by itself.",
"Home Assistant is not answering (no connection), perhaps it is "
"still starting. Control waits and resumes by itself."])
check("one 'still waiting' line per 5 minutes down, with the time so far",
[m for lv, m in lines if lv == I and m.startswith("Still")] == [
"Still waiting for Home Assistant to finish starting, 5 minutes "
"so far.",
"Still waiting for Home Assistant to finish starting, 10 minutes "
"so far."])
check("one INFO line when the last channel is back, with the wait, and a new "
"outage counts from its own start",
[m for lv, m in lines if lv == I and m.startswith("Home")] == [
"Home Assistant answers again after 11 minutes.",
"Home Assistant answers again after 30 seconds."])
def channels(_clock):
o = _hass.Outage()
seen = [o.waiting()]
o.down("websocket", "the meter connection closed", "")
seen.append(o.waiting())
o.down("api", "error 502", "")
seen.append(o.waiting())
o.up("websocket")
seen.append(o.waiting())
o.up("api")
seen.append(o.waiting())
return seen
check("waiting() follows the API channel only, not the meter websocket",
with_clock(channels) == [False, False, True, True, False])
# The flag as `HomeAssistant.state()` sets it: a 502 or a refused connection
# turns it on, the next good read turns it off, and a timeout (a slow Home
# Assistant, not a starting one) leaves it alone.
class _Answer:
def __init__(self, what):
self.what = what
async def __aenter__(self):
if isinstance(self.what, BaseException):
raise self.what
return self
async def __aexit__(self, *_a):
return False
@property
def status(self):
return self.what
def raise_for_status(self):
pass
async def json(self):
return {"state": "1"}
class _Session:
def __init__(self, answers):
self.answers = list(answers)
def get(self, *_a, **_k):
return _Answer(self.answers.pop(0))
def reads(answers):
async def go():
real, _hass.OUTAGE = _hass.OUTAGE, _hass.Outage()
lg = logging.getLogger("goodwe.hass")
lg.disabled = True
try:
h = _hass.HomeAssistant(_Session(answers), token="t")
seen = []
for _ in answers:
await h.state("sensor.x")
seen.append(_hass.OUTAGE.waiting())
return seen
finally:
_hass.OUTAGE = real
lg.disabled = False
return asyncio.run(go())
check("a 502 read turns waiting on and the next good read turns it off",
reads([502, 200, 503, 404]) == [True, False, True, False])
check("a refused connection is waiting too",
reads([_hass.aiohttp.ClientConnectionError(), 200]) == [True, False])
check("a read timeout is not waiting",
reads([TimeoutError(), TimeoutError()]) == [False, False])
# --------------------------------------------------------------------------- #
print("2. the status page, N=1 and N=2")
class _Hass:
session = None
token = ""
def __init__(self):
self.writes = []
async def limits(self, _e):
return (-5000.0, 5000.0, 1.0)
async def number(self, _e, invert=False):
return None # what a 502 read gives
async def state(self, _e):
return None
async def set_number(self, e, v):
self.writes.append((e, float(v)))
return False # what a 502 write gives
async def call(self, *_a):
return False
async def states(self):
return None
class _Store:
def __init__(self):
self.data = {}
def set(self, k, v):
self.data[k] = v
def get(self, k, d=None):
return self.data.get(k, d)
def set_time(self, k, v):
self.data[k] = v.isoformat()
def get_time(self, k):
return None
class _Pub:
enabled = False
def publish(self, *_a, **_k):
pass
def close(self):
pass
ONE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": 5000, "shadow_mode": "off",
"maintenance_enabled": False, "stale_input_s": 15}
TWO = dict(ONE, setpoint_entity="", soc_entity="", batt_entity="", inverters=[
{"name": "inverter-a", "setpoint_entity": "number.a", "soc_entity":
"sensor.soc_a", "batt_entity": "sensor.batt_a", "max_w": 2500,
"bms_soc_floor": 10},
{"name": "inverter-b", "setpoint_entity": "number.b", "soc_entity":
"sensor.soc_b", "batt_entity": "sensor.batt_b", "max_w": 2500,
"bms_soc_floor": 10}])
def make(opts, hass=None):
c = Controller(dict(opts), hass or _Hass(), _Store(), _Pub(),
stop=asyncio.Event())
c.auto = True
for inv in c.inverters:
inv.dev_min, inv.dev_max = -5000.0, 5000.0
return c
def waiting_on():
_hass.OUTAGE.down_on = {"api"}
_hass.OUTAGE.since = _hass.OUTAGE._told = _hass.time.monotonic()
def waiting_off():
_hass.OUTAGE.down_on = set()
BANNER = "Not ready — Waiting for Home Assistant to finish starting"
for label, opts in (("N=1", ONE), ("N=2", TWO)):
c = make(opts)
waiting_off()
plain = c.checks()
pay_off = c.status_payload()
waiting_on()
rows = c.checks()
pay = c.status_payload()
pay_nl = c.status_payload(lang="nl")
waiting_off()
check(f"{label}: no waiting row while Home Assistant answers",
not [r for r in plain if r.get("id") == "home_assistant"]
and pay_off["state"]["kind"] != "waiting")
check(f"{label}: while it does not, the waiting row is first and red",
rows[0] == {"ok": False, "warn": False, "id": "home_assistant",
"banner": messages.banner_ha_waiting(),
"text": messages.row_ha_waiting()}
and rows[1:] == plain)
check(f"{label}: ...and it is the banner ({pay['banner']!r})",
pay["banner"] == BANNER and pay["level"] == "bad")
check(f"{label}: Home says waiting, not 'Needs you now' "
f"({pay['state']['pill']!r}, {pay['state']['sub']!r})",
pay["state"]["kind"] == "waiting"
and pay["state"]["pill"] == "Waiting for Home Assistant"
and pay["state"]["sub"] == "Control resumes by itself when Home "
"Assistant answers.")
check(f"{label}: ...and in Dutch ({pay_nl['state']['pill']!r})",
pay_nl["state"]["pill"] == "Wacht op Home Assistant"
and pay_nl["state"]["sub"] == "De regeling gaat vanzelf verder "
"zodra Home Assistant antwoordt.")
check(f"{label}: Needs attention lists it first, with its own title",
pay["attention"][0]["id"] == "home_assistant"
and pay["attention"][0]["title"] == "Home Assistant is not answering"
and pay_nl["attention"][0]["title"] == "Home Assistant antwoordt "
"niet")
# A safety stop outranks the wait: it still needs a person.
c = make(TWO)
c.inverters[1].sign_wd.tripped = True
waiting_on()
pay = c.status_payload()
waiting_off()
check(f"a safety stop keeps its banner and 'Needs you now' over the wait "
f"({pay['banner']!r}, {pay['state']['kind']!r})",
pay["banner"] == messages.banner_direction(True)
and pay["state"]["kind"] == "alarm")
# A stalled control loop does not resume by itself, so it stays the headline.
c = make(ONE)
c.loop_pass_mono = 0.0
waiting_on()
rows, pay = c.checks(), c.status_payload()
waiting_off()
check(f"a stalled control loop stays first and keeps 'Needs you now' "
f"({pay['banner']!r})",
[r["id"] for r in rows[:2]] == ["controller", "home_assistant"]
and pay["state"]["kind"] == "alarm")
c = make(dict(ONE, shadow_mode="on"))
waiting_on()
pay = c.status_payload()
waiting_off()
check("shadow mode keeps its own state over the wait",
pay["state"]["kind"] == "shadow")
# The page script, read as text (the image has no JS engine): an amber dot,
# and the batteries tab carries the banner like the other held states.
JS = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "app",
"static", "app.js"), encoding="utf-8").read()
check("the page script gives 'waiting' an amber dot",
re.search(r'alarm: " bad", waiting: " warn" \}\[st\.kind\]', JS)
is not None)
check("...and shows it as the batteries-tab banner",
'["shadow", "stopped", "alarm", "waiting"].includes(S.state.kind)'
in JS)
# --------------------------------------------------------------------------- #
print("3. control is unchanged by the waiting flag")
def run(opts, flag):
async def go():
h = _Hass()
c = make(opts, h)
(waiting_on if flag else waiting_off)()
targets = []
for _ in range(4):
await c.read_inputs()
await c.cycle()
await c.send_heartbeats(asyncio.get_running_loop().time(), 10.0)
targets.append(c.target)
waiting_off()
return targets, h.writes
return asyncio.run(go())
for label, opts in (("N=1", ONE), ("N=2", TWO)):
off, on = run(opts, False), run(opts, True)
check(f"{label}: same targets and writes with the flag on or off "
f"({on[0]}, {len(on[1])} writes)", off == on)
check(f"{label}: ...and every write while Home Assistant does not answer "
f"asks 0 W", on[1] and all(v == 0.0 for _e, v in on[1]))
# With 502 readings every write is 0 W whatever the flag says, so the check
# above cannot see a flag that forces 0 W. Live readings can: the flag alone
# must not change a non-zero write.
class _LiveHass(_Hass):
async def number(self, e, invert=False):
v = {"sensor.grid": 900.0}.get(e, 50.0 if "soc" in e else 0.0)
return -v if invert else v
async def set_number(self, e, v):
self.writes.append((e, float(v)))
return True
def run_live(opts, flag):
async def go():
h = _LiveHass()
c = make(opts, h)
(waiting_on if flag else waiting_off)()
for _ in range(4):
await c.read_inputs()
await c.cycle()
await c.send_heartbeats(asyncio.get_running_loop().time(), 10.0)
waiting_off()
return h.writes
return asyncio.run(go())
for label, opts in (("N=1", ONE), ("N=2", TWO)):
off, on = run_live(opts, False), run_live(opts, True)
check(f"{label}: with live readings the flag changes no write "
f"({on[-2:]})", off == on and any(v != 0.0 for _e, v in on))
print()
if fails:
print(f"{len(fails)} FAILED")
for f in fails:
print(" -", f)
raise SystemExit(1)
print("all passed")
@@ -767,6 +767,79 @@ ok_i4, out_i4 = validate(dict(defaults, **{_mk: 24}))
show("the range is unchanged: 24 is refused (`at most 23`)",
(not ok_i4) and "at most 23" in str(out_i4), out_i4)
print("\n== J. OPT-17: several solar forecast entities per day ==")
# The same question as §G, for `pv_forecast_entity` /
# `pv_forecast_tomorrow_entity` (`str?` since 0.15.0). Each refusal is
# varied from the accepted baseline and asserted on the key's name.
# DECISIONS/OPT-17-several-forecast-entities.md.
FC_STORED = {"pv_forecast_entity": "sensor.energy_production_today_remaining",
"pv_forecast_tomorrow_entity": "sensor.energy_production_tomorrow"}
_fc_old = {k: v for k, v in defaults.items()
if k not in ("pv_forecast_entities", "pv_forecast_tomorrow_entities")}
_fc_base = MERGER.merge(copy.deepcopy(defaults),
dict(_fc_old, **FC_STORED))
ok_j, out_j = validate(_fc_base)
show("a 0.17.0 blob (single forecast keys set, no list keys) validates "
"unchanged, and both new keys default to []",
ok_j and out_j.get("pv_forecast_entity") == FC_STORED["pv_forecast_entity"]
and out_j.get("pv_forecast_entities") == []
and out_j.get("pv_forecast_tomorrow_entities") == [],
"" if ok_j else out_j)
for _k in ("pv_forecast_entity", "pv_forecast_tomorrow_entity"):
_s1 = dict(schema, **{_k: ["str"]})
ok_j1, out_j1 = validate_with(_s1, _fc_base)
show(f"`{_k}` declared as a LIST refuses the string an install stores",
(not ok_j1) and f"Invalid list for option '{_k}'" in str(out_j1),
out_j1)
_m1 = MERGER.merge(copy.deepcopy(dict(defaults, **{_k: []})),
copy.deepcopy(FC_STORED))
ok_j2, out_j2 = validate_with(_s1, _m1)
show("...and a `[]` default does not rescue it (list merge is override)",
_m1[_k] == FC_STORED[_k] and not ok_j2, out_j2)
_sm = dict(schema, **{_k: "match(.*)?"})
_ok_s, _ = validate_with(_sm, _fc_base)
_ok_l, _ = validate_with(_sm, dict(_fc_base, **{_k: ["sensor.a",
"sensor.b"]}))
_ok_n, _ = validate_with(_sm, dict(_fc_base, **{_k: 417}))
_ui_m = _ui_of(_sm, _k)
show(f"...`match(.*)?` on `{_k}` takes a string AND a list, but also the "
"integer 417, and renders as a plain text box (no second row)",
_ok_s and _ok_l and _ok_n and _ui_m
and _ui_m[0].get("multiple") is not True,
f"string={_ok_s} list={_ok_l} integer-417={_ok_n} "
f"ui={json.dumps(_ui_m)}")
_two = ["sensor.fc_east", "sensor.fc_west"]
ok_j3, out_j3 = validate(MERGER.merge(copy.deepcopy(defaults), dict(
_fc_old, pv_forecast_entities=_two,
pv_forecast_tomorrow_entities=_two)))
show("⭐ SHIPPED: two-entity lists for today and tomorrow validate",
ok_j3 and out_j3.get("pv_forecast_entities") == _two
and out_j3.get("pv_forecast_tomorrow_entities") == _two,
"" if ok_j3 else out_j3)
for _k in ("pv_forecast_entities", "pv_forecast_tomorrow_entities"):
ok_j4, out_j4 = validate({k: v for k, v in defaults.items() if k != _k})
show(f"...and the `options:` block with ONLY `{_k}` removed is refused "
"by name, so its `[]` default is load-bearing",
(not ok_j4) and f"Missing option '{_k}' in root" in str(out_j4),
out_j4)
_ui_j = _ui_of(schema, _k)
show(f"...and `{_k}` renders as a repeatable string field",
len(_ui_j) == 1 and _ui_j[0].get("multiple") is True
and _ui_j[0].get("type") == "string", json.dumps(_ui_j))
_flat_j = {k: v for k, v in schema.items()
if k not in ("pv_forecast_entities", "pv_forecast_tomorrow_entities")}
try:
_rj = vol.Schema(vol.All(dict, AppOptions(
None, _flat_j, cfg["name"], "local_goodwe_controller")))(
MERGER.merge(copy.deepcopy(defaults), dict(
FC_STORED, pv_forecast_entities=_two)))
show("a DOWNGRADE to 0.17.0's schema drops the list keys and keeps the "
"single ones",
"pv_forecast_entities" not in _rj
and _rj.get("pv_forecast_entity") == FC_STORED["pv_forecast_entity"])
except vol.Invalid as e:
show("a DOWNGRADE to 0.17.0's schema drops the list keys", False, str(e))
# ⚠️ THE EXIT CODE IS PART OF THE INSTRUMENT. Without it the probe prints FAIL
# and still succeeds, and a transcript is only evidence if a wrong answer is
# distinguishable from a right one by something other than reading it.
+6
View File
@@ -91,6 +91,12 @@ configuration:
pv_forecast_tomorrow_entity:
name: "Hardware · Solar forecast, tomorrow (kWh)"
description: "Optional. The same forecast for tomorrow. It helps plan a cheap hour before midnight."
pv_forecast_entities:
name: "Hardware · Several solar forecasts, today (kWh)"
description: "Optional. One row per forecast sensor, such as one per inverter, used instead of the single one above. They are added up."
pv_forecast_tomorrow_entities:
name: "Hardware · Several solar forecasts, tomorrow (kWh)"
description: "Optional. The same list for tomorrow. If one sensor has no reading, that day's forecast is not used."
solar_trust_pct:
name: "Hardware · Trust in the forecast (%)"
description: "Forecasts are often too sunny. At 70 % the add-on plans with 7 of every 10 kWh forecast."
+6
View File
@@ -72,6 +72,12 @@ configuration:
pv_forecast_tomorrow_entity:
name: "Hardware · Zonneprognose, morgen (kWh)"
description: "Optioneel. Dezelfde prognose voor morgen. Ze helpt een goedkoop uur voor middernacht te plannen."
pv_forecast_entities:
name: "Hardware · Meerdere zonneprognoses, vandaag (kWh)"
description: "Optioneel. Eén rij per prognosesensor, bijvoorbeeld per omvormer, in plaats van de losse sensor hierboven. Ze worden opgeteld."
pv_forecast_tomorrow_entities:
name: "Hardware · Meerdere zonneprognoses, morgen (kWh)"
description: "Optioneel. Dezelfde lijst voor morgen. Heeft één sensor geen meting, dan wordt de prognose voor die dag niet gebruikt."
solar_trust_pct:
name: "Hardware · Vertrouwen in de prognose (%)"
description: "Prognoses zijn vaak te zonnig. Bij 70 % plant de add-on met 7 van elke 10 kWh uit de prognose."