diff --git a/STYLE.md b/STYLE.md index 34af509..4117dfc 100644 --- a/STYLE.md +++ b/STYLE.md @@ -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. diff --git a/goodwe_controller/DECISIONS/UX-08-short-notifications.md b/goodwe_controller/DECISIONS/UX-08-short-notifications.md index da54cb9..16718e5 100644 --- a/goodwe_controller/DECISIONS/UX-08-short-notifications.md +++ b/goodwe_controller/DECISIONS/UX-08-short-notifications.md @@ -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//documentation`. 2026.5 stopped registering the `hassio` panel, so `/hassio/addon//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=`, 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. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 104ef6a..67605c0 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -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_` | -| 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_` | 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//documentation`; 2026.5 removed the old +`/hassio/addon//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 diff --git a/goodwe_controller/app/inverter.py b/goodwe_controller/app/inverter.py index eb886f7..d62f236 100644 --- a/goodwe_controller/app/inverter.py +++ b/goodwe_controller/app/inverter.py @@ -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: diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 53cd63c..7ff0cfe 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -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,35 @@ def control_task_done(task: asyncio.Task, *, controller, stop: asyncio.Event, stop.set() +DOCS_LINK_TIMEOUT_S = 3 + + +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). + Asked together with a 3 s budget: a link may never hold up control start. + """ + async def ask(url, field): + try: + async with session.get(url, timeout=DOCS_LINK_TIMEOUT_S, headers={ + "Authorization": f"Bearer {token}"}) as r: + body = await r.json(content_type=None) + data = body.get("data", body) if isinstance(body, dict) else {} + return 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) + return None + + slug, ha = await asyncio.gather( + ask("http://supervisor/addons/self/info", "slug"), + ask("http://supervisor/info", "homeassistant")) + out = {"slug": slug, "ha_version": ha} + _LOG.info("docs link: slug %s, Home Assistant %s", slug, ha) + return out + + async def amain() -> None: opts = load_options() logging.basicConfig( @@ -5394,6 +5430,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. diff --git a/goodwe_controller/app/messages.py b/goodwe_controller/app/messages.py index 1e7bb9d..24f6868 100644 --- a/goodwe_controller/app/messages.py +++ b/goodwe_controller/app/messages.py @@ -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//. 2026.5 dropped the old `hassio` panel, so +# /hassio/addon// 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 gives less than asked, which is normal for a " + "moment, but if it lasts, check 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 "