UX-09: Details link for current HA, "would" in shadow mode, plain terms
- Details link: /config/app/<slug>/documentation on HA 2026.2+ (the old /hassio/addon path is a 404 from 2026.5), /hassio/addon on 2026.1 and older. Slug from GET /addons/self/info, version from GET /info, hostname as fallback. - Shadow mode: the over-rating alarm and row say what would have been sent, the peak alarm no longer says the battery is giving all it can. - "controller" -> "the add-on" / "control". Settings named by their labels; four labels changed to match (Peak reserve, Battery level entity, Solar production ..., Reversed-sign solar entities). - Shadow banner and inverter link notes moved to messages.py and checked. - DOCS Notifications and Status page rewritten for home owners, with a "For installers" part each. Allowlist 168 -> 167. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
This commit is contained in:
@@ -26,22 +26,29 @@ Severity is shown by the title and the colour, never by CAPITALS or by words lik
|
||||
A rewrite never changes an alarm's severity, `notification_id` or cadence.
|
||||
|
||||
## 4. Words - the same word for the same thing everywhere
|
||||
A setting is named by its label in `translations/en.yaml`, in quotes when it sits mid-sentence (“Inverter rating”). The words below match those labels. If a label changes, change it here and in `app/messages.py` in the same change.
|
||||
|
||||
| Say | Not |
|
||||
|---|---|
|
||||
| the add-on (who decides), control (what it does: "start control", "Control: last ran …") | controller, loop, planner |
|
||||
| battery | pack, BMS pack |
|
||||
| inverter, or its name | unit, device, actuator |
|
||||
| controller | loop, planner |
|
||||
| meter | P1 feed, telegram |
|
||||
| peak limit / this month's peak | ceiling, capaciteitstarief, billed quarter |
|
||||
| 15-minute period | quarter |
|
||||
| battery level | SoC, state of charge |
|
||||
| cut-out level | BMS floor, `bms_soc_floor` |
|
||||
| peak reserve | `peak_soc_floor`, EMS floor |
|
||||
| inverter rating | `max_w`, rail |
|
||||
| battery level (setting: “Battery level entity”) | SoC, state of charge, charge level |
|
||||
| battery cut-out level, or cut-out level next to "battery" (setting: “Battery cut-out level”) | BMS floor, `bms_soc_floor` |
|
||||
| peak reserve (setting: “Peak reserve”) | `peak_soc_floor`, EMS floor, "peak guard will not discharge below" |
|
||||
| “Inverter rating” (the setting) | `max_w`, rail, inverter rating as a fact about the hardware |
|
||||
| solar production, solar entity (settings: “Solar production entity”, “Solar production entities, per inverter”, “Reversed-sign solar entities”) | PV, reversed-sign list, single sensor setting |
|
||||
| “Where the meter reading comes from” | meter source |
|
||||
| “Grid power entity”; entity for any Home Assistant sensor a setting points at | grid power sensor, sensor setting |
|
||||
| draw the battery down, give power | discharge, discharging |
|
||||
| control is stopped / every inverter is set to 0 W | failsafe, trip, latch, parked |
|
||||
| safety stop (a stop that only starting control clears) | failsafe latch |
|
||||
| software fault | BUG SIGNAL |
|
||||
| control board | master, ESP32 |
|
||||
| shadow mode | a mode that makes every decision but commands 0 W. The name is kept. |
|
||||
| shadow mode | a mode that makes every decision but commands 0 W. The name is kept. In shadow mode a text says what the add-on **would** send, never that it sent it. |
|
||||
|
||||
**Banned in titles and bodies** (the test fails on them): SAFETY-/TARIFF-/TEL-/CFG-/FW-/HW-nn and other card ids, integrator, anti-windup, windup, clamp, clamped, saturated, BUG SIGNAL, latch, latched, arbiter, allocator, register, `0x…`, layer 1/2/3, heartbeat, watchdog, telegram, ACK, CRC, OTA, Modbus, planner, `§n`, file names, IP addresses, option keys in snake_case, **HA entity ids**, and runs of 2 or more CAPITALISED words.
|
||||
|
||||
|
||||
@@ -26,3 +26,12 @@ Every row of the Commissioning list and every banner is now in `app/messages.py`
|
||||
6. **How a latch clears stays on the row**, shortened: "Only starting control clears this", "A restart does not clear this", "only stopping and starting control clears this" (not delivering). SAFETY-25's sentence stays on the peak-reserve row.
|
||||
7. **Rows got an `id`** (`meter_frozen`, `direction`, `pv`, …), so tests find a row by what it is, not by its words. The page ignores it.
|
||||
8. **Left for later:** texts that rows embed from other modules and that were not flagged as rows (the month-peak line, `peakguard.explain()`, the liveness and stale notes, `shadow.banner()`). The month-peak row only lost its semicolons.
|
||||
|
||||
# UX-09: fixes after the phase 2a test
|
||||
|
||||
1. **The Details link follows the Home Assistant version.** HA 2026.2 added the built-in `app` panel (core #157554, frontend #28245), whose add-on pages are `/config/app/<slug>/documentation`. 2026.5 stopped registering the `hassio` panel, so `/hassio/addon/<slug>/documentation` is a 404 there (TEST-23, HA 2026.9.1). 2026.1 and older have only the old path. The one path valid on both, `/_my_redirect/supervisor_addon?addon=<slug>`, opens the Info tab, not Documentation. So the add-on reads its slug (`GET /addons/self/info`) and the HA version (`GET /info`) at start, and picks the path. An unknown version gets the current path. The hostname stays as a fallback for the slug. Phase 2a item 7 is replaced by this.
|
||||
2. **Shadow mode says "would".** The over-rating alarm and row said "sent X W" while 0 W was on the wire, and the peak alarm said "the battery is giving all it can". Both now take `shadow=`, and in shadow mode say what would have been sent and that the inverters stay at 0 W. Severity, id and cadence do not change with the mode.
|
||||
3. **"Controller" is gone from texts.** "The add-on" names who decides, and "control" names what it does, as in the existing "start control". The add-on is what the reader sees in Home Assistant, and "controller" was easy to confuse with the control board.
|
||||
4. **One term per setting, matching its label.** Four labels changed instead of the texts, because the texts' words were plainer: “Peak reserve (%)” (was "Peak guard will not discharge below"), “Battery level entity (%)” (was "Battery charge level"), and “Solar production entity / entities, per inverter / Reversed-sign solar entities” (was "PV …"). The Dutch labels already said "Zonneproductie". Option descriptions are phase 2c and were not changed, so some still say "PV".
|
||||
5. **The shadow banner and the inverter notes** are now `messages.banner_shadow()` and `row_no_setpoint` / `row_not_controllable` / `row_commands_refused`, so `test_messages.py` checks them. The red shadow banner no longer lists the latches, because 12 words cannot carry them. It says a safety stop stays on, and each latch keeps its own red row, which names it and says how it clears. The colour of a liveness row now comes from `is_live()`, not from matching "NOT COMMANDABLE" in the text.
|
||||
6. **DOCS "Notifications" and "Status page"** are written for a home owner, and the card ids, keys and log detail moved to a "For installers" part of each.
|
||||
|
||||
+190
-109
@@ -2805,123 +2805,170 @@ it.
|
||||
|
||||
## Notifications
|
||||
|
||||
Each Home Assistant notification this add-on raises has a short title, one
|
||||
sentence (a red alarm may use three), and a **Details** link to the add-on
|
||||
documentation that names the section below. This section holds what the
|
||||
notification leaves out. The wording rule is `STYLE.md` in the repository; the
|
||||
texts live in `app/messages.py`. Every notification keeps a fixed
|
||||
`notification_id`, so a repeat replaces the card instead of adding one.
|
||||
The add-on tells you about a problem with a Home Assistant notification. Each
|
||||
one has a short title, one to three sentences, and a **Details** link to this
|
||||
page. The link opens the add-on's Documentation tab. Look for the section with
|
||||
the same name as the notification title below. A repeat of the same problem
|
||||
replaces the notification rather than adding a new one.
|
||||
|
||||
| Title | Severity | id |
|
||||
|---|---|---|
|
||||
| Battery is not keeping up | warning | `goodwe_integrator_bound` |
|
||||
| Power request too high | alarm | `goodwe_output_clamp` |
|
||||
| Load too high for battery | alarm | `goodwe_peak_saturated` |
|
||||
| Battery stopped limiting the peak | alarm | `goodwe_peak_soc_floor` |
|
||||
| Inverters working against each other | alarm | `goodwe_circulating_power` |
|
||||
| *name* is not delivering | alarm | `goodwe_not_delivering_<name>` |
|
||||
| Meter reading may be stuck | alarm | `goodwe_meter_frozen` |
|
||||
In **shadow mode** the add-on makes every decision but sends nothing: every
|
||||
inverter stays at 0 W. Notifications then say what the add-on *would* have
|
||||
sent.
|
||||
|
||||
### Battery is not keeping up
|
||||
|
||||
The grid-following loop's integrator reached its anti-windup bound
|
||||
(`integrator_max_w`): the controller has been asking for more than the battery
|
||||
delivers for a while. This is SAFETY-40, and it is **not** a fault. No command
|
||||
went over the rating and control keeps running. Short spells after a big load
|
||||
or solar step are normal. If it keeps coming back, check that the battery
|
||||
follows its commands. The bound and the command of that tick are in the add-on
|
||||
log (WARNING, reason code `OUTPUT_CLAMP`). See *The winding-loop alarm* for the
|
||||
full story.
|
||||
The battery gives less power than the add-on asks for. This is normal for a
|
||||
short time, for example just after a large load switches on or the sun comes
|
||||
out. It is not a fault, and control keeps running. If it keeps coming back,
|
||||
check that the battery is switched on, and that it is not empty (when it should
|
||||
give power) or full (when it should charge).
|
||||
|
||||
### Power request too high
|
||||
|
||||
The controller computed a command above a limit, and the command was cut to that
|
||||
limit before it was written. The notification names what was asked, what was
|
||||
sent, and which limit cut it: the configured inverter rating (`max_w`), the
|
||||
inverter's own limit (the setpoint device's range), or the inverter rating on
|
||||
the loop side. Asked always comes first. This is a **software fault** (the old
|
||||
wording was "BUG SIGNAL"). It is not the inverter failing to follow a legal
|
||||
command, which is the saturation freeze and does not alarm. Control keeps
|
||||
running. Report it with the add-on log, whose ERROR line carries the reason code
|
||||
`OUTPUT_CLAMP` and the full figures. See *The winding-loop alarm*.
|
||||
The add-on worked out a power request above what is allowed, and cut it back
|
||||
before sending it. The notification shows what was asked and what was sent,
|
||||
and which limit cut it: the “Inverter rating” setting, or the inverter's own
|
||||
limit. In shadow mode it shows what *would* have been sent. Control keeps
|
||||
running. This should never happen, so it is a software fault: please send the
|
||||
notification and the add-on log to your installer.
|
||||
|
||||
### Load too high for battery
|
||||
|
||||
The capacity-peak guard (SAFETY-07) is asking for everything the inverter can
|
||||
discharge, and the running 15-minute average is still projected over the peak
|
||||
limit. That 15-minute period will set or raise the month's billed peak
|
||||
(capaciteitstarief). The controller is not at fault: the house load is larger
|
||||
than the inverter. Switching off load in the next few minutes is the only thing
|
||||
that still changes the bill. The projection and the watts are in the add-on
|
||||
log. See *The capacity-peak guard*.
|
||||
Your home uses more power than the battery can supply, so this 15-minute
|
||||
period will go over your peak limit and raise this month's peak. The battery
|
||||
is already giving all it can. This is not a fault. Switch off large loads,
|
||||
such as an oven, a car charger or a heat pump, for the next few minutes. In
|
||||
shadow mode the battery gives nothing, so this can happen with a much smaller
|
||||
load.
|
||||
|
||||
### Battery stopped limiting the peak
|
||||
|
||||
Two words in this notification are settings. The **peak reserve** is
|
||||
*Peak guard will not discharge below (%)* (`peak_soc_floor`). The
|
||||
**cut-out level** is *Battery cut-out level (%)* (`bms_soc_floor`), where the
|
||||
battery's own system stops discharging.
|
||||
The add-on stopped using the battery to keep your peak down, so this
|
||||
15-minute period will go over your peak limit and raise this month's peak.
|
||||
Switch off large loads for the next few minutes. The notification says why,
|
||||
and what to do:
|
||||
|
||||
The peak guard wanted to discharge to hold the 15-minute peak, but it may not go
|
||||
below the peak reserve (`peak_soc_floor`, SAFETY-24). It also stops when the
|
||||
battery level cannot be read, or when an inverter's cut-out level is not set,
|
||||
because the reserve is graded against it. So this 15-minute
|
||||
period will go over the peak limit and set or raise the month's billed peak.
|
||||
Charging from the grid stays blocked meanwhile. On a site with several inverters
|
||||
the notification names the battery at its limit (SAFETY-32): each battery is
|
||||
graded against its own cut-out level (`bms_soc_floor`), and raising the other
|
||||
batteries' settings will not change this one.
|
||||
- **The battery is at its peak reserve.** The “Peak reserve” setting keeps
|
||||
that much charge back from peak limiting. Lower it if you accept a lower
|
||||
battery level.
|
||||
- **The battery level cannot be read.** Check the “Battery level entity”
|
||||
setting and that sensor in Home Assistant.
|
||||
- **A battery cut-out level is not known.** Fill in “Battery cut-out level”
|
||||
for each inverter in the settings.
|
||||
|
||||
⚠️ **The reserve binds the peak guard only (SAFETY-25).** It is not a
|
||||
whole-battery reserve. Ordinary self-consumption has no such floor and goes on
|
||||
discharging below it, down to the battery's own cut-out level. See *What
|
||||
`peak_soc_floor` does not protect*.
|
||||
The peak reserve only applies to keeping the peak down. Normal home use can
|
||||
still draw the battery below it, down to the battery cut-out level, where the
|
||||
battery switches itself off.
|
||||
|
||||
### Inverters working against each other
|
||||
|
||||
Two inverters were pushing power into each other: one discharging while another
|
||||
charged (SAFETY-31). **The meter cannot see this.** The two flows cancel at the
|
||||
P1 meter, so the site reads idle while both batteries cycle through two
|
||||
conversion stages. Every other guard reads that net figure, so none of them
|
||||
fires. The add-on has **stopped control and commands 0 W on every inverter**.
|
||||
If the grid reading does not move after that, one inverter is no longer
|
||||
listening to the add-on; this has happened on this hardware, once for
|
||||
6 h 25 min. Switch the inverters off at the isolator. The alarm is latched: a
|
||||
Home Assistant or add-on restart does not clear it, and the card is raised
|
||||
again every 5 min until you start control on the add-on page. The watts and the time
|
||||
window are in the add-on log; the *Circulating* line on the status page shows the
|
||||
live watts. See *The circulating-power alarm*.
|
||||
With several inverters, one was charging its battery from another. Your meter
|
||||
cannot see this, because the two flows cancel out, but it wastes energy. The
|
||||
add-on has stopped control and set every inverter to 0 W. If the grid power
|
||||
does not change after that, switch the inverters off at the isolator. A
|
||||
restart does not clear this. Find out why, then start control on the add-on
|
||||
page.
|
||||
|
||||
### An inverter is not delivering
|
||||
|
||||
One inverter has stood well short of its share of the site target for longer
|
||||
than the hold time (SAFETY-42), while the others have not. No other check sees
|
||||
this. A unit at 0 W is absent rather than pushing the wrong way, so the per-unit
|
||||
sign check and the circulation check stay quiet. **Nothing is commanded
|
||||
differently.** The guard only reports: commanding an inverter that has stopped
|
||||
listening achieves nothing, and the only fallback the add-on has is
|
||||
all-or-nothing. The other inverters keep regulating, and the site is short by up
|
||||
to the missing unit's rating. Check, in this order:
|
||||
With several inverters, one gives much less power than it is asked for, while
|
||||
the others do. Nothing is changed: control keeps running, and the other
|
||||
inverters carry on. Check, in this order:
|
||||
|
||||
1. Is the inverter answering on RS485 at all?
|
||||
2. Is its battery colder or emptier than its configured cut-out level
|
||||
(`bms_soc_floor`) says? If so, that setting does not describe this battery.
|
||||
3. Is anything else writing to it?
|
||||
1. Is the inverter on and connected?
|
||||
2. Is its battery empty or cold? If it is emptier than its “Battery cut-out
|
||||
level” says, that setting does not match this battery.
|
||||
3. Is anything else controlling it?
|
||||
|
||||
The alarm is latched and survives restarts. Control is still running, so to
|
||||
clear it, press *Start / stop control* twice (stop, then start) once you know
|
||||
why. See *A unit that stops DELIVERING is a third fault again*.
|
||||
A restart does not clear this. Once you know why, stop and then start control
|
||||
on the add-on page.
|
||||
|
||||
### Meter reading may be stuck
|
||||
|
||||
The meter keeps answering, but its value has not changed for longer than the
|
||||
hold time while the command stands at the power limit (SAFETY-18). The loop is
|
||||
regulating against a number that has stopped moving. The age is still fresh, so
|
||||
no other guard stops it. This is an alarm only: control keeps running and keeps
|
||||
following the reading. Held for a whole 15-minute period, it can set the month's
|
||||
billed peak. Check the meter; if it is stuck, stop control on the add-on page.
|
||||
The limit and command figures are in the add-on log. See
|
||||
*The frozen-meter alarm*.
|
||||
The meter keeps answering, but its value has not changed for a long time while
|
||||
the add-on asks the battery for full power. That usually means the meter
|
||||
reading is stuck. Control keeps running and keeps following the reading, so a
|
||||
stuck reading can raise this month's peak. Check the meter. If it is stuck,
|
||||
stop control on the add-on page.
|
||||
|
||||
### For installers: notifications
|
||||
|
||||
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
|
||||
replaces the card. The full figures and reason codes are on the ERROR or
|
||||
WARNING line of the add-on log.
|
||||
|
||||
| Title | Severity | id | Card |
|
||||
|---|---|---|---|
|
||||
| Battery is not keeping up | warning | `goodwe_integrator_bound` | SAFETY-40 |
|
||||
| Power request too high | alarm | `goodwe_output_clamp` | SAFETY-03 |
|
||||
| Load too high for battery | alarm | `goodwe_peak_saturated` | SAFETY-07 |
|
||||
| Battery stopped limiting the peak | alarm | `goodwe_peak_soc_floor` | SAFETY-24/25/32 |
|
||||
| Inverters working against each other | alarm | `goodwe_circulating_power` | SAFETY-31 |
|
||||
| *name* is not delivering | alarm | `goodwe_not_delivering_<name>` | SAFETY-42 |
|
||||
| Meter reading may be stuck | alarm | `goodwe_meter_frozen` | SAFETY-18 |
|
||||
|
||||
**The Details link.** Home Assistant 2026.2 and newer show add-on pages at
|
||||
`/config/app/<slug>/documentation`; 2026.5 removed the old
|
||||
`/hassio/addon/<slug>/documentation`, which 2026.1 and older still use. At
|
||||
start the add-on reads its slug from `GET /addons/self/info` and the Home
|
||||
Assistant version from `GET /info`, and builds the link for that version. If
|
||||
the version cannot be read it uses the new path. If the slug cannot be read it
|
||||
falls back to the container hostname, and outside an add-on the line names the
|
||||
section without a link. The documentation tab adds no heading anchors, so the
|
||||
link cannot jump to the section.
|
||||
|
||||
**Battery is not keeping up.** The grid-following loop's integrator reached its
|
||||
anti-windup bound (`integrator_max_w`, SAFETY-40). No command went over the
|
||||
rating. The log line is a WARNING with reason code `OUTPUT_CLAMP`. See *The
|
||||
winding-loop alarm*.
|
||||
|
||||
**Power request too high.** A computed command above a limit was cut before it
|
||||
was written (SAFETY-03). The limits are `max_w` (label “Inverter rating”), the
|
||||
setpoint device's own range, or the rating on the loop side. Asked always comes
|
||||
first. The old wording was "BUG SIGNAL". It is not the inverter failing to
|
||||
follow a legal command, which is the saturation freeze and does not alarm. In
|
||||
shadow mode the clamp still grades the decision (SAFETY-10), so the alarm can
|
||||
fire while 0 W is on the wire, and the text says "would have sent". ERROR line,
|
||||
reason code `OUTPUT_CLAMP`.
|
||||
|
||||
**Load too high for battery.** The capacity-peak guard (SAFETY-07) asks for
|
||||
everything the inverter can discharge and the running 15-minute average is
|
||||
still projected over the peak limit, so the month's billed peak
|
||||
(capaciteitstarief) goes up. In shadow mode the guard's projection is made on a
|
||||
plant that did not respond (SAFETY-47), and the text says so. See *The
|
||||
capacity-peak guard*.
|
||||
|
||||
**Battery stopped limiting the peak.** The peak guard may not discharge below
|
||||
`peak_soc_floor` (label “Peak reserve”, SAFETY-24). It also stops when the
|
||||
state of charge is unread, or when an inverter's `bms_soc_floor` (label
|
||||
“Battery cut-out level”) is missing, because the reserve is graded against it.
|
||||
Charging from the grid stays blocked meanwhile. With several inverters the
|
||||
notification names the battery at its limit (SAFETY-32), each graded against
|
||||
its own cut-out level. ⚠️ **The reserve binds the peak guard only
|
||||
(SAFETY-25).** Self-consumption has no such floor. See *What `peak_soc_floor`
|
||||
does not protect*.
|
||||
|
||||
**Inverters working against each other.** SAFETY-31. The flows cancel at the
|
||||
P1 meter, so every other guard, which reads that net figure, stays quiet. This
|
||||
has happened on this hardware once for 6 h 25 min. The alarm is latched,
|
||||
survives restarts, and is raised again every 5 min until control is started.
|
||||
The watts and the time window are in the log; the *Circulating power check*
|
||||
row shows the live watts. See *The circulating-power alarm*.
|
||||
|
||||
**An inverter is not delivering.** SAFETY-42. The unit stood well short of its
|
||||
share of the site target for longer than `delivery_hold_s`. A unit at 0 W is
|
||||
absent rather than pushing the wrong way, so the per-unit sign check and the
|
||||
circulation check stay quiet. The guard only reports: commanding an inverter
|
||||
that has stopped listening achieves nothing. Check first whether it answers on
|
||||
RS485. The alarm is latched and survives restarts; stop then start clears it.
|
||||
See *A unit that stops DELIVERING is a third fault again*.
|
||||
|
||||
**Meter reading may be stuck.** SAFETY-18. The value has not changed for
|
||||
longer than `meter_freeze_s` while the command stands at the power limit, and
|
||||
the age is still fresh, so no other guard stops it. Only `meter_source:
|
||||
homewizard_local` can tell a silent meter from an unchanged value. The limit
|
||||
and command figures are in the log. See *The frozen-meter alarm*.
|
||||
|
||||
## The Web UI
|
||||
|
||||
@@ -2938,27 +2985,61 @@ exactly the three buttons it always had.
|
||||
|
||||
## Status page
|
||||
|
||||
The banner at the top says in one line what matters most now. Below it, the
|
||||
**Commissioning** list has one short row per check: ✓ green is fine, ! amber
|
||||
needs a look, ✗ red needs action. The rows name settings in words, never a Home
|
||||
Assistant entity id. The full figures of any alarm are in the add-on log, on the
|
||||
ERROR or WARNING line of that alarm. The texts are in `app/messages.py`.
|
||||
The add-on page shows a banner at the top and a **Commissioning** list below
|
||||
it. The banner says in one line what matters most now. Each row of the list is
|
||||
one check: ✓ green is fine, ! amber needs a look, ✗ red needs action. Rows name
|
||||
settings by their label, never by a Home Assistant entity id.
|
||||
|
||||
In **shadow mode** the banner says so first, whatever else is going on: the
|
||||
add-on makes every decision but the battery is not controlled, and every
|
||||
inverter stays at 0 W. If the banner is red and says a safety stop stays on,
|
||||
one of the red rows below is a stop that will still be there when shadow mode
|
||||
ends. That row says how to clear it.
|
||||
|
||||
| Row starts with | What it tells you |
|
||||
|---|---|
|
||||
| Controller | When the controller last ran. Red when it has stalled: the control board then sets the inverter to 0 W. |
|
||||
| Meter | The grid reading, its age, readings refused, and how long its value has not changed. |
|
||||
| Stuck-meter check | Whether the frozen-meter alarm can run. It needs the HomeWizard local source (`homewizard_local`), an inverter rating, and a power range the inverter can reach. *The inverter's own limit* means the setpoint device's range sets the threshold, below `max_w`. |
|
||||
| Meter reading may be stuck | The frozen-meter alarm is on. See *Meter reading may be stuck* under Notifications. |
|
||||
| *month*: peak … | This month's peak, and how close it is to the soft limit. |
|
||||
| Peak guard, Load too high, Battery at its peak reserve | What the capacity-peak guard is doing. *Peak guard: off* means `meter_source` is `off`, so there is no 15-minute average to guard. |
|
||||
| Battery level, Battery power, Grid power | The reading of that sensor. *No usable reading* means the sensor is missing **or** its reading is held back as too old (a separate stale-reading row then says so). Check it in Developer Tools before replacing it. |
|
||||
| Inverter control | The setpoint entity, and the power range the inverter accepts. |
|
||||
| Power request too high, Battery is not keeping up | The two loop alarms, each with its own count since start. After an episode clears, the count stays, ending in *fine now*. See *The winding-loop alarm*. |
|
||||
| Direction check, Site direction check | The sign checks (SAFETY-02/30). *Opposite for 12 of 60 seconds* counts toward a stop. When it trips, the inverter, or every inverter for the site check, is set to 0 W until you start control. |
|
||||
| Circulating power check, Inverters worked against each other | SAFETY-31, only with several inverters. |
|
||||
| Delivery check, *name* is not delivering | SAFETY-42, only with several inverters. |
|
||||
| Solar production | The PV reading. Sources are numbered in the order of `pv_entities`. The *reversed-sign list* is `pv_invert_entities`. A strongly negative reading by day means that source's sign is reversed. |
|
||||
| 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. |
|
||||
| Stuck-meter check | Whether the add-on can warn you about a stuck meter. |
|
||||
| Meter reading may be stuck | See *Meter reading may be stuck* under Notifications. |
|
||||
| *month*: peak … | This month's peak, and how close it is to your peak limit. |
|
||||
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. |
|
||||
| Battery level, Battery power, Grid power | The reading of that entity. *No usable reading* means the entity is missing, or its reading is too old. Check it in Developer Tools before replacing it. |
|
||||
| Inverter control | Whether the inverter can be controlled, and the power range it accepts. |
|
||||
| *name*: … cannot be controlled, commands refused, “Setpoint entity” is not set | Whether each inverter still takes commands. |
|
||||
| Power request too high, Battery is not keeping up | See those titles under Notifications. Each shows how often it happened since the add-on started, and *fine now* once it has cleared. In shadow mode the row says what *would* have been sent. |
|
||||
| Direction check, Site direction check | Whether each inverter's power goes the way it is told. *Opposite for 12 of 60 seconds* counts toward a stop. When it stops, the inverter, or every inverter, is set to 0 W until you start control. |
|
||||
| Circulating power check, Inverters worked against each other | Only with several inverters. See *Inverters working against each other*. |
|
||||
| Delivery check, *name* is not delivering | Only with several inverters. See *An inverter is not delivering*. |
|
||||
| Solar production | Your solar production. Sources are numbered in the order of “Solar production entities, per inverter”. A strongly negative reading by day means that entity reads the wrong way round: add it to “Reversed-sign solar entities”. |
|
||||
|
||||
### For installers: status page
|
||||
|
||||
The texts are in `app/messages.py`. The full figures of any alarm are on its
|
||||
ERROR or WARNING line in the add-on log.
|
||||
|
||||
- **Stuck-meter check: off** needs `meter_source: homewizard_local` (label
|
||||
“Where the meter reading comes from”), a `max_w` (“Inverter rating”) and a
|
||||
power range the inverter can reach. *The inverter's own limit* means the
|
||||
setpoint device's range sets the threshold, below `max_w`.
|
||||
- **Peak guard: off** means `meter_source` is `off`, so there is no 15-minute
|
||||
average to guard.
|
||||
- **Battery level / Battery power / Grid power** are `soc_entity`,
|
||||
`batt_entity` and `meter_entity`. *No usable reading* is SAFETY-56: missing
|
||||
**or** held back as stale, and a separate stale-reading row then says which.
|
||||
- **Inverter control** is the `setpoint_entity` and the range it accepts.
|
||||
- ***name* cannot be controlled** (red): every write was rejected for
|
||||
`LINK_STALE_S`, so the unit is dropped and the others carry its share
|
||||
(DEV-02). ***name*: commands refused** (amber) is the same before that limit.
|
||||
***name*: “Setpoint entity” is not set** (amber): a roster entry nobody can
|
||||
write, so the site refuses to run at part strength and holds 0 W.
|
||||
- **Direction check, Site direction check** are the sign checks (SAFETY-02/30).
|
||||
**Circulating power check** is SAFETY-31, **Delivery check** SAFETY-42.
|
||||
- **Solar production** reads `pv_entity` or `pv_entities` (TEL-11/12). The
|
||||
*Reversed-sign solar entities* setting is `pv_invert_entities` (TEL-14).
|
||||
- **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.
|
||||
|
||||
## Status entities
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@ from datetime import datetime
|
||||
|
||||
from .clamp import ClampResult
|
||||
from .delivery import DeliveryConfig, DeliveryWatchdog
|
||||
from . import messages
|
||||
# SAFETY-37. The freshness policy, and the ONE place its bounds are written.
|
||||
# freshness.py imports nothing from this package, so the direction is one-way.
|
||||
from .freshness import BOUNDS, Freshness
|
||||
@@ -410,18 +411,20 @@ class Inverter:
|
||||
return self.batt_fresh.note(self.name)
|
||||
|
||||
def link_note(self, now: datetime) -> str:
|
||||
"""One line for the status page. Empty when there is nothing to say."""
|
||||
"""One line for the status page. Empty when there is nothing to say.
|
||||
|
||||
The words are `app/messages.py`'s (UX-09). main.py colours the row red
|
||||
exactly when `is_live()` is False.
|
||||
"""
|
||||
if not self.has_writer():
|
||||
return (f"{self.name}: NO SETPOINT ENTITY - this controller cannot "
|
||||
f"command it, and the site refuses rather than running at "
|
||||
f"part strength")
|
||||
live, why = self.is_live(now)
|
||||
return messages.row_no_setpoint(self.name)
|
||||
live, _why = self.is_live(now)
|
||||
held = (0.0 if self.rejected_since is None
|
||||
else (now - self.rejected_since).total_seconds())
|
||||
if not live:
|
||||
return f"{self.name}: NOT COMMANDABLE - {why}"
|
||||
return messages.row_not_controllable(self.name, held, LINK_STALE_S)
|
||||
if self.rejected_since is not None:
|
||||
held = (now - self.rejected_since).total_seconds()
|
||||
return (f"{self.name}: writes rejected for {held:.0f} s - still "
|
||||
f"commandable until {LINK_STALE_S:.0f} s")
|
||||
return messages.row_commands_refused(self.name, held, LINK_STALE_S)
|
||||
return ""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
|
||||
@@ -3796,7 +3796,8 @@ class Controller:
|
||||
# UX-08: the words are messages.output_clamp()'s; the numbers are the
|
||||
# ones `reason` was written from (ClampWatchdog.facts), asked first.
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.output_clamp(self.clamp_wd.facts).data())
|
||||
messages.output_clamp(self.clamp_wd.facts,
|
||||
shadow=self.shadow).data())
|
||||
|
||||
async def check_peak_saturated(self) -> None:
|
||||
"""SAFETY-07. Alarm ONLY when the guard has run out of inverter.
|
||||
@@ -3839,7 +3840,8 @@ class Controller:
|
||||
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
|
||||
# is not a frozen meter and is not an out-of-range setpoint.
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.peak_saturated(g.ceiling_w).data())
|
||||
messages.peak_saturated(
|
||||
g.ceiling_w, shadow=self.shadow).data())
|
||||
|
||||
async def check_peak_floor_limited(self) -> None:
|
||||
"""SAFETY-24. Alarm when the EMS SoC floor stops the guard shaving.
|
||||
@@ -4797,9 +4799,14 @@ class Controller:
|
||||
# `live_units()` has stopped listing has had its share handed to its
|
||||
# siblings, or - at a roster of one - has taken the site to 0 W. A
|
||||
# dropped link that shows nothing on the page reads as a healthy site.
|
||||
for note in self.liveness_notes():
|
||||
out.append({"ok": False, "warn": "NOT COMMANDABLE" not in note,
|
||||
"text": note})
|
||||
# Red only for a unit that is no longer live; the rest are amber.
|
||||
# The same notes, in the same order, as `liveness_notes()`.
|
||||
_now = datetime.now(timezone.utc)
|
||||
for inv in self.inverters:
|
||||
for note, red in ((inv.link_note(_now), not inv.is_live(_now)[0]),
|
||||
(inv.stale_note(), False)):
|
||||
if note:
|
||||
out.append({"ok": False, "warn": not red, "text": note})
|
||||
|
||||
# SAFETY-03. Red while an over-rating clamp is engaged (amber for the
|
||||
# integrator bound, SAFETY-40), and the engagement COUNT is shown even
|
||||
@@ -4814,8 +4821,8 @@ class Controller:
|
||||
if _cw.active and _cw.bug:
|
||||
out.append({"ok": False, "warn": False, "id": "power_too_high",
|
||||
"banner": messages.banner_power_too_high(),
|
||||
"text": messages.row_power_too_high(_cw.facts,
|
||||
_cw.over_rating)})
|
||||
"text": messages.row_power_too_high(
|
||||
_cw.facts, _cw.over_rating, shadow=self.shadow)})
|
||||
elif _cw.active:
|
||||
# SAFETY-40: the integrator bound alone is amber, not red.
|
||||
out.append({"ok": False, "warn": True, "id": "not_keeping_up",
|
||||
@@ -5363,6 +5370,30 @@ def control_task_done(task: asyncio.Task, *, controller, stop: asyncio.Event,
|
||||
stop.set()
|
||||
|
||||
|
||||
async def docs_link_facts(session, token: str) -> dict:
|
||||
"""`{slug, ha_version}` from the Supervisor, each None when unreadable.
|
||||
|
||||
`GET /addons/self/info` and `GET /info` are on the Supervisor's bypass
|
||||
list for an add-on's own token, so they need no role (see selfconfig.py).
|
||||
"""
|
||||
out = {"slug": None, "ha_version": None}
|
||||
for key, url, field in (("slug", "http://supervisor/addons/self/info",
|
||||
"slug"),
|
||||
("ha_version", "http://supervisor/info",
|
||||
"homeassistant")):
|
||||
try:
|
||||
async with session.get(url, timeout=10, headers={
|
||||
"Authorization": f"Bearer {token}"}) as r:
|
||||
body = await r.json(content_type=None)
|
||||
data = body.get("data", body) if isinstance(body, dict) else {}
|
||||
out[key] = data.get(field) if isinstance(data, dict) else None
|
||||
except Exception as err: # noqa: BLE001 - a link, never a stop
|
||||
_LOG.info("docs link: %s unreadable (%s)", url, err)
|
||||
_LOG.info("docs link: slug %s, Home Assistant %s", out["slug"],
|
||||
out["ha_version"])
|
||||
return out
|
||||
|
||||
|
||||
async def amain() -> None:
|
||||
opts = load_options()
|
||||
logging.basicConfig(
|
||||
@@ -5394,6 +5425,12 @@ async def amain() -> None:
|
||||
except Exception as _e: # noqa: BLE001
|
||||
_LOG.error("core api probe failed: %s", _e)
|
||||
|
||||
# UX-09: the Details link in every notification needs this add-on's
|
||||
# slug and Home Assistant's version (see messages.docs_url()). Both
|
||||
# are best effort: without them the link falls back to the hostname,
|
||||
# or to naming the section only.
|
||||
messages.set_site(**await docs_link_facts(session, _tok))
|
||||
|
||||
# ⚠️ THE STARTUP GATE (CFG-01). Nothing below this line may run on a
|
||||
# configuration validate.py rejects - specs §1.5: a config the system
|
||||
# cannot honour is refused at startup, not survived at runtime.
|
||||
|
||||
@@ -37,25 +37,60 @@ def names(items) -> str:
|
||||
return ", ".join(items[:-1]) + " and " + items[-1]
|
||||
|
||||
|
||||
def addon_slug() -> str | None:
|
||||
"""This add-on's Supervisor slug, from the container hostname.
|
||||
# The Details link (UX-09). Home Assistant 2026.2 added the built-in `app`
|
||||
# panel (core #157554, frontend #28245), whose add-on pages are
|
||||
# /config/app/<slug>/<tab>. 2026.5 dropped the old `hassio` panel, so
|
||||
# /hassio/addon/<slug>/<tab> is a 404 there; 2026.1 and older have only that
|
||||
# one. No path reaches the Documentation tab on both, so the link follows the
|
||||
# Home Assistant version, and an unknown version gets the current path.
|
||||
DOCS_PATH = "/config/app/{slug}/documentation"
|
||||
DOCS_PATH_OLD = "/hassio/addon/{slug}/documentation"
|
||||
APP_PANEL_SINCE = (2026, 2)
|
||||
_SLUG = re.compile(r"[a-z0-9]+_goodwe_controller")
|
||||
_site = {"slug": None, "ha": None}
|
||||
|
||||
The Supervisor names an add-on's host after its slug with `_` spelled `-`
|
||||
(`local_goodwe_controller` -> `local-goodwe-controller`). Anything else,
|
||||
e.g. a test run, gets no link rather than a wrong one.
|
||||
|
||||
def set_site(slug=None, ha_version=None) -> None:
|
||||
"""What main.py read from the Supervisor at start: our slug, HA's version."""
|
||||
_site["slug"] = slug if isinstance(slug, str) and _SLUG.fullmatch(slug) \
|
||||
else None
|
||||
_site["ha"] = ha_version if isinstance(ha_version, str) else None
|
||||
|
||||
|
||||
def ha_release(version) -> tuple | None:
|
||||
"""`"2026.9.1"` -> `(2026, 9)`; None when it cannot be read."""
|
||||
m = re.match(r"(\d{4})\.(\d{1,2})\b", str(version or ""))
|
||||
return (int(m.group(1)), int(m.group(2))) if m else None
|
||||
|
||||
|
||||
def addon_slug() -> str | None:
|
||||
"""This add-on's Supervisor slug, or None outside an add-on.
|
||||
|
||||
The Supervisor's own answer (`GET /addons/self/info`) first. Failing that,
|
||||
the container hostname, which the Supervisor names after the slug with `_`
|
||||
spelled `-` (`local-goodwe-controller`). Anything else gets no link rather
|
||||
than a wrong one.
|
||||
"""
|
||||
if _site["slug"]:
|
||||
return _site["slug"]
|
||||
host = os.environ.get("HOSTNAME", "")
|
||||
if re.fullmatch(r"[a-z0-9]+-goodwe-controller", host):
|
||||
return host.replace("-", "_")
|
||||
return None
|
||||
|
||||
|
||||
def docs_url(slug: str, ha_version=None) -> str:
|
||||
release = ha_release(ha_version)
|
||||
old = release is not None and release < APP_PANEL_SINCE
|
||||
return (DOCS_PATH_OLD if old else DOCS_PATH).format(slug=slug)
|
||||
|
||||
|
||||
def details(title: str) -> str:
|
||||
where = f"section “{title}” under {DOCS_SECTION}"
|
||||
slug = addon_slug()
|
||||
if slug is None:
|
||||
return f"Details: add-on documentation, {where}."
|
||||
return (f"Details: [add-on documentation](/hassio/addon/{slug}/documentation),"
|
||||
return (f"Details: [add-on documentation]({docs_url(slug, _site['ha'])}),"
|
||||
f" {where}.")
|
||||
|
||||
|
||||
@@ -98,19 +133,22 @@ ID_NOT_DELIVERING = "goodwe_not_delivering_{slug}"
|
||||
ID_METER_FROZEN = "goodwe_meter_frozen"
|
||||
|
||||
# Which limit cut a command (clamp.ClampResult.bound, plus the loop's own).
|
||||
# The setting is named by its label, "Inverter rating (W)".
|
||||
RATING = "the “Inverter rating” setting"
|
||||
LIMIT_WORDS = {
|
||||
"max_w": "the configured inverter rating",
|
||||
"max_w": RATING,
|
||||
"device": "the inverter's own limit",
|
||||
"max_w+device": "the configured inverter rating and the inverter's own limit",
|
||||
"rating": "the inverter rating",
|
||||
"max_w+device": f"{RATING} and the inverter's own limit",
|
||||
"rating": RATING,
|
||||
}
|
||||
|
||||
|
||||
def loop_bound() -> Notice:
|
||||
"""SAFETY-40: the integrator bound. Normal in short bursts; not a fault."""
|
||||
return Notice(ID_LOOP_BOUND, WARNING, T_LOOP_BOUND,
|
||||
"The battery cannot give what the controller asks, which is "
|
||||
"normal for a moment but needs a check if it keeps happening.")
|
||||
"The battery does less than asked, which is normal briefly, "
|
||||
"but if this keeps happening, check that it is on and not "
|
||||
"empty or full.")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -122,77 +160,98 @@ class Clamp:
|
||||
limit: str # a key of LIMIT_WORDS
|
||||
|
||||
|
||||
def output_clamp(clamps) -> Notice:
|
||||
def output_clamp(clamps, *, shadow: bool = False) -> Notice:
|
||||
"""SAFETY-03: a computed command above the limit. A software fault.
|
||||
|
||||
Asked comes before sent, always - reading them the other way round turns
|
||||
the alarm backwards (test_clamp.py part 6).
|
||||
the alarm backwards (test_clamp.py part 6). In shadow mode the cut command
|
||||
never reached the inverter, so the text says "would have" (UX-09).
|
||||
"""
|
||||
fault = "This is a software fault, so please report it to your installer."
|
||||
if not clamps:
|
||||
cut = ("would have been cut back" if shadow else "was cut back")
|
||||
state = ("Shadow mode is on, so every inverter stays at 0 W." if shadow
|
||||
else "Control is still running.")
|
||||
return Notice(ID_OUTPUT_CLAMP, ALARM, T_OUTPUT_CLAMP,
|
||||
"The controller asked for more power than the inverter "
|
||||
"rating and was cut back. Control is still running. This "
|
||||
"is a software fault, so please report it to your "
|
||||
"installer.")
|
||||
f"The add-on asked for more power than {RATING} allows "
|
||||
f"and {cut}. {state} {fault}")
|
||||
c = clamps[0]
|
||||
who = f"For {c.unit}, the" if c.unit else "The"
|
||||
others = [x.unit for x in clamps[1:] if x.unit]
|
||||
running = (f"{names(others)} {'was' if len(others) == 1 else 'were'} cut "
|
||||
f"too, and control is still running." if others
|
||||
else "Control is still running.")
|
||||
sent = "would have sent" if shadow else "sent"
|
||||
if others:
|
||||
verb = "was" if len(others) == 1 else "were"
|
||||
state = (f"{names(others)} would have been cut too, and shadow mode "
|
||||
f"keeps every inverter at 0 W." if shadow else
|
||||
f"{names(others)} {verb} cut too, and control is still "
|
||||
f"running.")
|
||||
else:
|
||||
state = ("Shadow mode is on, so the inverter stays at 0 W." if shadow
|
||||
else "Control is still running.")
|
||||
return Notice(ID_OUTPUT_CLAMP, ALARM, T_OUTPUT_CLAMP,
|
||||
f"{who} controller asked for {watts(c.asked_w)}, above "
|
||||
f"{LIMIT_WORDS.get(c.limit, LIMIT_WORDS['rating'])}, and sent "
|
||||
f"{watts(c.sent_w)} instead. {running} "
|
||||
f"This is a software fault, so please report it to your "
|
||||
f"installer.")
|
||||
f"{who} add-on asked for {watts(c.asked_w)}, above "
|
||||
f"{LIMIT_WORDS.get(c.limit, RATING)}, and {sent} "
|
||||
f"{watts(c.sent_w)} instead. {state} {fault}")
|
||||
|
||||
|
||||
def peak_saturated(ceiling_w) -> Notice:
|
||||
"""SAFETY-07: the battery gives all it can and the peak still goes up."""
|
||||
def peak_saturated(ceiling_w, *, shadow: bool = False) -> Notice:
|
||||
"""SAFETY-07: the battery gives all it can and the peak still goes up.
|
||||
|
||||
In shadow mode the battery gives nothing, so the text says what the add-on
|
||||
would ask and that the peak may rise (UX-09).
|
||||
"""
|
||||
act = "Switch off large loads in the next few minutes."
|
||||
if shadow:
|
||||
return Notice(ID_PEAK_SATURATED, ALARM, T_PEAK_SATURATED,
|
||||
f"The add-on would ask the battery for all it can give, "
|
||||
f"and this 15-minute period is heading over your "
|
||||
f"{watts(ceiling_w)} peak limit. Shadow mode is on, so "
|
||||
f"the battery gives nothing and this month's peak may "
|
||||
f"rise. {act}")
|
||||
return Notice(ID_PEAK_SATURATED, ALARM, T_PEAK_SATURATED,
|
||||
f"The battery is giving all it can, but this 15-minute period "
|
||||
f"will still go over your {watts(ceiling_w)} peak limit and "
|
||||
f"raise this month's peak. Switch off large loads in the next "
|
||||
f"few minutes. This is not a fault: the load is bigger than "
|
||||
f"the inverter.")
|
||||
f"raise this month's peak. {act} This is not a fault: the "
|
||||
f"load is more than the battery can supply.")
|
||||
|
||||
|
||||
def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
|
||||
packs=()) -> Notice:
|
||||
"""SAFETY-24/25/32: the peak guard may not discharge any further.
|
||||
"""SAFETY-24/25/32: the peak guard may not draw the battery any lower.
|
||||
|
||||
The last sentence is SAFETY-25 and must stay: the reserve binds the peak
|
||||
guard only, and normal home use goes on discharging below it.
|
||||
guard only, and normal home use goes on drawing the battery below it.
|
||||
`packs` names the batteries at their limit, only on a site with several.
|
||||
`floor_unknown`: the floor the guard grades against is None, which at a
|
||||
roster means some inverter's cut-out level is missing (the peak reserve
|
||||
itself always has a value) - so the action names the cut-out level.
|
||||
Both are named by their setting labels, "Peak reserve (%)" and "Battery
|
||||
cut-out level (%)".
|
||||
"""
|
||||
if soc_unread:
|
||||
why = "The battery level cannot be read"
|
||||
act = "Switch off large loads, and check the battery level sensor."
|
||||
act = ("Switch off large loads, and check the “Battery level entity” "
|
||||
"setting.")
|
||||
elif floor_unknown:
|
||||
why = "The cut-out level of a battery is not known"
|
||||
act = ("Switch off large loads, and set each battery's cut-out level "
|
||||
"in the settings.")
|
||||
why = "A battery cut-out level is not known"
|
||||
act = ("Switch off large loads, and set “Battery cut-out level” for "
|
||||
"each inverter.")
|
||||
else:
|
||||
why = (f"The battery of {names(packs)} is at its peak reserve" if packs
|
||||
else "The battery is at its peak reserve")
|
||||
act = ("Switch off large loads, or lower the peak reserve if the "
|
||||
"battery allows it.")
|
||||
act = "Switch off large loads, or lower the peak reserve."
|
||||
return Notice(ID_PEAK_RESERVE, ALARM, T_PEAK_RESERVE,
|
||||
f"{why}, so this 15-minute period will go over your "
|
||||
f"{watts(ceiling_w)} peak limit and raise this month's peak. "
|
||||
f"{act} The reserve only applies to keeping the peak down: normal home use "
|
||||
f"can still discharge the battery below it, down to its "
|
||||
f"cut-out level.")
|
||||
f"{act} The reserve only applies to keeping the peak down: "
|
||||
f"normal home use can still draw the battery below it, down "
|
||||
f"to the battery cut-out level.")
|
||||
|
||||
|
||||
def circulating(discharging=(), charging=()) -> Notice:
|
||||
"""SAFETY-31: the inverters push power round each other. Control stopped."""
|
||||
if discharging and charging:
|
||||
what = (f"{names(discharging)} was discharging while "
|
||||
what = (f"{names(discharging)} was giving power while "
|
||||
f"{names(charging)} was charging, and your meter cannot see "
|
||||
f"this.")
|
||||
else:
|
||||
@@ -222,7 +281,7 @@ def meter_frozen(unchanged_s) -> Notice:
|
||||
"""SAFETY-18: the meter answers but its value does not move."""
|
||||
return Notice(ID_METER_FROZEN, ALARM, T_METER_FROZEN,
|
||||
f"The meter has shown the same value for "
|
||||
f"{duration(unchanged_s)} while the controller asks the "
|
||||
f"{duration(unchanged_s)} while the add-on asks the "
|
||||
f"battery for full power. Control is still running and still "
|
||||
f"follows this reading. Check the meter, and if it is stuck, "
|
||||
f"stop control on the add-on page.")
|
||||
@@ -291,17 +350,31 @@ def banner_not_delivering(units) -> str:
|
||||
return f"{names(units)} {verb} not delivering. Control keeps running."
|
||||
|
||||
|
||||
# Rows: the controller and the meter.
|
||||
def banner_shadow(latched: bool) -> str:
|
||||
"""SAFETY-10: first in the cascade, never displaced by another banner.
|
||||
|
||||
With a safety stop standing it goes red and says the stop outlasts shadow
|
||||
mode. Each stop has its own red row below, which names it and says how it
|
||||
clears.
|
||||
"""
|
||||
if latched:
|
||||
return ("Shadow mode, battery not controlled. A safety stop stays on, "
|
||||
"see below.")
|
||||
return "Shadow mode: the battery is not controlled. Inverters stay at 0 W."
|
||||
|
||||
|
||||
# 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:
|
||||
return "Controller: starting"
|
||||
return "Control: starting"
|
||||
|
||||
|
||||
def row_controller_ran(age_s) -> str:
|
||||
return f"Controller: last ran {seconds(age_s)} ago"
|
||||
return f"Control: last ran {seconds(age_s)} ago"
|
||||
|
||||
|
||||
def row_controller_stalled(age_s, veto_s) -> str:
|
||||
return (f"Controller has not run for {seconds(age_s)}. The control board "
|
||||
return (f"Control has not run for {seconds(age_s)}. The control board "
|
||||
f"sets the inverter to 0 W after {seconds(veto_s)}.")
|
||||
|
||||
|
||||
@@ -322,9 +395,12 @@ def row_meter_frozen() -> str:
|
||||
f"and stop control if it is stuck.")
|
||||
|
||||
|
||||
# Settings are named by their labels in translations/en.yaml.
|
||||
METER_SOURCE = "“Where the meter reading comes from”"
|
||||
|
||||
FREEZE_OFF = {
|
||||
"source": "it only works with the HomeWizard local meter source",
|
||||
"rating": "the inverter rating is not set",
|
||||
"source": f"it needs {METER_SOURCE} set to HomeWizard local",
|
||||
"rating": "“Inverter rating” is not set",
|
||||
"range": "the inverter reports no usable power range",
|
||||
}
|
||||
|
||||
@@ -357,13 +433,12 @@ def row_peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
|
||||
why = (f"{names(packs)} battery at its peak reserve" if packs
|
||||
else "Battery at its peak reserve")
|
||||
return (f"{why}, so the {watts(ceiling_w)} peak limit will be passed. Home "
|
||||
f"use still discharges below the reserve, down to the cut-out level.")
|
||||
f"use still draws the battery below it, down to the cut-out level.")
|
||||
|
||||
|
||||
def row_peak_guard_off() -> str:
|
||||
"""No meter source: the 15-minute average cannot be measured."""
|
||||
return ("Peak guard: off, it needs a meter source instead of the grid "
|
||||
"power sensor")
|
||||
"""`meter_source` is off: the 15-minute average cannot be measured."""
|
||||
return f"Peak guard: off, set {METER_SOURCE} to anything but off"
|
||||
|
||||
|
||||
def banner_peak_reserve() -> str:
|
||||
@@ -383,24 +458,49 @@ def row_sensor(label, value, unit=None) -> str:
|
||||
|
||||
|
||||
def row_sensor_unset(label, unit=None) -> str:
|
||||
return f"{label}{for_unit(unit)}: no sensor set in the settings"
|
||||
"""The setting is "<label> entity" (e.g. "Grid power entity (W)")."""
|
||||
return f"{label}{for_unit(unit)}: “{label} entity” is not set"
|
||||
|
||||
|
||||
def row_sensor_unusable(label, unit=None) -> str:
|
||||
"""SAFETY-56: it may be missing OR held back as stale; say both."""
|
||||
return (f"{label}{for_unit(unit)}: no usable reading. The sensor may be "
|
||||
return (f"{label}{for_unit(unit)}: no usable reading. The entity may be "
|
||||
f"missing, or its reading is held back as too old. Check it in "
|
||||
f"Developer Tools before replacing it.")
|
||||
|
||||
|
||||
def setpoint_label(unit=None) -> str:
|
||||
"""The label: "Setpoint entity" in the inverter list, else its long form."""
|
||||
return "“Setpoint entity”" if unit else "“Inverter setpoint entity”"
|
||||
|
||||
|
||||
def row_control_unset(unit=None) -> str:
|
||||
return f"Inverter control{for_unit(unit)}: not set in the settings"
|
||||
return f"Inverter control{for_unit(unit)}: {setpoint_label(unit)} is not set"
|
||||
|
||||
|
||||
def row_control_missing(unit=None) -> str:
|
||||
return f"Inverter control{for_unit(unit)}: not found in Home Assistant"
|
||||
|
||||
|
||||
# Rows: whether each inverter can still be controlled (DEV-02). `name` is
|
||||
# always given: these rows came with the inverter list.
|
||||
def row_no_setpoint(name) -> str:
|
||||
"""Amber. The site refuses to run at part strength, so all stay at 0 W."""
|
||||
return f"{name}: “Setpoint entity” is not set, so every inverter stays at 0 W"
|
||||
|
||||
|
||||
def row_not_controllable(name, held_s, limit_s) -> str:
|
||||
"""Red. The inverter is dropped and the others carry its share."""
|
||||
return (f"{name} cannot be controlled. Every command was refused for "
|
||||
f"{seconds(held_s)}, limit {seconds(limit_s)}.")
|
||||
|
||||
|
||||
def row_commands_refused(name, held_s, limit_s) -> str:
|
||||
"""Amber. Refused, but not for long enough to be dropped yet."""
|
||||
return (f"{name}: commands refused for {seconds(held_s)}, left out of "
|
||||
f"control after {seconds(limit_s)}")
|
||||
|
||||
|
||||
def row_control(lo_w, hi_w, unit=None) -> str:
|
||||
def w(v):
|
||||
return "?" if v is None else signed_watts(v)
|
||||
@@ -409,12 +509,15 @@ def row_control(lo_w, hi_w, unit=None) -> str:
|
||||
|
||||
# Rows: the two loop alarms. The names are the notification titles, so the
|
||||
# row and the card always call the same thing by the same name (UX-07 item 1).
|
||||
def row_power_too_high(clamps, count) -> str:
|
||||
def row_power_too_high(clamps, count, *, shadow: bool = False) -> str:
|
||||
"""In shadow mode nothing was sent, so it says "would send" (UX-09)."""
|
||||
c = clamps[0] if clamps else None
|
||||
what = (f"asked {watts(c.asked_w)}{for_unit(c.unit)}, sent "
|
||||
f"{watts(c.sent_w)}. Cut back" if c else "cut back")
|
||||
return (f"{T_OUTPUT_CLAMP}: {what} {times(count)} since start. This is a "
|
||||
f"software fault, so please report it.")
|
||||
head = f"{T_OUTPUT_CLAMP}{' in shadow mode' if shadow else ''}"
|
||||
what = (f"asked {watts(c.asked_w)}{for_unit(c.unit)}, "
|
||||
f"{'would send' if shadow else 'sent'} {watts(c.sent_w)}. Cut back"
|
||||
if c else "cut back")
|
||||
return (f"{head}: {what} {times(count)} since start. Software fault, "
|
||||
f"please report it.")
|
||||
|
||||
|
||||
def banner_power_too_high() -> str:
|
||||
@@ -513,24 +616,29 @@ def row_no_estop() -> str:
|
||||
|
||||
|
||||
# Rows: solar production (TEL-11/12/14). Sources are numbered in the order of
|
||||
# the solar sensor list in the settings.
|
||||
# "Solar production entities, per inverter". The settings are named by their
|
||||
# labels in translations/en.yaml.
|
||||
PV_ONE = "“Solar production entity”"
|
||||
PV_REVERSED = "“Reversed-sign solar entities”"
|
||||
|
||||
|
||||
def source(n, of) -> str:
|
||||
return "the sensor" if of == 1 else f"source {n}"
|
||||
return "the entity" if of == 1 else f"source {n}"
|
||||
|
||||
|
||||
def pv_problem(why: str) -> str:
|
||||
"""`pv.watts()`'s reason, in plain words."""
|
||||
w = str(why)
|
||||
if w.startswith("no state"):
|
||||
return "the sensor is missing"
|
||||
return "the entity is missing"
|
||||
if w.startswith("entity is"):
|
||||
return "the sensor is unavailable"
|
||||
return "the entity is unavailable"
|
||||
if w.endswith("is not numeric"):
|
||||
return "the reading is not a number"
|
||||
if w.startswith("no unit"):
|
||||
return "the sensor has no unit"
|
||||
return "the entity has no unit"
|
||||
if w.startswith("unit "):
|
||||
return "the sensor's unit is not a power unit"
|
||||
return "the entity's unit is not a power unit"
|
||||
return "the reading is not usable"
|
||||
|
||||
|
||||
@@ -553,20 +661,19 @@ def row_pv_partial(missing, count) -> str:
|
||||
|
||||
|
||||
def row_pv_single_ignored() -> str:
|
||||
return ("Solar production: clear the single sensor setting, the list "
|
||||
"replaces it")
|
||||
return f"Solar production: clear {PV_ONE}, the per-inverter list replaces it"
|
||||
|
||||
|
||||
def row_pv_negative(n, count, value_w, flipped: bool) -> str:
|
||||
fix = ("remove it from" if flipped else "add it to")
|
||||
who = source(n, count)
|
||||
return (f"Solar production: {who} reads {signed_watts(value_w)}, "
|
||||
f"so {fix} the reversed-sign list")
|
||||
f"{fix} {PV_REVERSED}")
|
||||
|
||||
|
||||
def row_pv_stray(n) -> str:
|
||||
return (f"Solar production: entry {n} of the reversed-sign list is not a "
|
||||
f"solar sensor in use")
|
||||
return (f"Solar production: entry {n} of {PV_REVERSED} is not a solar "
|
||||
f"entity in use")
|
||||
|
||||
|
||||
RED, AMBER, GREEN = "bad", "warn", "ok"
|
||||
@@ -591,6 +698,8 @@ def status_samples() -> list:
|
||||
("banner_not_delivering", None, banner_not_delivering(["inverter-b"])),
|
||||
("banner_peak_reserve", None, banner_peak_reserve()),
|
||||
("banner_power_too_high", None, banner_power_too_high()),
|
||||
("banner_shadow", None, banner_shadow(False)),
|
||||
("banner_shadow", None, banner_shadow(True)),
|
||||
("row_controller_starting", GREEN, row_controller_starting()),
|
||||
("row_controller_ran", GREEN, row_controller_ran(2)),
|
||||
("row_controller_stalled", RED, row_controller_stalled(45, 30)),
|
||||
@@ -615,10 +724,19 @@ def status_samples() -> list:
|
||||
("row_control_unset", RED, row_control_unset("inverter-b")),
|
||||
("row_control_missing", RED, row_control_missing("inverter-b")),
|
||||
("row_control", GREEN, row_control(-5000, 5000, "inverter-b")),
|
||||
("row_control_unset", RED, row_control_unset()),
|
||||
("row_no_setpoint", AMBER, row_no_setpoint("inverter-b")),
|
||||
("row_not_controllable", RED, row_not_controllable("inverter-b", 45,
|
||||
30)),
|
||||
("row_commands_refused", AMBER, row_commands_refused("inverter-b", 12,
|
||||
30)),
|
||||
("row_power_too_high", RED, row_power_too_high(clamp, 3)),
|
||||
("row_power_too_high", RED, row_power_too_high(
|
||||
[Clamp("inverter-b", 6000, 4600, "max_w+device")], 12)),
|
||||
("row_power_too_high", RED, row_power_too_high((), 1)),
|
||||
("row_power_too_high", RED, row_power_too_high(
|
||||
[Clamp("inverter-b", 6000, 4600, "max_w+device")], 12,
|
||||
shadow=True)),
|
||||
("row_not_keeping_up", AMBER, row_not_keeping_up(48)),
|
||||
("row_before", AMBER, row_before(T_OUTPUT_CLAMP, 2)),
|
||||
("row_before", AMBER, row_before(T_LOOP_BOUND, 1)),
|
||||
@@ -662,7 +780,13 @@ def samples() -> list:
|
||||
output_clamp(()),
|
||||
output_clamp([Clamp("inverter-a", -3000, -2500, "device"),
|
||||
Clamp("inverter-b", 6000, 4600, "max_w+device")]),
|
||||
output_clamp([Clamp(None, 7412, 5000, "rating")], shadow=True),
|
||||
output_clamp((), shadow=True),
|
||||
output_clamp([Clamp("inverter-a", -3000, -2500, "device"),
|
||||
Clamp("inverter-b", 6000, 4600, "max_w+device")],
|
||||
shadow=True),
|
||||
peak_saturated(5000),
|
||||
peak_saturated(5000, shadow=True),
|
||||
peak_reserve(5000, soc_unread=False, floor_unknown=False),
|
||||
peak_reserve(5000, soc_unread=False, floor_unknown=False,
|
||||
packs=["inverter-b"]),
|
||||
|
||||
@@ -279,6 +279,8 @@ that write is issued rather than skipped as unchanged.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import messages
|
||||
|
||||
# ⚠️ CFG-03's mirror. `test_defaults_pin.py` part 2 discovers every
|
||||
# module-level name ending in OPTION_DEFAULTS and asserts each value
|
||||
# reproduces config.yaml EXACTLY, so this table cannot drift from the shipped
|
||||
@@ -471,25 +473,17 @@ def log_line(*, would_w: float, reason: str, sized_from_meter: bool) -> str:
|
||||
def banner(opts: dict, *, control_started: bool, latched: tuple = ()) -> tuple:
|
||||
"""`(level, text)` for the top of the status page, or `None` when live.
|
||||
|
||||
⚠️ IT NAMES A STANDING LATCH AND GOES RED FOR ONE. The shadow banner sits
|
||||
ABOVE every other branch of the cascade, because "the battery is not being
|
||||
controlled" is the one sentence that may never be displaced - but a
|
||||
failsafe latch that is up while the controller is inert is up when it
|
||||
stops being inert, and an operator who turns shadow mode off without
|
||||
clearing it gets a battery that will not move and a green page that
|
||||
never mentioned why. So the latch is carried INTO the shadow banner
|
||||
rather than losing its own.
|
||||
⚠️ IT GOES RED FOR A STANDING LATCH. The shadow banner sits ABOVE every
|
||||
other branch of the cascade, because "the battery is not controlled" is
|
||||
the one sentence that may never be displaced - but a failsafe latch that
|
||||
is up while the controller is inert is up when it stops being inert. So the
|
||||
banner says a safety stop outlasts shadow mode, and each latch keeps its
|
||||
own red row below, which names it and says how it clears. The words are
|
||||
`messages.banner_shadow()`'s (UX-09); `latched` names them for callers.
|
||||
"""
|
||||
if not engaged(opts, control_started=control_started):
|
||||
return None
|
||||
text = ("SHADOW MODE - the battery is NOT being controlled. Every "
|
||||
f"inverter is held at {WIRE_W:.0f} W and nothing this add-on "
|
||||
"decides reaches the inverter.")
|
||||
if latched:
|
||||
return ("bad", text + " ⚠️ A FAILSAFE LATCH IS STILL UP ("
|
||||
+ ", ".join(latched) + ") and it will still be up when shadow "
|
||||
"mode is turned off.")
|
||||
return ("warn", text)
|
||||
return ("bad" if latched else "warn", messages.banner_shadow(bool(latched)))
|
||||
|
||||
|
||||
def problems(opts: dict) -> list:
|
||||
|
||||
@@ -85,7 +85,8 @@ from named_absence import given, nth, present
|
||||
|
||||
from app import messages
|
||||
from app.clamp import (
|
||||
CLEAR_CYCLES, REASON_CODE, REASSERT_S, SUSTAINED_CYCLES, ClampWatchdog,
|
||||
CLEAR_CYCLES, REASON_CODE, REASSERT_S, SUSTAINED_CYCLES, ClampResult,
|
||||
ClampWatchdog,
|
||||
clamp_to_rating,
|
||||
is_commandable,
|
||||
)
|
||||
@@ -1751,6 +1752,35 @@ check("SAFETY-28's gate still OBSERVES too - it refused nothing on this run, "
|
||||
"implemented by making every write unauthorised",
|
||||
not [e for e in _cl_c.events if "WRITE REFUSED" in e])
|
||||
|
||||
# UX-09: what the over-rating alarm and its row SAY in each mode. The loop
|
||||
# bounds the command before the write clamp here, so an over-rating is injected
|
||||
# as the write path records one, and check_output_clamp() is run on it. Nothing
|
||||
# reaches the wire in shadow mode, so neither text may claim a command was sent.
|
||||
def _said(mode):
|
||||
h, c = _s10(mode, cycles=2)
|
||||
c.inverters[0].clamp_last = ClampResult(7412.0, 5000.0, 5000.0, True,
|
||||
"max_w")
|
||||
asyncio.run(c.check_output_clamp())
|
||||
notes = [d["message"] for d in clamp_notes(h)
|
||||
if d["notification_id"] == CLAMP_ID]
|
||||
rows = [r["text"] for r in c.checks() if r.get("id") == "power_too_high"]
|
||||
return notes, rows
|
||||
|
||||
|
||||
_ux_live, _ux_shad = _said("off"), _said("on")
|
||||
print(f" over-rating, live: {_ux_live}\n over-rating, shadow: {_ux_shad}")
|
||||
check("UX-09: live, the over-rating alarm and row say what was SENT",
|
||||
_ux_live[0] and all(" and sent 5 000 W" in m for m in _ux_live[0])
|
||||
and _ux_live[1] and all("sent 5 000 W" in t and "would" not in t
|
||||
for t in _ux_live[1]))
|
||||
check("...in shadow mode they say what WOULD have been sent and that the "
|
||||
"inverter stays at 0 W, never that it was sent",
|
||||
_ux_shad[0] and all("would have sent 5 000 W" in m
|
||||
and " and sent " not in m and "stays at 0 W" in m
|
||||
for m in _ux_shad[0])
|
||||
and _ux_shad[1] and all("would send 5 000 W" in t and " sent " not in t
|
||||
for t in _ux_shad[1]))
|
||||
|
||||
# ⚠️ AND SAFETY-18 IS GRADED ON WHAT IT WAS HANDED, WHICH IS AN ORDERING
|
||||
# CLAIM. `check_meter_frozen()` takes `self.target` - the arbiter's RESOLVED
|
||||
# INTENT - and `_shadow_settle()` re-seeds that to 0 W at the very end of the
|
||||
@@ -1835,15 +1865,15 @@ check("the status page has a Shadow mode row in BOTH states, so its ABSENCE is "
|
||||
_ROW in _shad_rows and _ROW in _live_rows)
|
||||
check("AC 3: the banner states plainly that the battery is not being "
|
||||
"controlled",
|
||||
"SHADOW MODE" in _shad_pay["banner"]
|
||||
and "NOT being controlled" in _shad_pay["banner"])
|
||||
"Shadow mode" in _shad_pay["banner"]
|
||||
and "not controlled" in _shad_pay["banner"])
|
||||
check("...and it is not green. A page that reads `ok` while nothing is being "
|
||||
"commanded is the 'looks healthy but is inert' failure itself",
|
||||
_shad_pay["level"] == "warn")
|
||||
check("...and it is FIRST in the cascade: the same controller has other "
|
||||
"conditions to report and none of them displaces this sentence",
|
||||
_live_pay["banner"] != _shad_pay["banner"]
|
||||
and "SHADOW MODE" not in _live_pay["banner"])
|
||||
and "Shadow mode" not in _live_pay["banner"])
|
||||
_slot = re.search(r"WOULD HAVE COMMANDED ([-+][\d ]+) W, inverter held at "
|
||||
r"([-\d]+) W", _shad_rows[_ROW])
|
||||
check("the row answers 'what would it have done' with a NUMBER in its own "
|
||||
@@ -1869,22 +1899,26 @@ check("with shadow mode off the row says so, and says what follows from it - "
|
||||
and "DOES reach the inverter" in _live_rows[_ROW]
|
||||
and "WOULD HAVE COMMANDED" not in _live_rows[_ROW])
|
||||
|
||||
# ⚠️ A STANDING LATCH IS NAMED RATHER THAN HIDDEN. The shadow banner sits above
|
||||
# the three `bad` branches, so it has to carry what they would have said - a
|
||||
# ⚠️ A STANDING LATCH IS SHOWN RATHER THAN HIDDEN. The shadow banner sits above
|
||||
# the three `bad` branches, so it has to say what they would have said - a
|
||||
# latch that is up while the controller is inert is up the moment it stops
|
||||
# being inert.
|
||||
# being inert. UX-09: the banner (12 words) says a safety stop outlasts shadow
|
||||
# mode, and the latch's own red row, still on the list, names it.
|
||||
_shad_c.inverters[0].sign_wd.tripped = True
|
||||
_latched = _shad_c.status_payload()
|
||||
print(f" banner with a latch up: {_latched['banner']!r}")
|
||||
check("a failsafe latch that is standing is NAMED in the shadow banner and "
|
||||
"turns it red, so nobody turns shadow mode off into a battery that will "
|
||||
"not move and a page that never mentioned why",
|
||||
check("a failsafe latch that is standing turns the shadow banner red and the "
|
||||
"banner says a safety stop stays on, so nobody turns shadow mode off "
|
||||
"into a battery that will not move and a page that never mentioned why",
|
||||
_latched["level"] == "bad"
|
||||
and "LATCH IS STILL UP" in _latched["banner"]
|
||||
and _shad_c.inverters[0].name in _latched["banner"])
|
||||
and "safety stop stays on" in _latched["banner"])
|
||||
check("...and the latch is NAMED on its own red row, which says how it clears",
|
||||
any(not r["ok"] and not r["warn"] and r.get("id") == "direction"
|
||||
and "A restart does not clear this" in r["text"]
|
||||
for r in _shad_c.checks()))
|
||||
check("...and it still says the battery is not being controlled, so the latch "
|
||||
"has not displaced the sentence that may never be displaced",
|
||||
"NOT being controlled" in _latched["banner"])
|
||||
"not controlled" in _latched["banner"])
|
||||
_shad_c.inverters[0].sign_wd.tripped = False
|
||||
|
||||
# --- 8i. THE RECORD A LATER ANALYSIS READS ----------------------------------
|
||||
|
||||
@@ -1356,7 +1356,7 @@ check("⚠️ AND A UNIT WITH NO `setpoint_entity` IS DELIBERATELY STILL LIVE HE
|
||||
inverter_b(setpoint="").is_live(_now)[0] is True)
|
||||
check("...and the status page says so in its own row, which is the surface an "
|
||||
"operator actually reads",
|
||||
any("NO SETPOINT ENTITY" in n for n in
|
||||
any("“Setpoint entity” is not set" in n for n in
|
||||
built(store=MemStore(), second=inverter_b(setpoint=""))[0]
|
||||
.liveness_notes()))
|
||||
check("...and a healthy site adds NO liveness rows at all, so the page is not "
|
||||
|
||||
@@ -150,15 +150,129 @@ check(f"the samples cover every notification id in the catalogue "
|
||||
check("the payload carries exactly the three keys Home Assistant accepts",
|
||||
all(set(n.data()) == {"notification_id", "title", "message"}
|
||||
for n in SAMPLES))
|
||||
# The Details link (UX-09). Home Assistant 2026.2+ serves add-on pages at
|
||||
# /config/app/<slug>/<tab> and 2026.5 dropped /hassio/addon/<slug>/<tab>, so
|
||||
# the path follows the version the Supervisor reports; unknown means current.
|
||||
_LINE = ("Details: [add-on documentation](/config/app/local_goodwe_controller/"
|
||||
"documentation), section “Battery is not keeping up” under "
|
||||
"Notifications.")
|
||||
os.environ["HOSTNAME"] = "3f2a9c1b7d4e" # a container id, as on TEST-23
|
||||
messages.set_site("local_goodwe_controller", "2026.9.1")
|
||||
check("with the Supervisor's slug the Details line is exactly the current-HA "
|
||||
"link plus the section by name, even when the hostname is a container id",
|
||||
SAMPLES[0].data()["message"].endswith("\n\n" + _LINE))
|
||||
_link = {v: messages.docs_url("local_goodwe_controller", v)
|
||||
for v in ("2026.1.3", "2026.2.0", "2026.4.2", "2026.10.0b1",
|
||||
"2025.12.5", None, "rubbish")}
|
||||
check(f"Home Assistant 2026.1 and older get the old /hassio path, 2026.2 and "
|
||||
f"newer and an unknown version the /config/app path ({_link})",
|
||||
_link == {
|
||||
"2026.1.3": "/hassio/addon/local_goodwe_controller/documentation",
|
||||
"2025.12.5": "/hassio/addon/local_goodwe_controller/documentation",
|
||||
"2026.2.0": "/config/app/local_goodwe_controller/documentation",
|
||||
"2026.4.2": "/config/app/local_goodwe_controller/documentation",
|
||||
"2026.10.0b1": "/config/app/local_goodwe_controller/documentation",
|
||||
None: "/config/app/local_goodwe_controller/documentation",
|
||||
"rubbish": "/config/app/local_goodwe_controller/documentation"})
|
||||
messages.set_site("../../etc", "2026.9.1")
|
||||
check("a slug that is not this add-on's shape is not put in a link",
|
||||
"](" not in SAMPLES[0].data()["message"])
|
||||
messages.set_site(None, None)
|
||||
os.environ["HOSTNAME"] = "abcd1234-goodwe-controller"
|
||||
check("inside the add-on the Details line links to its documentation page",
|
||||
"(/hassio/addon/abcd1234_goodwe_controller/documentation)"
|
||||
check("without the Supervisor's answer the slug comes from a Supervisor-style "
|
||||
"hostname",
|
||||
"(/config/app/abcd1234_goodwe_controller/documentation)"
|
||||
in SAMPLES[0].data()["message"])
|
||||
os.environ["HOSTNAME"] = "some-laptop"
|
||||
check("...and anywhere else it names the section without a guessed link",
|
||||
"](" not in SAMPLES[0].data()["message"]
|
||||
and "under Notifications" in SAMPLES[0].data()["message"])
|
||||
SAMPLES[0].data()["message"].endswith(
|
||||
"\n\nDetails: add-on documentation, section “Battery is not keeping "
|
||||
"up” under Notifications."))
|
||||
del os.environ["HOSTNAME"]
|
||||
|
||||
# Where the slug and the version come from: the Supervisor, read once at start.
|
||||
import asyncio # noqa: E402
|
||||
from app.main import docs_link_facts # noqa: E402
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, body):
|
||||
self.body = body
|
||||
|
||||
async def __aenter__(self):
|
||||
if isinstance(self.body, Exception):
|
||||
raise self.body
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def json(self, content_type=None):
|
||||
return self.body
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self, answers):
|
||||
self.answers, self.asked = answers, []
|
||||
|
||||
def get(self, url, **kw):
|
||||
self.asked.append((url, kw.get("headers", {}).get("Authorization")))
|
||||
return _Resp(self.answers[url])
|
||||
|
||||
|
||||
_ok = _Session({
|
||||
"http://supervisor/addons/self/info": {"result": "ok", "data": {
|
||||
"slug": "local_goodwe_controller", "version": "0.12.0"}},
|
||||
"http://supervisor/info": {"result": "ok", "data": {
|
||||
"homeassistant": "2026.9.1", "supervisor": "2026.08.0"}}})
|
||||
_down = _Session({"http://supervisor/addons/self/info": OSError("refused"),
|
||||
"http://supervisor/info": ValueError("not json")})
|
||||
check("at start the add-on asks the Supervisor for its slug and HA's version, "
|
||||
"with its own token",
|
||||
asyncio.run(docs_link_facts(_ok, "tok")) == {
|
||||
"slug": "local_goodwe_controller", "ha_version": "2026.9.1"}
|
||||
and all(a == "Bearer tok" for _u, a in _ok.asked))
|
||||
check("...and a Supervisor that does not answer costs the link, never the "
|
||||
"start",
|
||||
asyncio.run(docs_link_facts(_down, "tok")) == {
|
||||
"slug": None, "ha_version": None})
|
||||
|
||||
# Shadow mode (UX-09): nothing the add-on decides reaches the inverter, so no
|
||||
# text may say a command was sent or that the battery is giving power.
|
||||
_c1 = [messages.Clamp(None, 7412, 5000, "rating")]
|
||||
_c2 = [messages.Clamp("inverter-a", -3000, -2500, "device"),
|
||||
messages.Clamp("inverter-b", 6000, 4600, "max_w+device")]
|
||||
_live = [messages.output_clamp(c).body for c in (_c1, _c2, ())]
|
||||
_shad = [messages.output_clamp(c, shadow=True).body for c in (_c1, _c2, ())]
|
||||
check("live, the over-rating alarm says what was sent and that control runs",
|
||||
" and sent 5 000 W" in _live[0] and " and sent 2 500 W" in _live[1]
|
||||
and all("Control is still running" in b or "control is still running"
|
||||
in b for b in _live))
|
||||
check("in shadow mode it says what would have been sent, and that the "
|
||||
"inverters stay at 0 W",
|
||||
"would have sent 5 000 W" in _shad[0]
|
||||
and "would have sent 2 500 W" in _shad[1]
|
||||
and "would have been cut back" in _shad[2]
|
||||
and all(" sent" not in b.replace("would have sent", "") for b in _shad)
|
||||
and all("0 W" in b and "hadow mode" in b for b in _shad))
|
||||
_row_live = messages.row_power_too_high(_c1, 3)
|
||||
_row_shad = messages.row_power_too_high(_c1, 3, shadow=True)
|
||||
check("the over-rating row says 'sent' live and 'would send' in shadow mode",
|
||||
"sent 5 000 W" in _row_live and "would" not in _row_live
|
||||
and "would send 5 000 W" in _row_shad and " sent" not in _row_shad
|
||||
and "shadow mode" in _row_shad)
|
||||
_sat = messages.peak_saturated(5000, shadow=True).body
|
||||
check("in shadow mode the peak alarm does not claim the battery is giving "
|
||||
"power, and keeps its action",
|
||||
"giving all it can" not in _sat and "gives nothing" in _sat
|
||||
and "Switch off large loads" in _sat
|
||||
and "giving all it can" in messages.peak_saturated(5000).body)
|
||||
check("shadow mode changes no notification's id or severity",
|
||||
all(messages.output_clamp(c, shadow=True).id == messages.ID_OUTPUT_CLAMP
|
||||
and messages.output_clamp(c, shadow=True).severity == messages.ALARM
|
||||
for c in (_c1, _c2, ()))
|
||||
and messages.peak_saturated(1, shadow=True).id == messages.ID_PEAK_SATURATED
|
||||
and messages.peak_saturated(1, shadow=True).severity == messages.ALARM)
|
||||
check("the output-clamp alarm quotes asked before sent",
|
||||
messages.output_clamp([messages.Clamp(None, 7412, 5000, "rating")]).body
|
||||
.index("7 412 W") < messages.output_clamp(
|
||||
@@ -305,7 +419,7 @@ ALLOW_FILE = HERE / "ux08_allowlist.txt"
|
||||
# The allowlist may never grow. The test demands the list be exactly this long,
|
||||
# so deleting lines means lowering this number in the same change, and adding
|
||||
# one means raising it where a reviewer sees it.
|
||||
ALLOWLIST_MAX = 168
|
||||
ALLOWLIST_MAX = 167
|
||||
|
||||
|
||||
def read_allowlist(path):
|
||||
|
||||
@@ -1065,7 +1065,7 @@ check("...and the status page says the guard is off, and why, rather than "
|
||||
"'idle' or a green row - the same answer SAFETY-18 already gives on a "
|
||||
"transport that cannot see it", len(off_rows) == 1
|
||||
and ": off" in off_rows[0]["text"]
|
||||
and "meter source" in off_rows[0]["text"]
|
||||
and "Where the meter reading comes from" in off_rows[0]["text"]
|
||||
and "idle" not in off_rows[0]["text"]
|
||||
and not off_rows[0]["ok"] and off_rows[0]["warn"])
|
||||
|
||||
@@ -2487,7 +2487,7 @@ rows25 = [r for r in c25.checks() if r.get("id") == "peak_guard"]
|
||||
|
||||
def _row_scope(text):
|
||||
"""SAFETY-25 in messages.row_peak_reserve()'s words."""
|
||||
return ("Home use still discharges below the reserve" in text
|
||||
return ("Home use still draws the battery below it" in text
|
||||
and "down to the cut-out level" in text)
|
||||
|
||||
|
||||
@@ -2531,8 +2531,8 @@ def _says_scope(body):
|
||||
"""SAFETY-25 in messages.peak_reserve()'s words: the reserve binds the peak
|
||||
guard only, and home use goes on discharging below it to the cut-out."""
|
||||
return ("The reserve only applies to keeping the peak down" in body
|
||||
and "normal home use can still discharge the battery below it" in body
|
||||
and "down to its cut-out level" in body)
|
||||
and "normal home use can still draw the battery below it" in body
|
||||
and "down to the battery cut-out level" in body)
|
||||
|
||||
|
||||
print(f" notification body tail: ...{_body25[-180:]}")
|
||||
|
||||
@@ -535,7 +535,7 @@ print(f" {[r['text'] for r in _bad_row]}")
|
||||
check("a refused read says WHICH refusal - 'no reading' on its own sends an "
|
||||
"operator to look at a working sensor",
|
||||
len(_bad_row) == 1
|
||||
and _bad_row[0]["text"] == ("Solar production: no reading, the sensor's "
|
||||
and _bad_row[0]["text"] == ("Solar production: no reading, the entity's "
|
||||
"unit is not a power unit"))
|
||||
check("...and every refusal pv.watts() can give has its own plain words",
|
||||
len({messages.pv_problem(pv.watts(st)[1]) for st in (
|
||||
@@ -996,7 +996,7 @@ check("⭐ BOTH KEYS SET: the page SAYS `pv_entity` is ignored and how many "
|
||||
"key still names - but a replacement nobody is told about is the 'one of "
|
||||
"which silently wins' this config refuses elsewhere",
|
||||
len(_ign) == 1 and _ign[0]["ok"] is False and _ign[0]["warn"] is True
|
||||
and "clear the single sensor setting" in _ign[0]["text"]
|
||||
and "clear “Solar production entity”" in _ign[0]["text"]
|
||||
and _c_both.pv_w == 3750.0)
|
||||
check("...and that caution appears ONLY when both are set - not on the 0.11.0 "
|
||||
"configuration, not on a list-only one, and not on an install with "
|
||||
@@ -1126,7 +1126,7 @@ check("⭐ THE REPORTED CASE: 13 W of night-time standby on a reversed meter is
|
||||
_c_own.pv_w == -13.0
|
||||
and [r["text"] for r in pv_rows(_c_own)]
|
||||
== ["Solar production: -13 W, sign reversed"]
|
||||
and _row(_c_own, "reversed-sign list") == [])
|
||||
and _row(_c_own, "Reversed-sign solar entities") == [])
|
||||
check("...and WITHOUT the option it is the plain row",
|
||||
[r["text"] for r in pv_rows(_c_own0)] == ["Solar production: 13 W"])
|
||||
|
||||
@@ -1140,7 +1140,7 @@ check("⭐ PADDING: ` sensor.x` in the roster and `sensor.x ` in "
|
||||
"`pv_invert_entities` are the same entity - flipped on the read path, "
|
||||
"marked on the page, and no stray caution",
|
||||
_c_pad.pv_w == MIX_TOTAL
|
||||
and _row(_c_pad, "reversed-sign list") == [])
|
||||
and _row(_c_pad, "Reversed-sign solar entities") == [])
|
||||
|
||||
# --- the negative caution -------------------------------------------------------
|
||||
def _neg(watts_by_entity, flip=()):
|
||||
@@ -1156,12 +1156,12 @@ _n_rm = _neg({E1: 1200, E2: 1500, E3: 450}, flip=[E2])
|
||||
print(f" {[r['text'] for r in _n_add + _n_rm]}")
|
||||
check("⭐ AN UNFLIPPED ENTITY FAR BELOW ZERO gets a caution that names it by "
|
||||
"its place in the list, its watts, and says to ADD it to the "
|
||||
"reversed-sign list (`pv_invert_entities`)",
|
||||
"setting 'Reversed-sign solar entities' (`pv_invert_entities`)",
|
||||
[r["text"] for r in _n_add]
|
||||
== ["Solar production: source 2 reads -1 500 W, so add it to the "
|
||||
"reversed-sign list"])
|
||||
== ["Solar production: source 2 reads -1 500 W, add it to "
|
||||
"“Reversed-sign solar entities”"])
|
||||
check("...and a FLIPPED one far below zero is told to REMOVE it instead",
|
||||
len(_n_rm) == 1 and "remove it from the reversed-sign list"
|
||||
len(_n_rm) == 1 and "remove it from “Reversed-sign solar entities”"
|
||||
in (_at(_n_rm, 0) or {}).get("text", ""))
|
||||
check("⭐ ...and it is a CAUTION, never a `bad` row - a telemetry sign cannot "
|
||||
"put a battery site into NOT READY",
|
||||
@@ -1184,10 +1184,10 @@ _c_typo, _, _ = run(cycles=1, by_entity=FULL,
|
||||
check("a `pv_invert_entities` entry naming no PV entity in use is a named "
|
||||
"caution (a typo would otherwise leave the sign silently unflipped)",
|
||||
[(r["text"], r.get("ok"), r.get("warn"))
|
||||
for r in _row(_c_typo, "not a solar sensor in use")]
|
||||
== [("Solar production: entry 1 of the reversed-sign list is not a "
|
||||
"solar sensor in use", False, True)]
|
||||
and _row(_c_mix, "not a solar sensor in use") == [])
|
||||
for r in _row(_c_typo, "not a solar entity in use")]
|
||||
== [("Solar production: entry 1 of “Reversed-sign solar entities” is "
|
||||
"not a solar entity in use", False, True)]
|
||||
and _row(_c_mix, "not a solar entity in use") == [])
|
||||
|
||||
# --- default: byte-identical to 0.12.0 ------------------------------------------
|
||||
_c_d0, _, _p_d0 = run(cycles=1, by_entity=MIX, **PV_MANY)
|
||||
|
||||
@@ -1276,8 +1276,8 @@ check("PRECONDITION: both runs actually tripped and actually raised the card, "
|
||||
and len(_notes_g) == 1 and len(_notes_s) == 1)
|
||||
|
||||
# --- AC 4: THE ATTRIBUTION ITSELF -------------------------------------------
|
||||
_WHO_G = "garage was discharging while shed was charging"
|
||||
_WHO_S = "shed was discharging while garage was charging"
|
||||
_WHO_G = "garage was giving power while shed was charging"
|
||||
_WHO_S = "shed was giving power while garage was charging"
|
||||
check("⚠️ THE ALARM NAMES THE TWO PACKS AND SAYS WHICH WAY EACH ONE IS PULLING. "
|
||||
"`circulation.py` calls this the first thing somebody woken at 3 a.m. "
|
||||
"needs and impossible to reconstruct from the watts alone, and until this "
|
||||
|
||||
@@ -45,7 +45,7 @@ configuration:
|
||||
name: "Hardware · Inverter setpoint entity"
|
||||
description: "The ESPHome number entity this add-on writes the power setpoint to, normally number.*_goodwe_setpoint_w. This is the only thing the add-on commands. Leave it empty if you fill in the inverter list below: a site with a roster must not also name one loose actuator, and the add-on refuses to start when both are set."
|
||||
soc_entity:
|
||||
name: "Hardware · Battery charge level entity (%)"
|
||||
name: "Hardware · Battery level entity (%)"
|
||||
description: "The sensor that reports the pack's state of charge in percent. Use the ESP32's own reading, not a figure computed somewhere else. The maintenance cycle and every charge-level floor read this."
|
||||
batt_entity:
|
||||
name: "Hardware · Battery power entity (W)"
|
||||
@@ -70,7 +70,7 @@ configuration:
|
||||
name: "Battery cut-out level (%)"
|
||||
description: "The percent at which THIS pack's own battery management system cuts out. Read it off the pack that is actually installed (register 0x0560) before that pack is ever commanded; do not copy another unit's figure. Required of every entry, deliberately: an optional one would be a defaulted one, and one default for two batteries of different models protects the shallower pack with the deeper pack's number."
|
||||
soc_entity:
|
||||
name: "Battery charge level entity (%) (optional)"
|
||||
name: "Battery level entity (%) (optional)"
|
||||
description: "This pack's own state of charge. Leave it empty if this unit reports none - that is what it means, and it never falls back to another unit's sensor."
|
||||
batt_entity:
|
||||
name: "Battery power entity (W) (optional)"
|
||||
@@ -85,13 +85,13 @@ configuration:
|
||||
name: "Hardware · RS485 emergency stop is fitted"
|
||||
description: "Whether the RS485 emergency stop is physically installed. It only drives the warning banner on the status page; it changes no control behaviour."
|
||||
pv_entity:
|
||||
name: "Hardware · PV production entity (W)"
|
||||
name: "Hardware · Solar production entity (W)"
|
||||
description: "Optional, and MEASUREMENT ONLY - it changes no control behaviour whatsoever. Point it at your solar inverter's own power sensor (any brand Home Assistant reads; it does not have to be on the RS485 bus) and the add-on records production beside the grid and battery figures, so a day of unstored surplus can be told apart from a day with no sun. POSITIVE MUST MEAN PRODUCING - if yours reads negative while producing, list it under 'sign is reversed' below. The unit is read from the entity itself - W, kW and the rest of Home Assistant's power units - so a sensor with no unit is refused rather than guessed at, and the status page says so. Leave it empty and nothing is read, nothing is published and nothing is shown."
|
||||
pv_entities:
|
||||
name: "Hardware · PV production entities, one per inverter (W)"
|
||||
name: "Hardware · Solar production entities, per inverter (W)"
|
||||
description: "Optional, and MEASUREMENT ONLY - it changes no control behaviour whatsoever. Use this instead of the single entity above when your roof has SEVERAL solar inverters: add one row per inverter, pointing at each one's own power sensor, and the add-on records the SITE total. Each entity's own unit is read, so a kW inverter beside a W one adds up correctly. ⚠️ SETTING THIS IGNORES THE SINGLE ENTITY ABOVE - clear that one when you fill this in, or you will see a caution on the status page saying so. ⚠️ AND IF ANY ONE OF THESE STOPS REPORTING THERE IS NO SITE TOTAL: the sensor goes to 'unknown' and the history records a gap, rather than publishing the sum of the ones that did answer. Two inverters out of three add up to a smaller number that looks exactly like a real one, and a week later that reads as 'there was less sun than there was' - which is the one conclusion this recording exists to test. The status page says which one is missing, by its place in this list. Leave it empty and nothing is read, nothing is published and nothing is shown."
|
||||
pv_invert_entities:
|
||||
name: "Hardware · PV entities whose sign is reversed"
|
||||
name: "Hardware · Reversed-sign solar entities"
|
||||
description: "Optional, MEASUREMENT ONLY. Some sensors - typically a kWh meter or a power clamp in the PV circuit - read NEGATIVE while producing and a few watts POSITIVE at night. Add each such entity here, spelled exactly as it is above, and its sign is flipped before it is added up; leave the others out. The status page marks every flipped entity. History recorded before you set this keeps the old sign."
|
||||
meter_source:
|
||||
name: "Meter · Where the meter reading comes from"
|
||||
@@ -148,7 +148,7 @@ configuration:
|
||||
name: "Limits · Quarter-hour demand forecast entity (W)"
|
||||
description: "Optional. A forecast of the current billing quarter's average demand. Leave it empty and there is no capacity cap at all."
|
||||
peak_soc_floor:
|
||||
name: "Limits · Peak guard will not discharge below (%)"
|
||||
name: "Limits · Peak reserve (%)"
|
||||
description: "DERIVED, not picked (SAFETY-24): the reference pack holds 13.8 kWh usable and the worst modelled peak-shaving day asks for 878 Wh, which is 6.4 %, so 20 % sits ten points above the pack's own cut-out and still leaves the guard about 1.6 of its worst observed days of room. ⚠️ IT BOUNDS THE PEAK GUARD ONLY. It is not a reserve for the whole pack: ordinary self-consumption discharges straight past it and goes on down to the battery cut-out, and raising this number does not change that by a tenth of a percent. It must stay strictly above the battery cut-out or the add-on refuses to start. Do not change it without re-deriving it."
|
||||
price_provider:
|
||||
name: "Tariff · Dynamic price source"
|
||||
|
||||
@@ -128,7 +128,7 @@ configuration:
|
||||
name: "Grenzen · Entiteit met de kwartierprognose (W)"
|
||||
description: "Optioneel. Een prognose van het gemiddelde vermogen in het lopende factuurkwartier. Laat u dit leeg, dan is er helemaal geen piekbegrenzing."
|
||||
peak_soc_floor:
|
||||
name: "Grenzen · Piekbewaking ontlaadt niet onder (%)"
|
||||
name: "Grenzen · Piekreserve (%)"
|
||||
description: "AFGELEID, niet gekozen (SAFETY-24): de referentiebatterij heeft 13,8 kWh bruikbaar en de zwaarste doorgerekende afvlakdag vraagt 878 Wh, oftewel 6,4 %, dus 20 % ligt tien punten boven de uitschakelgrens van de batterij zelf en laat de bewaking nog ongeveer 1,6 van haar zwaarste waargenomen dagen speling. ⚠️ DIT BEGRENST ALLEEN DE PIEKBEWAKING. Het is geen reserve voor de hele batterij: gewoon eigenverbruik ontlaadt er dwars doorheen en gaat door tot aan de uitschakelgrens van de batterij, en dit getal verhogen verandert daar geen tiende procent aan. Het moet strikt boven de uitschakelgrens van de batterij blijven, anders start de add-on niet op. Verander het niet zonder de afleiding opnieuw te maken."
|
||||
price_provider:
|
||||
name: "Tarief · Bron van dynamische prijzen"
|
||||
|
||||
@@ -127,7 +127,6 @@ option-name 52d03808da01 # en.yaml over 6 words (8) :: 'Your own INJECTION form
|
||||
option-name bf22f2121af4 # en.yaml over 6 words (8) :: 'Your own tariff formula (one line of JSON)'
|
||||
option-name 5593992fcf5b # en.yaml over 6 words (7) :: 'How far ahead to fetch prices (hours)'
|
||||
option-name d9fee5514a4b # en.yaml over 6 words (11) :: 'Levies and VAT by time of day (one line of JSON)'
|
||||
option-name 7d8d691905e0 # en.yaml over 6 words (7) :: 'PV production entities, one per inverter (W)'
|
||||
option-name eac7ee084aaa # en.yaml banned: Saturated :: 'Saturated cycles before freezing (count)'
|
||||
option-name 5c6bbc5cab40 # en.yaml banned: Saturation :: 'Saturation threshold (W)'
|
||||
option-name 99a1ec154d36 # en.yaml over 6 words (7) :: 'Shadow mode — run every decision, command nothing'
|
||||
|
||||
Reference in New Issue
Block a user