Compare commits

...
5 Commits
Author SHA1 Message Date
Claude review 8e26574b03 Release 0.21.0: CHANGELOG wording (SAFETY-136, SAFETY-138 known issues)
checks / unit (pull_request) Successful in 24m11s
infra-pin / fresh (pull_request) Successful in 2s
SAFETY-136: the line said 10 to 15 s late is not caught, then that 15 s late
is caught after about 4 minutes. It now says 10 s is not caught and 15 s only
after about 4 minutes, as the card says.
SAFETY-138: the 30 s timer that zeroes a late write is the ESPHome board's
watchdog (README), not the inverter's own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 10:20:46 +01:00
Claude 0f208a0500 Release 0.21.0: version bump and CHANGELOG
checks / unit (pull_request) Successful in 33m53s
infra-pin / fresh (pull_request) Successful in 2s
Minor bump, no option changes. Folds "Not in a release yet" into a new
0.21.0 section, adds the contents table for the nine merges on release/1.0
since master 0.20.1, updates the known issues, and rewrites the 0.20.1
SAFETY-128 known issue to say it ships in 0.21.0.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-08 09:08:59 +01:00
admin b3cfcf2df4 Merge pull request 'SAFETY-137: the shutdown 0 W must be the last write every unit hears' (#240) from SAFETY-137 into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-11-08 04:20:04 +01:00
Claude 8953be0362 SAFETY-137: the shutdown and Stop 0 W is the last write every unit hears
checks / unit (pull_request) Successful in 22m59s
infra-pin / fresh (pull_request) Successful in 1s
The 0 W of shutdown and of the Stop button now waits (bounded, 2 s) for a
write already on its way to the same unit, for every driver, GoodWe included.
Once shutdown has begun, cycle, heartbeat and safety-stop writes are dropped
right before the write is created. One shared wait (_await_inflight) serves
the 0 W and SMA-10's _release_last.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-08 02:39:38 +01:00
admin dbdc37b959 Merge pull request 'SMA-10: two idle states, hold 0 W or hand the inverter back' (#239) from SMA-10 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-11-07 23:43:14 +01:00
6 changed files with 520 additions and 26 deletions
+118 -8
View File
@@ -1,13 +1,123 @@
# Changelog
### Not in a release yet
## 0.21.0
**A minor bump: control now holds the batteries when Home Assistant readings come late, Home draws the whole night's grid charge, and the shutdown 0 W is sent last. No option changes.**
No option was added, removed or renamed, and no default changed (86 options). **Existing installs need no option change:** options that start 0.20.1 also start 0.21.0, and the Supervisor form is the same. It is a minor bump because control acts differently when readings come late and when the add-on stops; every saved setting still starts.
**Late Home Assistant readings are held, not followed.** When Home Assistant passes readings on late, control used to correct a mistake its last command had already fixed, so the give run could swing and send power to the grid. It now waits until the battery shows its last power request, and the peak limit no longer asks for full power on late readings. A row on the page says so, and if the readings stay too late every inverter goes to 0 W. With readings on time, the commands are the same as before.
- With a 2 s meter, readings 10 s late were found in 43 s in a rig test.
- With a 5 s meter, readings 30 s late are now found in about 90 s (88 s in a simulation, 75 s in a rig test), where they took about 3 minutes. Readings 10 s late at a 5 s meter are not found ([[SAFETY-136]]).
**Home draws the whole night's grid charge.** Home used to draw a battery's grid charge only for the cheap run under way. It now draws every hour the add-on will charge it tonight, and shares the peak room between batteries as control does. It shows the plan and changes no command.
**Tomorrow's forecast stays in the afternoon.** If one of the tomorrow solar forecast sensors stops updating, tomorrow's forecast now stays instead of vanishing until 20:00.
**The shutdown 0 W is sent last.** When the add-on stops, the 0 W now waits up to 2 s for a command already on its way, so it is the last thing each inverter hears. A command slower than that can still land after it ([[SAFETY-138]]).
Contents of this release, in merge order since 0.20.1 (`d53f70f`):
| | |
|---|---|
| `371961e` | When Home Assistant passes readings on late, control no longer corrects a mistake its last command has already fixed. It waits until the battery shows its last power request, and the peak limit no longer asks for full power on late readings. The page says so, and if the readings stay too late every inverter goes to 0 W ([[SAFETY-128]], #228) |
| `af2d0d2` | History only: brings 0.20.1, already live, into this branch. Nothing else is new (#233) |
| `47c89a1`, `c66aa7f` | Home draws a battery's grid charge for every hour the add-on will charge it tonight, not only the cheap run under way, and shares the peak room between batteries as control does. It shows the plan and changes no command ([[UX-77]], #234, with its second round #235) |
| `b291262` | With a 5 s meter, readings 30 s late are found in about 90 s, not about 3 minutes. Readings 10 s late are not found at a 5 s meter; the owner accepted this and it has its own card ([[SAFETY-129]], #236, [[SAFETY-136]]) |
| `23ff9d0` | If one of the tomorrow solar forecast sensors stops updating in the afternoon, tomorrow's forecast now stays on the Forecast tab instead of vanishing until 20:00. The night plan can use it in that time ([[OPT-38]], #237) |
| `57c8d1b` | Each inverter is now reached through a driver. This prepares support for other inverter makes; there is no change for GoodWe sites ([[SMA-2]], #238) |
| `dbdc37b` | When the add-on stops, an inverter that can steer itself can be handed back instead of held at 0 W. This prepares support for other inverter makes; there is no change for GoodWe sites ([[SMA-10]], #239) |
| `b3cfcf2` | When the add-on stops, the 0 W now waits up to 2 s for a command already on its way, so it is the last thing each inverter hears. In a rig test, 0 of 111 stops ended on a value other than 0 W, against 4 of 110 before. A slower command can still land after it ([[SAFETY-137]], #240, [[SAFETY-138]]) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s. This is about the guard's discharge only; a grid charge is cut back since 0.19.2 ([[SAFETY-99]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
- When the 15-minute demand forecast sensor counts the battery's own charge, the maintenance charge runs at about half the power the peak limit leaves, so it takes about twice as long. Seen on a site: about 1.2 kW instead of 2.5 kW, and the peak limit was kept ([[SAFETY-111]]).
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]).
- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]).
- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]).
- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]).
- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]).
- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]).
- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]).
- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]).
- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]).
- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the action log on the page gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]).
- A battery in a safety stop, or one the add-on cannot reach, can still be started for a cycle, and its drain then waits about 12 h and ends in a failed-cycle notice. This comes from reading the code; it has not been seen on a site ([[MAINT-19]]).
- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]).
- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]).
- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]).
- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]).
- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]).
- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]).
- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]).
- A steady overload on a 1 s meter can warn, and send the Home Assistant notification, 2 or 3 times at the start of each 15-minute period instead of once. The peak limit is still kept ([[SAFETY-122]]).
- A fixed tariff is priced once per hour, so a price window that starts or ends at :15, :30 or :45 gets the wrong price for part of that hour. Windows that start and end on the hour are right ([[TARIFF-23]]).
- Step 6 of the setup wizard stays empty because of an error in the page, in English and in Dutch ([[UX-51]]).
- On Dutch pages, refusal texts, the last-event line under the Home buttons, the installer details and the trading status sentences are still English, and a few Dutch texts read badly, such as "1 metingen geweigerd" ([[UX-52]]).
- Some price texts say "hour" where the add-on acts on a 15-minute period, such as "cheapest hour" ([[UX-47]]).
- After the add-on restarts, the Home price chart shows no price for the earlier part of today, and the setup check can say "12 of 24 hours", though the prices are known ([[UX-48]]).
- With battery modes on and only batteries on "Leftover solar", the Home price chart can mark a cheap period where no battery charges ([[UX-49]]).
- On a phone, the bars of the Home price chart are too thin to tap one 15-minute period. The arrow keys work ([[UX-50]]).
- In the last second of a 15-minute period, being a few watts over the aim can make the peak guard ask a battery for full power for one second. When a charge ends on a period edge, the last-second give can fade out over about 10 s, and a rig test exported up to 7.4 kW for about 8 s ([[SAFETY-124]]).
- During the night grid charge, a 15-minute period can end 6 to 14 W over the peak aim, because the battery charges harder than asked. This was seen in simulation; a rig test saw 2 to 8 W ([[OPT-35]]).
- The test rig could not bring back the false peak alarm that [[SAFETY-126]] fixes, so that fix is shown in simulation only. Watch the notifications for a few nights ([[TEST-35]]).
- The night cheap run is sized for the batteries' full charging power, not for what the peak limit leaves. Under a tight limit the run can end before the batteries reach their level, and Home can show a level they do not reach (79.9 % against a shown 95 % in a rig test) ([[OPT-25]]).
- By day, a Leftover solar plan that has only a daily forecast figure counts the sun from now, not from when the figure was read. It counts about 22 % too much for a figure one hour old; the night plan is not affected ([[OPT-30]]).
- The night charge can buy too much in a house that uses more at night than by day, such as one with a car charger or heat pump, and on a sunny day, because spare sun in one expensive hour does not count for the later ones ([[OPT-31]]).
- A cheap run that has already started can overlap a new give run after a re-plan, so a battery can charge from the grid at the dearest price for part of an hour. This was seen in a test case, not on a site. Also in 0.19.2 ([[OPT-32]]).
- If the solar forecast sensors stop updating before 17:00, as in winter or further north, there is no forecast from three hours after their last update until 20:00 ([[OPT-33]]).
- With a forecast that gives only a daily total, the sun is spread an hour off on the two clock-change days after the change. The day's total is right ([[OPT-24]]).
- The add-on learns the house's use only once a solar forecast is set, so a site that adds a forecast later waits one more day for its first plan ([[OPT-26]]).
- Home says "No trading on price yet: no prices have arrived" when every battery is on Solar first, though the prices are shown ([[UX-55]]).
- The Home plan header can say "Charges in the cheapest hour, gives power in the dearest" when Home draws no charge, and it says "hour" for 15-minute prices ([[UX-56]]).
- While the peak guard holds back charging, Home hides the rest of the cheap run and gives no reason. The charge starts again when the guard lets go ([[UX-57]]).
- With battery modes on and selling on, Home can draw a "Sells up to" block when the add-on judges the trade not worth it, and then it will not sell ([[UX-58]]).
- Home, the plan and the Forecast tab stop drawing a battery's give at the trading floor, though control goes on giving down to the battery's cut-out ([[UX-59]]).
- During a price outage, the Batteries screen says "Prices differ too little" and Home says "The price gap is too small" ([[UX-60]]).
- With battery modes on and no meter, or a meter that is a Home Assistant entity, a grid charge is not possible and nothing says so ([[UX-61]]).
- On the Batteries screen, a charge the add-on refused in a cheap run reads as "Saves its power for ...", for up to 15 minutes ([[UX-62]]).
- A Leftover solar battery with no capacity set never charges from the grid when a solar forecast is set, and nothing warns about it ([[UX-63]]).
- The "How the modes work" box on the Batteries screen does not describe Cheap hours only ([[UX-64]]).
- With battery modes on, Settings still shows "Trade on the price spread", which the modes ignore. A site with modes on and no price source starts, and the Batteries screen says "No prices, so it follows the house" without pointing to Settings ([[UX-65]]).
- With battery modes off and several equal prices in a row, Home and the Forecast tab draw one price quarter, while the add-on trades every quarter of the run ([[UX-67]]).
- Late in the day, the Forecast tab's "now" line and last bars sit over the axis labels at the right edge ([[UX-68]]).
- The Forecast tab's level curve charges at each battery's full rating. Under a peak limit the levels it draws are too high and a charge block can end early (54.6 % shown against 39.7 % in a rig test) ([[UX-70]]).
- The peak forecast check says maintenance charging is not kept under the peak limit when no peak forecast is set, but since 0.19.2 it is ([[UX-73]]).
- The peak guard row can show the same figure twice, such as "at this rate 3 500 W, over 3 500 W". Also in 0.19.2 ([[UX-74]]).
- If the inverter's setpoint entity appears in Home Assistant after the add-on has started, the page keeps saying "Inverter control: not found in Home Assistant" and the batteries say "An alarm stopped control" until a restart, though control runs ([[UX-75]]).
- In the last minute of a 15-minute period, with Home Assistant readings 30 s late, the period can end about 80 W over the peak aim and over the limit: 3 530 W against a 3 450 W aim and a 3 500 W limit in a rig test with a 2 kW load added 45 s before the end. The peak guard sees the load too late to hold it back ([[SAFETY-133]]).
- With Home Assistant readings 10 s late, the grid charge in a cheap run can swing by several kW. In a test the add-on asked for an 8.7 kW charge (9.6 kW on 0.20.0) ([[SAFETY-132]]).
- A plan rebuilt while the battery levels are missing for a moment can drop a cheap run that has not started yet, so the battery does not charge that day. Seen in a replay of real prices, where it cost about EUR 0.40 of extra grid use; the Leftover solar change in 0.20.1 no longer triggers it in those replays, but the cause stays ([[OPT-37]]).
- On a 5 s meter, Home Assistant readings 10 s late are not caught, and readings 15 s late only after about 4 minutes. With a changing load such as an oven or a kettle, 21 to 142 Wh can go to the grid in a simulation, against 2 to 27 Wh with readings on time ([[SAFETY-136]]).
- With readings 10 to 15 s late, the hold can end in the middle of the delay and the swing can come back. Seen in rig tests with a 2 s and a 5 s meter ([[SAFETY-134]]).
- With readings 10 s late on a 1 s meter, the grid can still move between -422 and +340 W while the batteries are held. With a very long delay (30 to 90 s) the request can swing by about 900 W every few seconds. Seen in review tests ([[SAFETY-130]]).
- A battery that answers very slowly (10 s or more) can be taken for late Home Assistant readings and held, and the page then blames Home Assistant. A GoodWe battery settles in about 6 s and this has not been seen on one ([[SAFETY-131]]).
- The page row for late readings reads awkwardly in English ("Home Assistant readings show requests about N seconds late, so control holds the batteries"), and the Dutch text misses a word ([[UX-78]]).
- On a night grid charge held to the peak limit, the peak guard can stop the charge 1 to 3 s into a 15-minute period and keep it paused for that whole period. In a rig test this cost up to about 0.27 kWh of grid charge per run; it may come from the sped-up rig clock ([[SAFETY-135]]).
- When the add-on stops, a write that takes more than 2 s can still land after the 0 W, and the 30 s safety timer on the ESPHome board then sets 0 W. After the Stop button, a cycle that had already decided can send its share (560 to 670 W) just after the 0 W; the next cycle sends 0 W again ([[SAFETY-138]]).
- In the last hour of a cheap run, Home can draw a battery's grid charge up to an hour past the time control stops charging it. It only draws too much, never too little ([[UX-79]]).
- A tomorrow solar forecast sensor that reports just after midnight with yesterday's value can be used as the new day's forecast for up to 12 hours. This has not been seen on a site ([[OPT-39]]).
- During a cheap-hours charge, at some 15-minute boundaries the add-on sends 0 W for one tick ("no trade (headroom)") and then goes on. Seen in a rig test, in 5 of 8 cases; once, with a frozen battery reading, it left a battery at 0 W until 23:45 ([[OPT-40]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
- When the add-on stops, an inverter that can steer itself is handed back instead of held at 0 W. Safety stops still hold 0 W, and a GoodWe site is not affected ([[SMA-10]]).
- Each inverter is now reached through a driver, so another make can be added later. Nothing changes for a GoodWe site ([[SMA-2]]).
- Home now draws today's charge from the grid for every hour the add-on will really charge, not only the run under way ([[UX-77]]).
- When Home Assistant passes readings on late, control no longer swings on them: it waits until the battery shows its last power request, and the peak limit no longer asks for full power on them. The page says so, and if the readings stay too late every inverter goes to 0 W ([[SAFETY-128]]).
- With a 5 s meter, readings 30 s late are now noticed within 100 seconds, not 3 minutes ([[SAFETY-129]]).
- If one of the tomorrow solar forecast sensors stops updating in the afternoon, tomorrow's forecast now stays on the Forecast tab instead of vanishing until 20:00 ([[OPT-38]]).
## 0.20.1
@@ -64,7 +174,7 @@ Contents of this release, in merge order since 0.20.0 (`01e88a2`):
- After the add-on restarts, the Home price chart shows no price for the earlier part of today, and the setup check can say "12 of 24 hours", though the prices are known ([[UX-48]]).
- With battery modes on and only batteries on "Leftover solar", the Home price chart can mark a cheap period where no battery charges ([[UX-49]]).
- On a phone, the bars of the Home price chart are too thin to tap one 15-minute period. The arrow keys work ([[UX-50]]).
- Control acts on Home Assistant readings that arrive late. With readings 10 s behind, the give run can swing and send up to 1.9 kW to the grid for about 50 s in a rig test; with readings 30 s behind, a battery can charge from the grid in an expensive period. Also in 0.19.2. A fix is made for the next release and is not in 0.20.1 ([[SAFETY-128]]).
- Control acts on Home Assistant readings that arrive late. With readings 10 s behind, the give run can swing and send up to 1.9 kW to the grid for about 50 s in a rig test; with readings 30 s behind, a battery can charge from the grid in an expensive period. Also in 0.19.2. The fix is not in 0.20.1; it ships in 0.21.0 ([[SAFETY-128]]).
- In the last second of a 15-minute period, being a few watts over the aim can make the peak guard ask a battery for full power for one second. When a charge ends on a period edge, the last-second give can fade out over about 10 s, and a rig test exported up to 7.4 kW for about 8 s ([[SAFETY-124]]).
- During the night grid charge, a 15-minute period can end 6 to 14 W over the peak aim, because the battery charges harder than asked. This was seen in simulation; a rig test saw 2 to 8 W ([[OPT-35]]).
- The test rig could not bring back the false peak alarm that [[SAFETY-126]] fixes, so that fix is shown in simulation only. Watch the notifications for a few nights ([[TEST-35]]).
@@ -0,0 +1,21 @@
# SAFETY-137 - the shutdown 0 W is the last write every unit hears
SMA-10 made a hand-back release wait for a write already on its way
(`_release_last`, `Inverter.inflight`). A GoodWe unit skipped that, and rig 5b
saw a cycle write (-2 823 W) land after the shutdown 0 W and stay until relaunch.
Now the 0 W of shutdown and of the Stop button is a *last* write
(`_write_unit(last=True)`), for every driver:
- It first waits, at most `SETTLE_BUDGET_S` (2 s), for the writes already on
its way to that unit (`_await_inflight`, shared with `_release_last`). A hung
write is not waited out and not cancelled; the 0 W goes anyway.
- Once shutdown has begun (`stopping`), every other write (cycle, heartbeat,
safety stop) is dropped at the same place, right before the write is created.
That is the gate that keeps the control cycle quiet; the cycle itself needs
no cancelling.
- Worst case for a GoodWe unit is 2 s + `WRITE_BUDGET_S` (4 s) = 6 s, the same
as `HAND_BACK_WORST_S`; the units are handled together.
Not touched: the heartbeat's own timing, safety-stop paths, the maintenance
abort (still a plain 0 W).
+45 -15
View File
@@ -153,6 +153,13 @@ RELEASE_BUDGET_S = WRITE_BUDGET_S
FALLBACK_BUDGET_S = 2.0
HAND_BACK_WORST_S = RELEASE_BUDGET_S + FALLBACK_BUDGET_S
# SAFETY-137. How long a LAST write (the Stop 0 W, the shutdown 0 W, a release)
# waits for a write already on its way to the same unit. Half the release
# budget, as `_release_last` always waited. For a GoodWe unit that is
# SETTLE_BUDGET_S + WRITE_BUDGET_S = 6 s at worst, which is HAND_BACK_WORST_S:
# the wind-down is no slower than SMA-10's worst case.
SETTLE_BUDGET_S = RELEASE_BUDGET_S / 2
# SAFETY-34: a Core-only restart wipes persistent_notification and does NOT
# restart this add-on (measured on TEST-23), so a latched alarm is re-asserted
# this often. Why: goodwe docs/DECISIONS.md, SAFETY-34.
@@ -4020,7 +4027,7 @@ class Controller:
return rec.authorises(inv.name, value, self.alloc_tick)
async def write_setpoint(self, value: float, *, force: bool = False,
unit=None) -> None:
unit=None, last: bool = False) -> None:
"""Put `value` on ONE inverter, or - with `unit=None` - on all of them.
⚠️ `unit=None` IS FOR A NUMBER THAT IS NOT A SHARE. The stop button,
@@ -4059,16 +4066,23 @@ class Controller:
targets = [unit]
if not targets:
return
await asyncio.gather(*(self._write_unit(inv, value, force=force)
await asyncio.gather(*(self._write_unit(inv, value, force=force,
last=last)
for inv in targets))
async def _await_inflight(self, inv, budget_s: float) -> None:
"""Wait, at most `budget_s`, for the writes already on their way to
`inv` (SAFETY-137: every driver, GoodWe too). A write still running at
the bound is left alone, not cancelled: its own WRITE_BUDGET_S ends it."""
pending = [t for t in inv.inflight if not t.done()]
if pending:
await asyncio.wait(pending, timeout=budget_s)
async def _release_last(self, inv) -> bool:
"""`release()`, after a write already on its way to `inv` has finished
(at most half the budget is spent waiting for it), so the release is the
LAST thing the unit hears and a late write cannot undo it."""
pending = [t for t in inv.inflight if not t.done()]
if pending:
await asyncio.wait(pending, timeout=RELEASE_BUDGET_S / 2)
await self._await_inflight(inv, SETTLE_BUDGET_S)
return await self._driver(inv).release()
async def _hand_back(self, inv, *, force: bool = False) -> bool:
@@ -4141,7 +4155,8 @@ class Controller:
_LOG.debug("The fallback write to %s failed: %r", inv.name, err)
return False
async def _write_unit(self, inv, value: float, *, force: bool = False) -> None:
async def _write_unit(self, inv, value: float, *, force: bool = False,
last: bool = False) -> None:
if not inv.driver.configured():
return
# --- SMA-10: A UNIT THAT HANDS BACK IS NOT WRITTEN WHILE THE ADD-ON IS
@@ -4284,6 +4299,21 @@ class Controller:
value, inv.name, shadow.WIRE_W)
value = shadow.WIRE_W
# --- SAFETY-137: THE 0 W THAT ENDS THINGS IS THE LAST WRITE THE UNIT
# HEARS. `last` is the Stop 0 W and the shutdown 0 W (and nothing else).
# It first waits, bounded, for a write already on its way to this unit,
# so that write cannot land after it. And once shutdown has begun every
# other write (cycle, heartbeat, a safety stop) is dropped here: the
# check sits right before the write is created with no await between,
# so a write that starts after it cannot exist, and one that started
# before is in `inflight` for the wait above. The idle gate for a
# hand-back unit is higher up and does the same for those.
if last:
await self._await_inflight(inv, SETTLE_BUDGET_S)
elif self.stopping:
_LOG.debug("Shutting down, so %+.0f W to %s is not sent.",
value, inv.name)
return
changed = (inv.last_write is None or value != inv.last_write)
if not (changed or force):
return
@@ -4295,11 +4325,11 @@ class Controller:
# `changed` is still true.
try:
_w = self._driver(inv).write(value)
if inv.driver.hands_back:
# SMA-10: kept, so a release can wait for it (`_release_last`)
_w = asyncio.ensure_future(_w)
inv.inflight.add(_w)
_w.add_done_callback(inv.inflight.discard)
# SMA-10 / SAFETY-137: kept for every driver, so a release or a last
# 0 W can wait for it (`_await_inflight`).
_w = asyncio.ensure_future(_w)
inv.inflight.add(_w)
_w.add_done_callback(inv.inflight.discard)
ok = await asyncio.wait_for(_w, timeout=WRITE_BUDGET_S)
except (asyncio.TimeoutError, TimeoutError):
ok = False
@@ -7209,17 +7239,17 @@ class Controller:
call takes no longer than its slowest bound.
"""
if not any(inv.driver.hands_back for inv in self.inverters):
await self.command_site_zero()
await self.command_site_zero(last=True)
return
self.target = 0.0
for inv in self.inverters:
inv.target = 0.0
await asyncio.gather(*(
self._hand_back(inv, force=True) if inv.driver.hands_back
else self._write_unit(inv, 0.0, force=True)
else self._write_unit(inv, 0.0, force=True, last=True)
for inv in self.inverters))
async def command_site_zero(self) -> None:
async def command_site_zero(self, *, last: bool = False) -> None:
"""Every inverter to 0 W, and every inverter's TARGET to 0 W with it.
⚠️ THE SECOND HALF IS NOT BOOKKEEPING, AND IT IS A HAZARD DEV-02
@@ -7241,7 +7271,7 @@ class Controller:
self.target = 0.0
for inv in self.inverters:
inv.target = 0.0
await self.write_setpoint(0.0, force=True)
await self.write_setpoint(0.0, force=True, last=last)
# -- UI ------------------------------------------------------------------
def checks(self, when: datetime | None = None) -> list:
+1 -1
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.20.1"
version: "0.21.0"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over its RS485 EMS port by sending it a
@@ -0,0 +1,332 @@
"""SAFETY-137: the 0 W of shutdown and of the Stop button is the last write every
unit hears. `python3 test_safety137_last_write.py`
A cycle write already on its way (0.3-0.6 s, or hung) must not land after the
0 W, for a GoodWe unit and for a fake unit that does not hand back, at N=1 and
N=2, at shutdown and at Stop. The control cycle (and the heartbeat) issue no
new write once shutdown has begun. The whole wind-down stays inside
HAND_BACK_WORST_S (6 s) when a write hangs.
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
The mutants this file was run against are listed at the end.
"""
import asyncio
import sys
import time
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
from app import main as M # noqa: E402
from app.drivers import Driver, Probe, REGISTRY # noqa: E402
from app.main import Controller # noqa: E402
from app.store import DEFAULTS # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
class PlainFake(Driver):
"""A unit that does not hand back (so, like GoodWe, no release()). A write
of anything but 0 W takes `delay` s to land; every write is logged when it
LANDS (`landed`) and when it STARTS (`started`)."""
name = "plainfake"
writer_key = "host"
brand = "Fake"
hands_back = False
delay = 0.0
def __init__(self, host="", port=502, unit_id=1, rating=2000.0):
self.host, self.port, self.unit_id = host, port, unit_id
self.rating, self.power = rating, 0.0
self.landed, self.started = [], []
@classmethod
def from_entry(cls, entry):
return cls(entry.get("host", ""), entry.get("port", 502),
entry.get("unit_id", 1))
@classmethod
def actuator_id_of(cls, entry):
host = str(entry.get("host") or "").strip()
return f"{host}:{entry.get('port', 502)}:{entry.get('unit_id', 1)}" \
if host else ""
def configured(self):
return bool(self.host)
def actuator_id(self):
return f"{self.host}:{self.port}:{self.unit_id}"
def soc_source(self):
return "fake-soc"
def batt_source(self):
return "fake-power"
async def limits(self):
return -self.rating, self.rating
async def read(self):
return 60.0, self.power, {}
async def write(self, w):
self.started.append((time.monotonic(), float(w)))
if w != 0 and self.delay:
await asyncio.sleep(self.delay)
self.landed.append((time.monotonic(), float(w)))
self.power = w
return True
async def probe(self):
return Probe(-self.rating, self.rating, 60.0, self.power, {})
REGISTRY["plainfake"] = PlainFake
class MemStore:
def __init__(self):
self.data = dict(DEFAULTS)
def set(self, k, v):
self.data[k] = v
def get_time(self, *a):
return None
def set_time(self, *a):
pass
class Pub:
enabled = False
def publish(self, values):
pass
def close(self):
pass
class SlowHass:
"""Home Assistant for the GoodWe units. A set_number of anything but 0 W
takes `delay` s to land; the log gets the write when it lands."""
def __init__(self):
self.delay = 0.0
self.landed, self.started = [], [] # (t, entity, value)
self.numbers = {"sensor.grid": 3000.0, "sensor.soc_a": 50.0,
"sensor.batt_a": 0.0, "sensor.soc_b": 50.0,
"sensor.batt_b": 0.0}
async def number(self, entity, invert=False):
return self.numbers.get(entity)
async def limits(self, entity):
return (-3000.0, 3000.0, 1.0)
async def set_number(self, entity, value):
self.started.append((time.monotonic(), entity, value))
if value != 0 and self.delay:
await asyncio.sleep(self.delay)
self.landed.append((time.monotonic(), entity, value))
return True
async def state(self, entity):
return None
async def call(self, *a):
return True
def gw(name, suffix):
return dict(name=name, setpoint_entity=f"number.sp_{suffix}",
soc_entity=f"sensor.soc_{suffix}",
batt_entity=f"sensor.batt_{suffix}", max_w=3000,
bms_soc_floor=10)
def fk(name):
return dict(name=name, driver="plainfake", host="10.9.9.9", port=502,
unit_id=3, max_w=2000, bms_soc_floor=10)
ROSTERS = {
"N=1 GoodWe": [gw("inverter-a", "a")],
"N=1 fake": [fk("inverter-a")],
"N=2 GoodWe+GoodWe": [gw("inverter-a", "a"), gw("inverter-b", "b")],
"N=2 GoodWe+fake": [gw("inverter-a", "a"), fk("inverter-b")],
}
def site(inverters, delay):
opts = dict(meter_source="off", meter_entity="sensor.grid", max_w=5000,
gain=0.6, slew_w=0, deadband_w=15, target_grid_w=0, step_w=10,
heartbeat_s=10, stale_input_s=15, shadow_mode="off",
inverters=inverters)
hass = SlowHass()
hass.delay = delay
c = Controller(opts, hass, MemStore(), Pub(), stop=asyncio.Event())
for inv in c.inverters:
inv.dev_min, inv.dev_max = -3000.0, 3000.0
if isinstance(inv.driver, PlainFake):
inv.driver.delay = delay
c.auto = True
return c, hass
def per_unit(c, hass, kind="landed"):
"""name -> [(t, value)] in the order the writes LANDED (or started)."""
out = {}
for inv in c.inverters:
d = inv.driver
if isinstance(d, PlainFake):
out[inv.name] = list(getattr(d, kind))
else:
ent = d.setpoint_entity
out[inv.name] = [(t, v) for t, e, v in getattr(hass, kind)
if e == ent]
return out
async def cycles(c, n=2):
for _ in range(n):
await c.read_inputs()
await c.cycle()
async def in_flight(c):
"""A cycle write on its way to every unit (what rig 5b had): the real
cycle, whose writes are authorised and take `delay` s to land."""
tasks = [asyncio.ensure_future(cycles(c, 1))]
await asyncio.sleep(0.1)
assert all(not t.done() for t in tasks)
return tasks
async def finish(tasks):
for t in tasks:
t.cancel()
for t in tasks:
try:
await t
except BaseException: # noqa: BLE001 - cancelled or timed out
pass
def last_is_zero(c, hass):
got = per_unit(c, hass)
return all(w and w[-1][1] == 0.0 for w in got.values()), got
def scenario(label, inverters, how, delay):
"""how: 'shutdown' or 'stop'. Returns (seconds, ok, detail)."""
c, hass = site(inverters, delay)
async def go():
tasks = await in_flight(c)
t0 = time.monotonic()
if how == "shutdown":
await c.shutdown()
else:
await c.handle_action("auto_toggle") # Stop
dt = time.monotonic() - t0
# let a write that is still on its way land, to see if it overtakes
await asyncio.sleep(0.8 if delay < 5 else 0.0)
await finish(tasks)
return dt
dt = asyncio.run(go())
ok, got = last_is_zero(c, hass)
n_sent = {n: len(w) for n, w in got.items()}
check(f"{label}: the cycle write was on its way at {how} (test sees it)",
all(len(w) >= 2 for w in per_unit(c, hass, "started").values()))
print(f" {label}: {how}, delay {delay} s: {dt:.2f} s, "
f"landed per unit {n_sent}, last "
f"{ {n: (w[-1][1] if w else None) for n, w in got.items()} }")
return dt, ok, got
print("SAFETY-137 - the 0 W is the last write")
print("1. a slow write on its way (0.3 s, 0.6 s) when shutdown / Stop starts")
for label, roster in ROSTERS.items():
for how in ("shutdown", "stop"):
for delay in (0.3, 0.6):
dt, ok, got = scenario(label, roster, how, delay)
check(f"{label}, {how}, {delay} s in flight: the last write per "
f"unit is 0 W", ok)
check(f"{label}, {how}, {delay} s in flight: the slow write "
f"landed first, then the 0 W (not dropped, not overtaken)",
all(len(w) >= 2 and w[-2][1] != 0.0
for w in got.values()))
check(f"{label}, {how}: took about the write, not the budget "
f"({dt:.2f} s)", dt < delay + 1.0)
print("2. a hung write on its way: the 0 W still reaches every unit, in time")
for label, roster in ROSTERS.items():
for how in ("shutdown", "stop"):
dt, ok, got = scenario(label, roster, how, 3600.0)
check(f"{label}, {how}, hung write: 0 W landed on every unit and is "
f"its last write", ok)
check(f"{label}, {how}, hung write: inside HAND_BACK_WORST_S "
f"({dt:.2f} s <= {M.HAND_BACK_WORST_S:.0f} s)",
dt <= M.HAND_BACK_WORST_S)
check(f"{label}, {how}, hung write: the 0 W goes after the settle "
f"wait ({M.SETTLE_BUDGET_S:.0f} s), not after the write's own "
f"budget ({dt:.2f} s)", dt <= M.SETTLE_BUDGET_S + 0.5)
print("3. no new write once shutdown has begun (cycle, heartbeat)")
for label, roster in ROSTERS.items():
c, hass = site(roster, 0.4)
async def go3():
tasks = await in_flight(c)
s = asyncio.ensure_future(c.shutdown())
await asyncio.sleep(0) # shutdown has begun
begun = time.monotonic()
await cycles(c, 3) # would write: grid 3000 W
await c.send_heartbeats(10 ** 9, 10) # every unit is due
await asyncio.wait_for(s, 10)
await asyncio.sleep(0.8)
await finish(tasks)
return begun
begun = asyncio.run(go3())
starts = per_unit(c, hass, "started")
late = {n: [v for t, v in w if t >= begun and v != 0.0]
for n, w in starts.items()}
check(f"{label}: no non-zero write started after shutdown began "
f"({late})", all(not v for v in late.values()))
ok, got = last_is_zero(c, hass)
check(f"{label}: and the last write per unit is 0 W", ok)
# sanity: the same cycles DO write before shutdown
c2, hass2 = site(roster, 0.0)
asyncio.run(cycles(c2, 3))
before = per_unit(c2, hass2, "started")
check(f"{label}: control, same cycles before shutdown, write non-zero "
f"(the test can see a write)",
all(any(v != 0.0 for _t, v in w) for w in before.values()))
print()
if fails:
print(f"FAILED {len(fails)}:")
for f in fails:
print(" -", f)
sys.exit(1)
print("all ok")
# MUTANTS this file was run against (each must turn it red):
# (a) _await_inflight does nothing (no wait for in-flight writes), GoodWe too
# (b) the `elif self.stopping: return` gate removed (cycle not stopped)
# (c) `last=True` not passed by command_site_release (Stop/shutdown 0 W is a
# plain write)
# (d) the wait unbounded (no timeout): red on the settle-bound check
@@ -1103,8 +1103,9 @@ ss = src.splitlines()
def calls_in(name):
"""Line numbers of every `await self.<name>()` in main.py."""
return [i + 1 for i, ln in enumerate(ss) if f"await self.{name}()" in ln]
"""Line numbers of every `await self.<name>(...)` in main.py (SAFETY-137:
the all-GoodWe branch passes `last=True`)."""
return [i + 1 for i, ln in enumerate(ss) if f"await self.{name}(" in ln]
rel, zer = calls_in("command_site_release"), calls_in("command_site_zero")