Compare commits

..
32 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5.5 bbbc89b235 MAINT-20 part 2 review: comment-only fixes in main.py and the MAIN notes
checks / unit (pull_request) Successful in 30m13s
infra-pin / fresh (pull_request) Successful in 1s
- check_peak_floor_limited: restore the SAFETY-25 line (the reserve sentence is
  in messages.peak_reserve() on every branch, unreadable SoC included).
- Three status-page comments that no longer said anything (SAFETY-20 row,
  AC 5 delivery row, banner-vs-text) now say their rule in one line.
- tariff_refresh_wanted docstring: no orphan "TARIFF-24)" line.
- MAIN-orchestration.md: blank line before the first heading.
- MAIN-orchestration-archive.md: refilled intro; says how to find a section
  from a main.py pointer.

No code change: maint20_same_code.py 16029d7 -> SAME CODE, -OO same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 19:25:37 +01:00
glenn schrooyenandClaude f8df7cd860 MAINT-20 part 2 r2: DECISIONS MAIN-orchestration-archive intro
checks / unit (pull_request) Successful in 25m14s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-08 18:06:01 +01:00
glenn schrooyenandClaude f5714248f6 MAINT-20 part 2 r2: app/main.py comments, lines 6001-6185 (amain .. main)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-08 18:06:01 +01:00
glenn schrooyenandClaude 40ef27c367 MAINT-20 part 2 r2: app/main.py comments, lines 5501-6000 (status_payload .. maint_site_tz)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-08 18:06:01 +01:00
glenn schrooyenandClaude b05ad2b273 MAINT-20 part 2 r2: app/main.py comments, lines 5001-5500 (shutdown .. checks)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-08 18:06:01 +01:00
glenn schrooyenandClaude 31d13ee8bb MAINT-20 part 2 r2: app/main.py comments, lines 4501-5000 (check_peak_saturated .. publish)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-08 18:06:00 +01:00
glenn schrooyenandClaude 47e99a37bc MAINT-20 part 2 r2: app/main.py comments, lines 4001-4500 (_shadow_settle .. check_output_clamp)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-08 18:06:00 +01:00
glenn schrooyenandClaude d298f8aa4f MAINT-20 part 2 r2: app/main.py comments, lines 3501-4000 (module level)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-08 18:06:00 +01:00
glenn schrooyenandClaude 391c4b4411 MAINT-20 part 2 r2: app/main.py comments, lines 3001-3500 (shadow .. cycle)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-08 18:06:00 +01:00
glenn schrooyenandClaude 6f94944e44 MAINT-20 part 2 r2: app/main.py comments, lines 2501-3000 (_peak_over_drain .. _read_unit)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-08 18:06:00 +01:00
glenn schrooyenandClaude 56d5532b83 MAINT-20 part 2 r2: app/main.py comments, lines 2001-2500 (_reseed_drain_end .. _fill_peak_gaps)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-08 18:06:00 +01:00
glenn schrooyenandClaude 9ec0a84129 MAINT-20 part 2 r2: app/main.py comments, lines 1501-2000 (pack_floor_soc .. _reseed_charge_end)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-08 18:06:00 +01:00
glenn schrooyenandClaude 6492d179d6 MAINT-20 part 2 r2: app/main.py comments, lines 1001-1500 (_refusal_from_stale .. site_circulating_w)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-08 18:06:00 +01:00
glenn schrooyenandClaude f114a19dcc MAINT-20 part 2 r2: app/main.py comments, lines 501-1000 (_refusal_probe .. _refusal_update)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-08 18:06:00 +01:00
glenn schrooyenandClaude a5fec377ad MAINT-20 part 2 r2: app/main.py comments, lines 1-500 (tariff_refresh_wanted .. __init__)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-08 18:06:00 +01:00
glenn schrooyenandClaude 8fc5f15b03 MAINT-20 part 2 r2: cut the comments that came in with SMA-2, SMA-10, SAFETY-128 and SAFETY-137
Word for word in MAIN-orchestration-archive.md, keyed by the line at 16029d7.
No code change.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-08 17:29:27 +01:00
glenn schrooyenandClaude c2b4302dcf Merge release/1.0 (16029d7) into MAINT-20-2
Nine conflicts in app/main.py, all in comments around new code: every code
line is release/1.0's, every comment follows the part 2 rule.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-08 17:28:03 +01:00
admin 16029d7cb8 Merge pull request 'Release 0.21.0' (#241) from release-0.21.0 into release/1.0
infra-pin / fresh (push) Successful in 2s
checks / unit (pull_request) Successful in 23m41s
infra-pin / fresh (pull_request) Successful in 2s
2026-11-08 10:46:27 +01:00
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
glenn schrooyenandClaude 6cae5a2f8f SMA-10 round 4: pin the Dutch default "de eigen sturing"
checks / unit (pull_request) Successful in 30m41s
infra-pin / fresh (pull_request) Successful in 2s
The NL default for a hand-back driver without words (handback._BACK_NL) was
changed to "de eigen sturing" in round 2 at the PR #239 review's request
(comment 2495: the article as in the generic clause). The brief does not name
it, so DECISIONS now says where it came from and a test checks the exact Dutch
sentence. No app code change.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-07 22:45:08 +01:00
glenn schrooyenandClaude 313503724d SMA-10 round 3: Start clears a failed hand-back streak; the hook is tested
checks / unit (pull_request) Successful in 23m23s
infra-pin / fresh (pull_request) Successful in 2s
When control takes the unit again, release_fails, release_retry_at and
release_zero_sent are reset, so the next Stop is a new streak with its own one
0 W, error line and event. Tests: a streak that Start interrupts without a
recovery; a driver that overrides release_fallback(). _BACK_NL "de eigen
sturing" (round 2) is kept.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-07 21:31:28 +01:00
glenn schrooyenandClaude d26cc573fe SMA-10 round 2: a failed release sends 0 W once, retries at heartbeat pace
checks / unit (pull_request) Successful in 23m37s
infra-pin / fresh (pull_request) Successful in 2s
When release() fails (False, raises or hangs past RELEASE_BUDGET_S) the unit
gets one bounded write(0) through the new Driver.release_fallback() hook
(default write(0)), then nothing more, so the inverter's own fallback timer is
not reset. The release is retried once per heartbeat, not on every idle
write. One error line and one action-log event per failure streak, one line on
recovery. Dutch default "terug naar de eigen sturing". DECISIONS says what a
unit is left at.

test_messages.py: the RELEASE_BUDGET_S data entry is gone again (the line
that needed it is gone).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-07 20:17:01 +01:00
glenn schrooyenandClaude a1be9c48b3 SMA-10: two idle states, hold 0 W or hand the inverter back
checks / unit (pull_request) Successful in 24m9s
infra-pin / fresh (pull_request) Successful in 1s
A driver now says whether its release() hands the inverter back
(Driver.hands_back; GoodWe is False, so release() stays write(0) and the
Controller never calls it). command_site_release() sits next to
command_site_zero(): the Stop button and shutdown() (so SIGTERM, restart,
control task death and a late config refusal) hand a hand-back unit back,
once, and zero every other unit as before. Safety stops and refusals still
hold 0 W for every unit. Shadow mode releases a hand-back unit. After a
release nothing is written to the unit (heartbeat and cycle included) until
control starts again. release() is bounded by RELEASE_BUDGET_S (= the write
budget), runs concurrently per unit, and a raising or hanging release on one
unit does not stop the others.

The "set to 0 W" sentences for Stop, restart, shadow mode and the stopped
banner are built from the roster (app/handback.py, EN and NL, words from the
driver); a GoodWe-only roster keeps every old literal. A handed-back unit
shows "Back under <its control>" on Home.

Existing test edited: test_messages.py (two classification entries for the
CRITICAL line that moved into a roster builder, and the new release bound).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-07 18:21:28 +01:00
admin 57c8d1b912 Merge pull request 'SMA-2: each inverter is reached through a driver; GoodWe behaviour unchanged' (#238) from SMA-2 into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-11-07 14:46:58 +01:00
glenn schrooyenandClaude 2bbf88862a MAINT-20 part 2: main.py keeps the FCST-04 windowing caveat a test pins
checks / unit (pull_request) Successful in 25m56s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-01 04:00:00 +01:00
glenn schrooyenandClaude 3d31b679eb MAINT-20 part 2: main.py: keep whole sentences, no truncated warnings; refresh archive
checks / unit (pull_request) Failing after 24m55s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-01 03:33:54 +01:00
glenn schrooyenandClaude 6889667fcd MAINT-20 part 2: main.py comments and docstrings cut to a line and a pointer; DECISIONS MAIN-orchestration and its archive
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-01 03:32:54 +01:00
glenn schrooyenandClaude 0fb4c2b5e2 MAINT-20 part 2: same-code script checks -OO code, added/removed files, allowed paths, docstring list
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-01 03:27:45 +01:00
18 changed files with 6801 additions and 3865 deletions
+118 -7
View File
@@ -1,12 +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.
- 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
@@ -63,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]]).
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
# MAIN - why app/main.py is built the way it is
**Status: SHIPPED.** Written down by MAINT-20 part 2 from the long comments and
docstrings of `app/main.py`, so the module can keep one line and a pointer. The
comments that were shortened but not rewritten here are kept word for word in
[[MAIN-orchestration-archive]], by their line in `app/main.py` at release/1.0
`d4c01da`. Related: [[SAFETY-07-peak-guard]], [[FCST-04-the-day-that-is-carried]],
[[SAFETY-30-per-unit-sign]], [[SAFETY-73-refusal-per-inverter]],
[[SAFETY-121-bound-every-quarter]], [[TARIFF-10-fetching-is-not-trading]],
[[TARIFF-24-modes-need-the-tariff-task]], [[OPT-34-the-night-charge-keeps-its-level]].
## Controller construction
- **Per-write budget (DEV-02).** Each write is dispatched concurrently and bounded, so one hung `hass.call()` cannot eat a sibling's layer-1 budget (the ESP32 zeroes after about 30 s, and a fault on A could zero a healthy B). The number is derived: `heartbeat_s` max 25 s, layer-1 veto `validate.FIRMWARE_INGEST_VETO_S` 30 s, so 25 + 4 < 30. test_inverter.py grades the inequality, so raising the heartbeat ceiling goes red instead of taking an inverter to zero. Below hass.py's 10 s HTTP timeout on purpose: abandon the write and re-issue it next tick, since `last_write` moves only on an accepted write.
- **Tariff refresh (OPT-04).** `resolve_tariff()` may do an HTTPS fetch. The tick feeds the ESP32 heartbeat, so a supplier's TLS timeout on the tick would push the add-on into layer 1. The tick never awaits a fetch. The 30 min cadence is not about price change (day-ahead publishes once at 14-16 h CET and 8.3's carried hours go firm then); it picks the switch up within the hour at 48 fetches a day. The age bound answers another question: a failed refresh still stores a plan (the 11.4 static fallback), the bound covers the refresh task dying while a plan from hours ago stands as current. Three missed refreshes, for `p1.max_age_s`'s reason.
- **Status rows (TARIFF-11).** The Tariff row used to be `static_status()`'s sentence alone and never saw the provider plan (read 'no static profile' at all 802 samples of a 40 h soak with a live feed). `tick_words` and `plan` are two clocks and the row says so; the plan is stamped with its own age from `tariff_at` (TARIFF-12 does not move it on a kept plan), and `age_s` is the caller's measurement. The Arbitrage row used to be `"off (arbitrage_enabled)" if self.arb is None`, under a comment saying `arb is None` iff trading was off; false, since `cycle()` returns at the input hold before `self.arb = None`: `arbitrage_enabled: true` plus a sensor answering None rendered 'off (arbitrage_enabled)', naming an option that is on, and past the first cycle the previous decision stood as current. Three states: off, armed with no decision this cycle, a decision with its age (always printed: a row that reports staleness only once stale cannot be told from one that never does).
- **`stop` is required (review catch).** `stop=None` made a Controller that cannot stop itself the default: on the late-refusal path in `run_control()` it logged the banner, returned and left the process alive forever with no loop and no output.
- **Action log first.** The store migration can log to it (15: how an operator finds a latch was re-armed); an AttributeError on the way to saying so would stop the add-on booting.
- **Roster and migrations (DEV-02, SAFETY-44, MAINT-05).** Every per-device value lives on the roster objects (app/inverter.py); a `Tuning` and an integrator are deliberately not per device (6.2: one integrator on P1 above the split; a second regulating the same meter is the two-writers failure). The SAFETY-02 key migration runs before the first `Inverter` reads its latch, else a tripped install comes up armed with a green banner. SAFETY-44: it is handed `roster_names(opts)[0]`, never `UNIT_A` (on a declared roster the flat latch landed under `unit/inverter-a/`, read back under the name it was handed, stamping the marker); `roster_names()` returns `[UNIT_A]` on the flat shape so today's path is byte-identical. `misdirected` is unreachable from that call site (`unit` is a roster member by construction; dropping the `roster` argument survives 28/28), so the claim that the call site buys the refusal is gone; the argument stays for new callers and is graded in test_maintenance.py. The maintenance migration targets `self.inverters[0].name`, not `UNIT_A`: with `UNIT_A` the history would sit under a name on no roster (a nameless entry falls back to a positional `inverter-1`). Every two-pack fixture once named its first entry `inverter-a`, so swapping them left 19 files green; `migrate_maint_keys()` now grades the namespace it wrote against the names the machines read, and test_maintenance.py sections 9 and 11 use `["garage", "shed"]`.
- **CFG-04 disclosure.** A declared `inverters:` roster ignores `soc_entity`, `batt_entity`, `batt_invert` and `max_w`, which the UI still shows. Rule 9(e) refuses the dangerous one (a flat `setpoint_entity`); the rest cannot be refused (the schema has no blank value) so one action-log line at every start names the roster and the ignored keys.
- **Site bound.** Summed from the roster, not `opts["max_w"]`: it is the integrator bound and rail of the site loop, then split. Identical at N=1; a per-unit `max_w` binds each share through its `supervised` claim.
- **SAFETY-39 slew fallback** is 0 ("follow the rail"); it was 1000, a stale third copy. `control.slew_limit_w()` is the one reader.
- **CFG-03 in main.py.** Eight `opts.get(key, <literal>)` calls used to live in the maintenance setup and two disagreed with config.yaml (2500 against 2000). `MaintConfig.from_options()` reads them. A ninth key goes in config.yaml `options:`/`schema:` and `maintenance.MAINT_OPTION_DEFAULTS` only.
- **Watchdog thresholds (SAFETY-30).** The per-unit pair is re-derived at two inverters, not carried (it grades a command against one unit's own power). The site pair is used only at N>=2: the 300 W threshold carries (meter noise, deadband), the window does not (110 s at one inverter, 215 s at two, against a 180 s hold).
- **Latches (SAFETY-02, -31, -34, -42, -18).** SAFETY-02: one per actuator, restored from namespaced keys; graded on every start (a latch that failed to move namespace, or was taken by an older add-on in a rollback, is a disarmed guard, so a disagreement re-arms by hand and shouts). SAFETY-31: site-level, flat keys (circulating power is defined over the roster), restored because it acts. SAFETY-42's delivery latch does not act, so it is restored only so the ALARM survives (the 2026-09-02 outage went unseen 6 h 25 m with the detector correct). SAFETY-18 is not restored (does not act). SAFETY-30's site check is armed only at N>=2.
- **PV (TEL-11).** Instrumentation only; wiring it in would be an unrequested behaviour change and would destroy the measurement.
- **Peak guard setup (SAFETY-07, TARIFF-01, FCST-04).** See SAFETY-07-peak-guard.md and FCST-04. The windowing caveat for FCST-04: hours already elapsed leave the spread, so a cheap night that has gone no longer sets the day's cheapest hour.
## Cycle state and latch repair
- **Allocation record (SAFETY-28).** The tick that mints a record increments once per resolution, not per loop iteration. A cycle with no resolution (the "inputs missing, not yet stale" hold) must not bump it: that hold returns before the allocator so one missed poll does not zero the inverter, and the heartbeat re-affirms `self.target` through the write path; bumping would make that heartbeat unauthorised and drop the site to 0 W. The record is superseded, never expired.
- **`self.arb` (TARIFF-11).** See "Status rows" above; `arb_at < cycle_at` is the derived "the latest cycle did not reach the arbitrage step".
- **Shadow (SAFETY-10, SAFETY-47).** `shadow_would_w` is what a live controller would have put on the wire; `self.target` is what is standing on the wire (held at `shadow.WIRE_W`, re-seeded each shadow cycle). Folding them shows an operator a number nothing is doing. The "sized from the billed-quarter meter" flag is recorded where the decision was taken (the page runs on its own task); False, not None, before the first shadow cycle.
- **ARB-03.** `units` is a property derived from the roster so a site cannot claim two actuators while holding one writer. At N=1 (6.2) the grid connection and the only inverter are one actuator; a second entry makes per-inverter claims stop being site claims (ALLOC-01).
- **DEV-02 latch guard (`_check_sign_latch...`).** Runs on every start: gating on `rep.ran` swallowed store.py's rule-2 round trip (upgrade, downgrade with a trip in the window, upgrade; the controller came up untripped while state.json said `sign_tripped: true`; a firmware rollback path was performed on this site on 2026-09-02). The gate could go because `handle_action()` clears the flat keys with the namespaced ones and the trip mirrors them. SAFETY-44 second half: the question is whether a namespaced latch THAT A MACHINE READS is set. An earlier version accepted any `unit/*/sign_tripped`, which blinded the guard on exactly the installs the broken migration had put the latch under `unit/inverter-a/` (a key nothing reads). The first blunt fix (every off-roster latch is litter) re-armed a healthy A off B's mirrored flat key (test_inverter.py DEV-02 restart case). `UNIT_A` is the discriminator: the only name the broken migration wrote. A single flag written for two intents was defeated by one removed unit's key (roster ['garage','shed'] with `unit/old-shed/sign_tripped` drove on a state file reading `sign_tripped: true`; `tools/rehearse_roster.py` exit 1); the fixtures now hold both arrangements. Residual: a first unit really named `inverter-a`, latched and removed from the roster is re-armed on `inverters[0]` (conservative). The printed reason is the flat mirror's (whatever tripped last), so on a multi-unit site one inverter's evidence may appear under another's name; test_maintenance.py's check that the reason is not the removed unit's depends on that fixture's flat string, not on this code.
- **Maintenance views (MAINT-05).** `maints` is one FSM per roster entry; `maint`, `maint_unit` and the button target are views of the one that holds the slot (`maintenance.arbitrate()` allows at most one out of IDLE). validate.py rule 6 grades `maintenance_charge_w` against the weakest cap on the roster.
- **N=1 views.** Properties, not fields, and they refuse at N>=2 (6.2's identity is false there). Nothing inside main.py may read them (test_inverter.py AST scan).
- **`_arm_sign_watchdogs` (SAFETY-30).** Called every cycle because fixtures append an `Inverter` after construction; a detector frozen at construction would be graded against the meter at a roster of two. `arm()` touches no timer or latch.
- **Per-inverter refusal (SAFETY-73).** The probe ask is PROBE_W, not `saturation_w`: a unit's share is often small (a 400 W night load split two ways) and SAFETY-43 would name the empty battery as not delivering. The site target is restarted from the watts on the wire when a unit reopens (phase A: -1059 W).
## Write path and cycle
- **The write gate (SAFETY-28).** specs 1.5 makes two writers on one actuator a non-goal. `len(validate.ACTUATOR_WRITE_KEYS) == 1` is a fact about the options schema, not the writes, and goes false when CFG-04 adds per-inverter config. So the enforcement is on the write: a number reaches the wire only if the single allocator placed it, on this tick, for this unit (`Allocation.authorises()`); a second writer must file a Claim. 0 W is always authorised and the test for zero comes first and reads nothing: the failsafe branches write 0 W while the allocator has refused (`Allocation.ok` False, no shares), and a gate asking its permission would deadlock the failsafe behind the component it answers; at S = 0 broadcast and allocation coincide, so there is no over-command (the hazard is N-times loop gain from broadcast, TEST-19's `N=2 gain 1.0` limit cycle). The predicate is `value == 0.0`, not a `failsafe=True` flag, which a second writer would pass; -0.0 == 0.0 and a nan never gets here. DEV-02: the unit is an argument, not the N=1 view; the default refuses at N>=2. The refusal sits above the clamp and below the non-finite coercion.
- **Clamp (SAFETY-03)** is on the only path to the wire, so no optimizer, maintenance or manual path routes around it. A non-finite value is loss of input (11.1), coerced to 0 W. Every engagement goes to the action log undeduplicated; the notification is deduplicated.
- **Shadow (SAFETY-10)** suppresses in the same method, below the gate and the clamp, as a substitution not a return. It is not a second writer.
- **Reads (DEV-02, TEL-12).** Each inverter's readings are gathered concurrently across units and sequentially within one; PV entities ride in the same gather (a slow sensor must not push a healthy battery toward layer 1's veto). P1 has no inverter-side fallback: the inverter's AC power correlates 0.998 with battery power and 0.09 with the real meter.
- **Stale reading (SAFETY-37/56/57).** The power reading goes through the freshness gate, the SoC does not (the power feeds the sign and delivery checks, which a frozen register fools). SAFETY-57 rewrote the escape clause: "it clears itself when the register reports a different value" was false for the state announced, because the 0 W it announces is what stops the value changing. The release is reported, never silent (it withdraws a SITE STOP).
- **TARIFF-03.** The one shiftable load is the maintenance cycle's cheap-window top-up; the static window is synchronous and I/O-free, and validate.py refuses `static_profile` together with the price entities.
- **Cycle ordering (SAFETY-02/31/42).** The watchdog evaluations run every cycle, before the input hold, so a run of held cycles cannot leave their timers resuming from a start that predates the gap. Reason first, flag second (each `set` writes the file): a crash between persists a reason nothing reads, not a latch with no explanation. The per-unit trip mirrors the legacy flat keys (store.py rule 2 covered only the latch present at migration time). The site check is flat-keyed, armed only at N>=2. SAFETY-31's input is MEASURED power (`inv.batt`), never the allocation record, which has sign-coherence by construction.
- **Maintenance FSMs (MAINT-05).** Each pack is ticked with its own SoC (a minimum would abort a cycle on a sibling's state, an average would declare a pack full that is not); `arbitrate()` runs first. A deferral is carried to the status page because the edge-logged line scrolls out of the 40-row log.
- **Aggregates.** SUM for the site bound, achievable rating and the battery power the site loop regulates against (`compute()`'s saturation test compares against the site target; one unit's power trips the freeze permanently, up to 510 W of split-dependence at gain 0.2, test_alloc.py part 8). MINIMUM for the discharge permission (the emptiest pack). MEASURED power for circulating power. Unknown bounds stay unknown when any unit is silent.
- **Trade sizing (OPT-09).** `trade_sizing` uses arbitrage's own SoC bounds, not the BMS cut-out (SAFETY-25): sizing is the trade's business, the pack envelope is the pack's. Weights are a preference, so a pack between its arbitrage floor and its cut-out can still take part of a sell; fixing it needs a per-pack envelope depending on which claim won the tick.
@@ -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).
@@ -0,0 +1,62 @@
# SMA-10 - two idle states: hold 0 W, or hand the inverter back
**Status: for review.** Tests: `test_sma10_two_idle_states.py`. Source: SMA-9
report sections 1.4, 3.2; owner decision 2026-10-20 (Q1, "as proposed").
A unit is idle in one of two ways, and the driver says which:
`Driver.hands_back`. **False (GoodWe):** the add-on holds 0 W, `release()` is
`write(0)` and the Controller never calls it, so Stop keeps today's 0 W write and
today's 0 W heartbeat. **True:** `release()` gives the unit back to whatever
steers it by itself (SMA energy management), once, and then nothing is written
to it until control starts again.
**Why two.** When the add-on goes away (Stop, shutdown, SIGTERM, restart, task
death, a config refusal) nobody is left to hold a number, so the unit goes back
to its own controller. When the add-on itself decides to stop (sign trip, site
sign, circulating power, a refused or non-finite write, missing readings,
another writer, an inverter fault) it has just said it cannot trust that
battery, and handing it to something else would let that something do what it
likes with it. Those hold 0 W through `write(0)` for every unit, whatever its
driver. Shadow mode on a unit that hands back uses `release()`: it must leave
the unit alone, and 0 W would be a command.
**Where.** `Controller.command_site_release()` sits beside `command_site_zero()`
(unchanged). Stop and `shutdown()` call it; the two maintenance-abort paths keep
zero (the add-on runs on, the sentence says 0 W for a moment). With no
hand-back unit it is `command_site_zero()`. `_write_unit()` also refuses to write
a hand-back unit while the add-on is idle (stopping, stopped, or shadow mode): it
releases once and returns, so the heartbeat and the cycle's own "stopped" 0 W
cannot reach it. A safety stop runs while control runs, so it falls through to
the write. Start re-arms: `released` drops and `last_write` clears.
**Bounds.** `RELEASE_BUDGET_S` equals `WRITE_BUDGET_S` (4 s): units are released
together, so one slow unit costs the kill window no more than a hung GoodWe
write does. A release at the bound is abandoned, not awaited. A release that
raises, hangs or returns false leaves that unit un-handed (the next idle write
tries again) and does not touch the others. Exit 78 before the Controller
exists has no driver to release and keeps today's path.
**Words.** Every sentence that said "set to 0 W" for the add-on going away or
for shadow mode is built from the roster in `app/handback.py` (EN and NL): a
roster with no hand-back unit gets None and keeps its old literal, so a GoodWe
site sees no changed byte. A hand-back unit's words (`Driver.words`: `back`,
`back_nl`) come from its driver; one without them gets "its own control" /
"de eigen sturing" (the article as in the generic clause; PR #239 review
comment 2495, not the brief). MQTT `status` is unchanged; the Home battery
row of a handed-back unit says "Back under SMA control" and carries
`handed_back`.
**Not done here (SMA-4, SMA-5, SMA-11):** any SMA/FoxESS driver, the read-back
after `release()`, per-driver timing, DOCS.md's safety model and the
"control stalled" sentence, the site-level "Held at 0 W" tiles in shadow mode on
a mixed roster, and the MQTT per-unit setpoint of a handed-back unit (it still
publishes 0).
**After a failed release** (returns False, raises or hangs past the bound) the
unit gets one `write(0)` through `Driver.release_fallback()` (default
`write(0)`; a driver where 0 W is not always safe overrides it), then nothing:
a repeated 0 W would keep resetting the inverter's own fallback timer, so after
that the unit is left to its own fallback. The release is retried once per
heartbeat; one error line and one action-log event per failure streak, one line
on recovery. Worst case at shutdown is `RELEASE_BUDGET_S` + `FALLBACK_BUDGET_S`
(6 s).
+24 -3
View File
@@ -77,7 +77,13 @@ class Driver:
writer_key = ""
#: the brand word the per-driver sentences and device names use
brand = ""
#: per-driver sentences (SMA-10 and later). Empty until a caller needs one.
#: SMA-10. Does `release()` give the inverter BACK to something that steers
#: it by itself (SMA energy management)? False: `release()` is `write(0)`,
#: the add-on goes on holding 0 W, and the Controller never calls it.
hands_back = False
#: per-driver sentences. A driver that hands back says whose control the
#: unit returns to: `{"back": "SMA control", "back_nl": "de sturing van SMA"}`
#: ("goes back to SMA control"). Missing words fall back to a plain phrase.
words: dict = {}
#: how long all-rejected writes last before the unit stops being live
link_stale_s = GOODWE_LINK_STALE_S
@@ -145,7 +151,21 @@ class Driver:
raise NotImplementedError
async def release(self) -> bool:
"""Stop steering this inverter. For a driver with nothing better, 0 W."""
"""Stop steering this inverter. For a driver with nothing better, 0 W.
SMA-10. Only a driver with `hands_back = True` has this called by the
Controller, and only when the add-on goes away (Stop, shutdown, restart,
task death) or sits in shadow mode. After it the Controller writes
nothing more to the unit until control starts again. A safety stop is
never a release: that is `write(0)`."""
return await self.write(0.0)
async def release_fallback(self) -> bool:
"""SMA-10 round 2. What to send, ONCE, when `release()` has failed
(False, raised, or hung past the bound), so the unit does not carry its
last command while nothing else steers it. Default: `write(0)`. It is
not repeated: a repeat would keep resetting the inverter's own fallback
timer. A driver for which 0 W is not always a safe hold overrides this."""
return await self.write(0.0)
async def probe(self) -> Probe:
@@ -159,7 +179,8 @@ class GoodWeEsphomeDriver(Driver):
A transcription of what `main.py` and `selfconfig.py` did inline: `limits()`
is `hass.limits(setpoint)`, `read()` is `hass.number(soc)` then
`hass.number(batt, invert)`, `write()` is `hass.set_number(setpoint, w)`.
`release()` is `write(0)`: the board's own watchdog is what hands back.
`release()` is `write(0)` and `hands_back` is False: the board's own watchdog
is what hands back, so after Stop the unit keeps its 0 W write and heartbeat.
"""
name = DEFAULT_DRIVER
+386
View File
@@ -0,0 +1,386 @@
"""SMA-10: the two idle states, in words.
A unit is either HELD at 0 W (GoodWe: the add-on goes on writing 0 W) or
HANDED BACK (its driver has `hands_back = True`: `release()` gives it back to
whatever steers it by itself, and the add-on then writes nothing to it). So every
sentence that said "every inverter is set to 0 W" for the add-on going away or
for shadow mode has to be built from the roster. DECISIONS/SMA-10-two-idle-
states.md.
THE RULE FOR EVERY BUILDER HERE: a roster with no hand-back unit gets None, and
the caller then uses the sentence it always used, untouched. So a GoodWe-only
site cannot see a changed byte; test_sma10_two_idle_states.py renders each one
and compares it with the literal from before this card.
The words for a hand-back unit come from its driver (`Driver.words`), not from
here: `back` is "SMA control" in "goes back to SMA control", `back_nl` is the
Dutch noun phrase after "terug naar". A driver without them gets a plain,
brand-free phrase.
Safety stops and refusals are not in this file on purpose: they hold 0 W for
every unit, and their sentences say so.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
_BACK_EN = "its own control"
_BACK_NL = "de eigen sturing"
@dataclass(frozen=True)
class Unit:
name: str
hands_back: bool = False
back_en: str = _BACK_EN
back_nl: str = _BACK_NL
def unit_of(inv) -> Unit:
"""One roster entry's view: its name, and (for a hand-back driver) the
words of whose control it returns to."""
drv = inv.driver
back = bool(getattr(drv, "hands_back", False))
words = getattr(drv, "words", None) or {}
return Unit(str(inv.name), back,
str(words.get("back") or _BACK_EN),
str(words.get("back_nl") or _BACK_NL))
def units_of(inverters) -> tuple:
return tuple(unit_of(i) for i in inverters)
# The roster of the Controller that is running, for the callers that have no
# Controller in hand (the page, the banner). One process runs one Controller.
_CURRENT: list = [()]
def set_roster(units) -> None:
_CURRENT[0] = tuple(units)
def current() -> tuple:
return _CURRENT[0]
def active(units) -> bool:
"""Is any unit handed back? False is every GoodWe-only roster."""
return any(u.hands_back for u in units)
# --------------------------------------------------------------------------- #
# The grammar. English and Dutch, a few verb forms each.
# --------------------------------------------------------------------------- #
def _join(names, lang) -> str:
names = list(names)
word = " en " if lang == "nl" else " and "
if len(names) <= 1:
return "".join(names)
return ", ".join(names[:-1]) + word + names[-1]
def _forms(lang):
"""kind -> (zero template, back template); `{n}` the names, `{b}` the words.
`one`/`many` pick the verb by how many units the clause is about."""
if lang == "nl":
return {
"set": (("{n} gaat naar 0 W", "{n} gaan naar 0 W"),
("{n} gaat terug naar {b}", "{n} gaan terug naar {b}")),
"at": (("{n} op 0 W",) * 2, ("{n} onder {b}",) * 2),
"is": (("{n} staat op 0 W", "{n} staan op 0 W"),
("{n} staat onder {b}", "{n} staan onder {b}")),
"stays": (("{n} blijft op 0 W", "{n} blijven op 0 W"),
("{n} blijft onder {b}", "{n} blijven onder {b}")),
}
return {
"set": (("{n} is set to 0 W", "{n} are set to 0 W"),
("{n} goes back to {b}", "{n} go back to {b}")),
"at": (("{n} at 0 W",) * 2, ("{n} under {b}",) * 2),
"is": (("{n} is at 0 W", "{n} are at 0 W"),
("{n} is under {b}", "{n} are under {b}")),
"stays": (("{n} stays at 0 W", "{n} stay at 0 W"),
("{n} stays under {b}", "{n} stay under {b}")),
}
def _groups(units, lang):
"""`(zero names, [(names, back words)])`: the held units, and the handed-back
ones grouped by whose control they return to, in roster order."""
zeros = [u.name for u in units if not u.hands_back]
groups: dict = {}
for u in units:
if u.hands_back:
groups.setdefault(u.back_nl if lang == "nl" else u.back_en,
[]).append(u.name)
return zeros, [(names, back) for back, names in groups.items()]
# A clause may not eat the sentence it sits in: STYLE.md allows 25 words, and
# the longest tail after a clause is about 11.
CLAUSE_MAX_WORDS = 14
def _count_txt(n, lang) -> str:
if lang == "nl":
return f"{n} omvormer" if n == 1 else f"{n} omvormers"
return f"{n} inverter" if n == 1 else f"{n} inverters"
def _clause(units, lang, kind, sep, generic, counted) -> str:
forms = _forms(lang)[kind]
zeros, groups = _groups(units, lang)
if generic: # one group, and no make named
names = [n for g, _b in groups for n in g]
own = ("de eigen sturing" if lang == "nl" else
"their own control" if len(names) > 1 else "its own control")
groups = [(names, own)] if names else []
subject = ("de omvormers", "de omvormer") if lang == "nl" else (
"the inverters", "the inverter")
parts = []
if zeros:
parts.append(forms[0][1 if len(zeros) > 1 else 0].format(
n=(str(len(zeros)) if groups else _count_txt(len(zeros), lang))
if counted else _join(zeros, lang)))
for names, back in groups:
if not zeros and len(groups) == 1:
names_txt = subject[0 if len(names) > 1 else 1]
elif counted:
# the bare number after a counted first clause: "1 inverter is set
# to 0 W and 2 go back to their own control"
names_txt = (str(len(names)) if zeros
else _count_txt(len(names), lang))
else:
names_txt = _join(names, lang)
parts.append(forms[1][1 if len(names) > 1 else 0].format(
n=names_txt, b=back))
if sep:
return sep.join(parts)
word = " en " if lang == "nl" else " and "
return (", ".join(parts[:-1]) + word + parts[-1]) if len(parts) > 1 \
else "".join(parts)
def clause(units, lang, kind, sep=None) -> str:
"""One clause about the whole roster, `kind` one of set/at/is/stays.
Held units first, then the handed-back ones, joined by "and" (or `sep`).
When EVERY unit is handed back to the same control the subject is "the
inverters", not their names, as the card's table has it.
A long roster would break the 25-word rule, so a clause over
CLAUSE_MAX_WORDS is said again without naming the make (one "own control"
for all), and then by counting units instead of naming them."""
text = _clause(units, lang, kind, sep, False, False)
if sep or _words(text) <= CLAUSE_MAX_WORDS:
return text
text = _clause(units, lang, kind, sep, True, False)
if _words(text) <= CLAUSE_MAX_WORDS:
return text
return _clause(units, lang, kind, sep, True, True)
def _cap(text) -> str:
return text[:1].upper() + text[1:]
def _bi(en, nl):
from .messages import Bi
return Bi(en, nl)
def _words(text) -> int:
return len([w for w in re.findall(r"\S+", text)
if re.search(r"[A-Za-z0-9]", w)])
# --------------------------------------------------------------------------- #
# The sentences. Each returns None for a roster with no hand-back unit.
# --------------------------------------------------------------------------- #
def stop_dialog(units, lang):
"""Stop control? (was "Every inverter is set to 0 W until you start
control again.")"""
if not active(units):
return None
c = clause(units, lang, "set")
return (f"{_cap(c)} tot u de regeling opnieuw start." if lang == "nl"
else f"{_cap(c)} until you start control again.")
def restart_dialog(units, lang):
"""Restart now? / Save and restart? (live)"""
if not active(units):
return None
c = clause(units, lang, "set")
return (f"{_cap(c)} terwijl de add-on herstart, daarna gelden de nieuwe "
f"instellingen." if lang == "nl" else
f"{_cap(c)} while the add-on restarts, and then the new settings "
f"apply.")
def restart_dialog_shadow(units, lang):
"""Save and restart? when shadow mode keeps the units idle afterwards."""
if not active(units):
return None
c = clause(units, lang, "set")
return (f"{_cap(c)} terwijl de add-on herstart, en de schaduwmodus houdt "
f"het zo." if lang == "nl" else
f"{_cap(c)} while the add-on restarts, and shadow mode keeps it "
f"that way.")
def setup_dialog_shadow(units, lang):
"""Save and restart? in setup, shadow mode on."""
if not active(units):
return None
c = clause(units, lang, "at")
return (f"De add-on herstart met deze instellingen, en de schaduwmodus "
f"houdt {c}." if lang == "nl" else
f"The add-on restarts with these settings, and shadow mode keeps "
f"{c}.")
def control_stopped(units, lang):
"""The page's control line when stopped."""
if not active(units):
return None
c = clause(units, lang, "is")
return (f"De regeling is gestopt. {_cap(c)}." if lang == "nl"
else f"Control is stopped. {_cap(c)}.")
def control_shadow(units, lang):
"""The page's control line in shadow mode."""
if not active(units):
return None
c = clause(units, lang, "stays")
return (f"Schaduwmodus: {c}." if lang == "nl" else f"Shadow mode: {c}.")
def attn_shadow_body(units, lang):
"""The attention card's body in shadow mode."""
if not active(units):
return None
c = clause(units, lang, "stays")
return (f"De add-on beslist alles, maar {c}." if lang == "nl" else
f"The add-on decides everything, but {c}.")
def shadow_state(units, lang, many=True):
"""The tail of a notification: "Shadow mode is on, so <this>." For the
three output-clamp sentences that said every inverter stays at 0 W."""
if not active(units):
return None
return clause(units, lang, "stays")
def banner_stopped(units):
"""Stopped. 12 words at most (STYLE.md)."""
if not active(units):
return None
out = {}
for lang, stopped in (("en", "Stopped"), ("nl", "Gestopt")):
text = f"{stopped} — {clause(units, lang, 'at', sep=', ')}"
if _words(text) > 12:
text = _banner_counts(units, lang, stopped)
out[lang] = text
return _bi(out["en"], out["nl"])
def _banner_counts(units, lang, stopped) -> str:
"""The long-roster form: how many, not which."""
zeros, groups = _groups(units, lang)
if len(groups) == 1:
back = groups[0][1]
n_back = len(groups[0][0])
if lang == "nl":
held = f"{len(zeros)} op 0 W, " if zeros else ""
return f"{stopped} — {held}{n_back} onder {back}"
held = f"{len(zeros)} at 0 W, " if zeros else ""
return f"{stopped} — {held}{n_back} under {back}"
return (f"{stopped} — omvormers op 0 W of teruggegeven" if lang == "nl"
else f"{stopped} — inverters at 0 W or handed back")
def banner_shadow(units):
"""Shadow mode, nothing standing. The GoodWe sentence adds "Inverters stay
at 0 W." which is false for a unit that hands back, and a banner is 12
words, so the unit-by-unit state is left to the control line."""
if not active(units):
return None
return _bi("Shadow mode: the battery is not controlled.",
"Schaduwmodus: de batterij wordt niet geregeld.")
def hint_shadow_on(units):
""""Shadow mode on, inverters stay at 0 W" for the Last event line."""
if not active(units):
return None
return "Shadow mode on, " + clause(units, "en", "at", sep=", ")
def unit_state(unit, lang):
"""One handed-back unit's own state, in place of "Held at 0 W"."""
if lang == "nl":
return f"Terug onder {unit.back_nl}"
return f"Back under {unit.back_en}"
def log_shutdown(units):
"""The INFO line at shutdown (was "shutting down - commanding 0 W on every
inverter")."""
if not active(units):
return None
zeros, groups = _groups(units, "en")
held = f"commanding 0 W on {_join(zeros, 'en')}" if zeros else ""
back = " and ".join(f"handing back {_join(n, 'en')} to {b}"
for n, b in groups)
return "shutting down - " + " and ".join(x for x in (held, back) if x)
def task_died_state(units) -> str:
"""The part of the CRITICAL line after "so" when the control task dies.
For a roster with no hand-back unit it is the sentence it always was."""
if not active(units):
return "every inverter is set to 0 W"
zeros, groups = _groups(units, "en")
backs = [n for names, _b in groups for n in names]
parts = []
if zeros:
parts.append(f"{_join(zeros, 'en')} "
f"{'are' if len(zeros) > 1 else 'is'} set to 0 W")
parts.append(f"{_join(backs, 'en')} "
f"{'are' if len(backs) > 1 else 'is'} handed back")
return " and ".join(parts)
# --------------------------------------------------------------------------- #
# The page words: the ui_text sentences that move with the roster, by language.
# --------------------------------------------------------------------------- #
def overlay(units) -> dict:
"""`{lang: nested dict}` of ui_text paths to replace; {} when no unit is
handed back, so the page is the file as it always was."""
if not active(units):
return {}
out = {}
for lang in ("en", "nl"):
out[lang] = {
"home": {"dialog": {"stop": {
"description": stop_dialog(units, lang)}}},
"settings": {
"dialog": {"description": restart_dialog(units, lang)},
"setup": {
"dialog": {"description":
restart_dialog_shadow(units, lang)},
"dialog_live": {"description":
restart_dialog(units, lang)},
"dialog_setup": {"description":
setup_dialog_shadow(units, lang)}}},
"home_words": {
"attn_shadow_body": attn_shadow_body(units, lang),
"control": {"shadow": control_shadow(units, lang),
"stopped": control_stopped(units, lang)}},
}
return out
+11 -1
View File
@@ -19,7 +19,7 @@ section at the bottom and is held to STYLE.md by test_messages.py.
import contextlib
from datetime import datetime, timedelta, timezone
from . import arbitrage, cheaproll, messages, peakguard, priceruns, uitext
from . import arbitrage, cheaproll, handback, messages, peakguard, priceruns, uitext
from .dayplan import EFF
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
from .maintenance import IDLE as M_IDLE, next_slot_start
@@ -936,6 +936,13 @@ def battery_rows(c, shadow, running, held=False):
else other, at=at,
**plan_words(c, inv.name, why)),
})
if getattr(inv, "released", False):
# SMA-10. A unit handed back says so, in its driver's words, not
# "Held at 0 W": nothing is written to it. The key exists only for
# such a unit, so a GoodWe-only page is unchanged.
rows[-1]["now"] = handback.unit_state(handback.unit_of(inv),
_LANG[0])
rows[-1]["handed_back"] = True
if modes_on:
# OPT-11 P1: the battery's switch and mode, for the Batteries
# screen. Only with battery modes on, so the page is unchanged
@@ -1211,6 +1218,9 @@ _WORDS: dict = {}
def _hw() -> dict:
lang = _LANG[0]
if _WORDS.get("") != uitext.generation(): # SMA-10: the roster's words moved
_WORDS.clear()
_WORDS[""] = uitext.generation()
if lang not in _WORDS:
_WORDS[lang] = uitext.words(lang)
return _WORDS[lang]
+15
View File
@@ -275,6 +275,21 @@ class Inverter:
self.target = 0.0
self.last_write = None
self.last_write_at = None
# SMA-10. True from the moment this unit is handed back (`release()`)
# until control starts again. While it is True nothing is written to the
# unit - not the heartbeat, not the cycle - so the unit's own
# controller keeps what it was given. Only a driver with
# `hands_back = True` ever sets it.
self.released = False
# SMA-10. Writes to a hand-back driver that are on their way (see
# Controller._write_unit), so the release can wait for them and be LAST.
self.inflight: set = set()
# SMA-10 round 2. A failed hand-back: how many tries in a row failed
# (the log is written at the first and on recovery only), when the next
# try is due (monotonic s), and whether the one fallback 0 W was sent.
self.release_fails = 0
self.release_retry_at = 0.0
self.release_zero_sent = False
self.last_write_ok = None
# ⚠️ TWO TIMESTAMPS, NOT ONE FLAG. `last_write_ok` is the last write's
# verdict and it flips on a single transient 400; liveness needs a RUN
+1656 -3837
View File
File diff suppressed because it is too large Load Diff
+30 -3
View File
@@ -12,6 +12,8 @@ import re
from dataclasses import dataclass
from datetime import datetime
from . import handback
# Severity (STYLE.md section 3). An ALARM may use up to three sentences so
# that no action a person must take is lost; everything else gets one.
ALARM, WARNING, INFO, RESOLVED = "alarm", "warning", "info", "resolved"
@@ -228,6 +230,19 @@ class Clamp:
limit: str # a key of LIMIT_WORDS
def _shadow_so(held: str) -> str:
""""Shadow mode is on, so <held>." SMA-10: with a unit handed back, the
roster's own words say where each unit stays; otherwise `held`, as ever."""
moved = handback.shadow_state(handback.current(), "en")
return f"Shadow mode is on, so {moved or held}."
def _shadow_keeps(held: str) -> str:
moved = handback.clause(handback.current(), "en", "at") \
if handback.active(handback.current()) else None
return moved or held
def output_clamp(clamps, *, shadow: bool = False) -> Notice:
"""SAFETY-03: a computed command above the limit. A software fault.
@@ -238,7 +253,7 @@ def output_clamp(clamps, *, shadow: bool = False) -> Notice:
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
state = ((_shadow_so("every inverter stays at 0 W")) if shadow
else "Control is still running.")
return Notice(ID_OUTPUT_CLAMP, ALARM, T_OUTPUT_CLAMP,
f"The add-on asked for more power than {RATING} allows "
@@ -250,11 +265,11 @@ def output_clamp(clamps, *, shadow: bool = False) -> Notice:
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"keeps {_shadow_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
state = (_shadow_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} add-on asked for {watts(c.asked_w)}, above "
@@ -576,6 +591,9 @@ def banner_running() -> str:
def banner_stopped() -> str:
moved = handback.banner_stopped(handback.current())
if moved is not None: # SMA-10: a unit is handed back, not held
return moved
return Bi("Stopped — the inverters are set to 0 W",
"Gestopt — de omvormers staan op 0 W")
@@ -601,6 +619,9 @@ def banner_shadow(latched: bool) -> str:
mode. Each stop has its own red row below, which names it and says how it
clears.
"""
moved = None if latched else handback.banner_shadow(handback.current())
if moved is not None: # SMA-10: a unit is handed back, not held
return moved
if latched:
return Bi("Shadow mode: battery not controlled. A safety stop stays "
"on, see below.",
@@ -1367,6 +1388,8 @@ HINTS = {
"setup_only": "In setup, nothing is sent to any inverter",
"roster": "Inverter list read: {n} inverters",
"maint_history": "Maintenance history did not move over cleanly",
"handed_back": "{unit}: handed back, the add-on writes nothing to it",
"hand_back_failed": "{unit}: could not be handed back, set to 0 W once, trying again",
"stop_restored": "{unit}: an earlier safety stop is back, it stays at 0 W",
"readings_late": "Home Assistant readings arrive late, control holds",
"readings_back": "Home Assistant readings are on time again",
@@ -1470,6 +1493,10 @@ def hint(key: str, **kw) -> str:
kw["w"] = watts(kw["w"])
if "sw" in kw:
kw["sw"] = signed_watts(kw["sw"])
if key == "shadow_on": # SMA-10: a unit handed back is not at 0 W
moved = handback.hint_shadow_on(handback.current())
if moved is not None:
return moved
return HINTS[key].format(**kw)
+29 -2
View File
@@ -13,6 +13,11 @@ import os
DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text")
LANGS = ("en", "nl")
_CACHE: dict = {}
# SMA-10. The sentences that move with the roster (handback.overlay): empty for
# every GoodWe-only roster, and then `raw()` is the file as it always was.
_IDLE: dict = {}
_GEN = [0]
_MERGED: dict = {}
def lang_of(value) -> str:
@@ -21,14 +26,36 @@ def lang_of(value) -> str:
return head if head in LANGS else "en"
def raw(lang: str) -> dict:
"""One language file as written, without the English fallback."""
def _file(lang: str) -> dict:
if lang not in _CACHE:
with io.open(os.path.join(DIR, f"{lang}.json"), encoding="utf-8") as f:
_CACHE[lang] = json.load(f)
return _CACHE[lang]
def set_idle(overlay: dict) -> None:
"""SMA-10: the roster-built sentences, `{lang: nested dict}` (or {})."""
_IDLE.clear()
_IDLE.update(overlay or {})
_MERGED.clear()
_GEN[0] += 1
def generation() -> int:
"""Changes whenever `set_idle()` does, so a cache of words can tell."""
return _GEN[0]
def raw(lang: str) -> dict:
"""One language file as written, without the English fallback (and with the
roster's own sentences laid over it when a unit is handed back)."""
if lang not in _IDLE:
return _file(lang)
if lang not in _MERGED:
_MERGED[lang] = _merge(_file(lang), _IDLE[lang])
return _MERGED[lang]
def _merge(base, over):
if not (isinstance(base, dict) and isinstance(over, dict)):
return copy.deepcopy(over)
+2 -1
View File
@@ -442,7 +442,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
"""Restart the add-on, at the operator's explicit request.
⚠️ THIS IS THE ONE ROUTE ON THIS SURFACE THAT COSTS ANYTHING. The
wind-down commands 0 W on every inverter (`Controller.shutdown()`), so
wind-down commands 0 W on every inverter, or hands a unit back when its
driver does (`Controller.shutdown()`, SMA-10), so
the request must carry the intent token; a reload, a retry or a stray
POST cannot spend a control interruption. It restarts THIS ADD-ON and
nothing else: no setpoint is written from here, and a latched failsafe
+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
+7
View File
@@ -793,6 +793,7 @@ from datetime import datetime as _dt, timedelta as _td, timezone as _tz # noqa:
from app import clamp as _clamp, freshness as _fresh, peakguard as _pg # noqa: E402
from app import store as _store # noqa: E402
from app import handback # noqa: E402
from app.circulation import CirculationConfig, CirculationWatchdog # noqa: E402
from app.control import Tuning, compute # noqa: E402
from app.delivery import (DeliveryConfig, DeliveryWatchdog, UnitDelivery, # noqa: E402
@@ -1058,6 +1059,12 @@ TEXT = {
"main.py:messages.reason_code(inv.batt_fresh.reason)": _coded(_stale_texts),
"main.py:rep.explain()": _migration_texts,
"store.py:rep.explain()": _migration_texts,
# SMA-10: the control-task CRITICAL line's state, for a GoodWe-only roster
# (the sentence it always was) and a mixed one.
"main.py:handback.task_died_state(handback.current())": lambda: [
handback.task_died_state(()),
handback.task_died_state((handback.Unit("inverter-a"),
handback.Unit("inverter-b", True)))],
"main.py:plan.words": _tariff_texts,
# OPT-11 P2: why an MQTT command was ignored.
"main.py:why": lambda: list(messages.PACK_REFUSED.values()),
@@ -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
File diff suppressed because it is too large Load Diff
+75 -10
View File
@@ -5,12 +5,19 @@
For each FILE (default: every goodwe_controller/app/*.py that differs from
BASE_REF, default release/1.0) the ast.dump() of the tree with docstrings
removed must be identical at BASE_REF and in the working tree. Also prints
code lines vs comment-only + docstring lines (whole lines, via tokenize) before
and after. Run from the repository root. Exit 1 on any code difference.
removed must be identical at BASE_REF and in the working tree, and so must
the `python -OO` compiled code (a wholly removed docstring leaves a no-op
there). An added or removed app/*.py file is a code difference. The diff
against BASE_REF may touch only goodwe_controller/app/*.py,
goodwe_controller/DECISIONS/*.md and this script. Also prints code lines vs
comment-only + docstring lines (whole lines, via tokenize) before and after,
and the docstrings removed entirely or added. Run from the repository root.
Exit 1 on any difference.
"""
import ast
import io
import os
import re
import subprocess
import sys
import tokenize
@@ -53,29 +60,87 @@ def counts(src):
return len(code), len(doc) + len(com - code - doc)
def qualnames(src):
out = {}
def walk(node, prefix):
for n in ast.iter_child_nodes(node):
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef,
ast.ClassDef)):
q = prefix + n.name
out[q] = docstring(n) is not None
walk(n, q + ".")
out["<module>"] = docstring(ast.parse(src)) is not None
walk(ast.parse(src), "")
return out
def code_sig(co):
consts = tuple(code_sig(c) if hasattr(c, "co_code") else c
for c in co.co_consts)
return (co.co_name, co.co_code, consts, co.co_names, co.co_varnames,
co.co_freevars, co.co_cellvars, co.co_flags, co.co_argcount,
co.co_stacksize, co.co_exceptiontable)
def oo_same(old, new):
return (code_sig(compile(old, "f", "exec", optimize=2))
== code_sig(compile(new, "f", "exec", optimize=2)))
def git(*a):
return subprocess.run(["git", *a], check=True, capture_output=True,
text=True).stdout
def exists(base, f):
return subprocess.run(["git", "cat-file", "-e", f"{base}:{f}"],
capture_output=True).returncode == 0
ALLOWED = re.compile(r"^goodwe_controller/(app/[^/]+\.py|DECISIONS/[^/]+\.md|"
r"tools/maint20_same_code\.py)$")
def ratio(m, c):
return f"{m / c:.2f}" if c else "n/a"
def main(argv):
base = argv[0] if argv else "release/1.0"
files = argv[1:] or [
f for f in git("diff", "--name-only", base, "--",
"goodwe_controller/app").split()
if f.endswith(".py")]
bad = 0
print(f"base {base} = {git('rev-parse', base).strip()}")
changed = git("diff", "--name-only", base).split()
for f in changed:
if not ALLOWED.match(f):
print(f"FAIL: diff touches {f}, outside app/*.py, DECISIONS/*.md "
"and this script")
bad += 1
files = argv[1:] or [f for f in changed if re.match(
r"^goodwe_controller/app/[^/]+\.py$", f)]
for f in files:
in_base, in_head = exists(base, f), os.path.exists(f)
if in_base != in_head:
print(f"CODE DIFFERS {f} "
f"{'removed' if in_base else 'added'} file")
bad += 1
continue
old = git("show", f"{base}:{f}")
new = open(f, encoding="utf-8").read()
same = dump(old) == dump(new)
oo = oo_same(old, new)
(c0, m0), (c1, m1) = counts(old), counts(new)
print(f"{'SAME CODE' if same else 'CODE DIFFERS'} {f} "
f"code {c0}->{c1} comment+doc {m0}->{m1} "
f"ratio {m0 / c0:.2f}->{m1 / c1:.2f}")
bad += not same
print("OK: no code change" if not bad else f"FAIL: {bad} file(s) differ")
f"ratio {ratio(m0, c0)}->{ratio(m1, c1)} "
f"-OO {'same' if oo else 'DIFFERS'}")
bad += (not same) + (not oo)
qo, qn = qualnames(old), qualnames(new)
for q in sorted(q for q in qo if qo[q] and not qn.get(q, True)):
print(f" docstring REMOVED: {f}:{q}")
for q in sorted(q for q in qn if qn[q] and not qo.get(q, True)):
print(f" docstring ADDED: {f}:{q}")
print("OK: no code change" if not bad else f"FAIL: {bad} problem(s)")
return 1 if bad else 0