fix(sungrow): a zero-power battery command needs evidence after all - #43
fix(sungrow): a zero-power battery command needs evidence after all#43frahlg wants to merge 1 commit into
Conversation
|
Flagging for human review rather than merging — this PR now directly contradicts a decision already merged to The conflict: This branch was cut from This PR's whole thesis is the opposite: it adds tests asserting a zero-watt command must be refused ( It's also a real git conflict, not just a logical one — Why I'm not resolving this myself: both sides make a genuine safety-tradeoff argument (this PR: never write mode 2 + a setpoint to an unconfirmed device; #42: never let a stop command be gated, because a device stuck in a forced state is worse). That's a design call about battery-control safety, not something to pick between mechanically. Someone should decide which behavior is correct, then either:
No CI checks have run on this PR ( Generated by Claude Code |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cd1663334b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if driver == FTW_V2: | ||
| call = ''' | ||
| local result = driver_default_mode_v2({reason = "host_shutdown"}) | ||
| print("RELEASED " .. tostring(result.status ~= "rejected")) |
There was a problem hiding this comment.
Require the v2 release to report
defaulted
When the v2 mode write succeeds but its readback fails, driver_default_mode_v2 returns {status = "failed"} while this expression still prints true; because register 13049 was already written to zero, the EMS-mode assertion also passes. The test therefore misses a failed structured result on the safety-release path, so compare the status explicitly with "defaulted".
AGENTS.md reference: AGENTS.md:L17-L18
Useful? React with 👍 / 👎.
|
Scheduled review pass: this PR can't merge as-is, and the reason isn't mechanical.
if power_w ~= 0 and model_family ~= "hybrid" and not battery_confirmed theni.e. a zero-watt battery command is now unconditionally accepted, exempt from the family/battery-confirmation guard, specifically so it can run before the first poll. This PR's new tests ( This branch ( Given this is battery-command safety semantics on a live control-capable driver, I'm not resolving this by picking a side autonomously. Could a maintainer confirm which behavior is intended — #42's "zero always lands" or this PR's "zero refused until a poll confirms the device"? Whichever is correct, the other needs to be reverted or this PR closed as superseded. Generated by Claude Code |
cd16633 to
1b690e4
Compare
1b690e4 to
a327e46
Compare
1.5.4 exempted zero from the guard #40 added, on three grounds. None of them holds. "Zero hands the device back to itself: forced mode off." It does not. set_battery_idle writes 13049 = 2 -- forced mode on -- and pins the battery at 0 W under the host's control. Its own comment two lines above says so: hold at 0 W *without* handing control back. The write that returns an inverter to self-consumption is mode 0, in driver_default_mode. FTW's two tests distinguish them: default mode asserts 13049 == 0, the zero command asserts 13049 == 2. #44 states the same fact from the other side, in the reason it gives for not gating set_self_consumption. "It arrives from the lifecycle rather than the planner." Not on FTW. The only producer of {"action":"battery","power_w":0} is the dispatch loop in go/cmd/ftw/main.go -- planner targets, or the self-tune override. Every path that has to release a device without waiting for a poll goes through Registry.SendDefault to driver_default_mode: shutdown, lease expiry, the telemetry watchdog, the stale-site-meter standdown. That entrypoint is not gated. driver_init writes self-consumption as well, before the run loop exists to accept a command. "On a string inverter the write fails at the Modbus layer and costs nothing." The incident says otherwise. first_write_error returns false if any of the three writes errors, so had the SG12RT rejected them the driver would have stopped there and reported failure. It went on to the read-back and reported success instead -- which is only reachable if the writes were taken. Some firmware accepts a write to a register it does not implement. So refusing withholds nothing that is not reachable by a route which cannot refuse, and the exemption put mode 2 and a setpoint back on an SG inverter that implements neither -- with the read-back that would catch it failing on that same device. Under 1.5.4 a zero command could also put an SG inverter *into* forced mode with no battery command able to take it out again, since non-zero stays refused. Both control paths move together: sungrow 1.5.5 -> 1.5.6, FTW v2 target 1.3.4 -> 1.3.5. Four pytest cases carry the reasoning. Two are load-bearing and both are proven to fail when the behaviour is put back: the release is never gated, on either copy, and driver_init reaches self-consumption on its own. Those are what make refusing safe; gate either and the argument collapses. #44 keeps its conclusion and loses its premise. Not gating set_self_consumption is still right -- an inverter can arrive already holding mode 2, from a container that died mid-force or an installer app, and a hybrid shipped under an unrecognised device-type code lands in the "string" branch too. Its test now seeds that state instead of creating it with a zero command, and covers driver_init as well. Keeps 1.5.4's host_mock fix, which was right and independent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
a327e46 to
65dbf1d
Compare
|
Superseded by #45, which takes this PR's position (a zero-power battery command needs evidence, same as here) combined with #44's startup gate, and lands on top of #44 instead of predating it. Closing this one in favor of #45. Generated by Claude Code |
Takes back #42's exemption. sungrow 1.5.5 → 1.5.6, FTW v2 target 1.3.4 → 1.3.5.
#42 let a zero-power battery command through the guard #40 added, on three grounds. None of them holds.
"Zero hands the device back to itself: forced mode off"
It does not.
set_battery_idlewrites13049 = 2— forced mode on — and pins the battery at 0 W under the host's control. Its own comment, two lines above and untouched by #42, says so: hold the battery at 0W without handing control back to the inverter's autonomous self-consumption mode. The write that returns an inverter to self-consumption is mode 0, indriver_default_mode.FTW's own two tests distinguish them.
TestSungrowDefaultModeReturnsToSelfConsumptionasserts13049 == 0;TestSungrowZeroBatteryCommandForcesIdleasserts13049 == 2. Run against 1.5.4, the driver reports it itself:#44 states the same fact from the other side, in the reason it gives for not gating
set_self_consumption: "since 1.5.4 a zero-watt battery command is accepted on every family and writes EMS mode 2.""It arrives from the lifecycle rather than the planner"
Not on FTW. The only producer of
{"action":"battery","power_w":0}is the dispatch loop ingo/cmd/ftw/main.go— planner targets, or the self-tune override that holds every other driver at 0.Every path that has to release a device without waiting for a poll goes through
Registry.SendDefaulttodriver_default_mode: shutdown (runLoop's stop case), lease expiry (leaseC), the telemetry watchdog, and the stale-site-meter standdown, whosestaleSiteDefaultTrackercovers every registered driver including one added while the gate is closed. That entrypoint is not gated.driver_initwrites self-consumption as well, before the run loop exists to accept a command — and on the control v2 path FTW callsdriver_default_mode_v2itself oncedriver_initreturns.So "a battery with no way to be told to stop" was not reachable.
"On a string inverter the write fails at the Modbus layer and costs nothing"
The incident says otherwise.
first_write_errorreturns false if any of the three writes errors, so had the SG12RT rejected them the driver would have stopped there and reported failure. It went on to the read-back and reported success — which is only reachable if the writes were taken. Some firmware accepts a write to a register it does not implement.What the exemption cost
Mode 2 and a setpoint back on an SG inverter that implements neither, with the verifying read-back failing on that same device, so the host records an applied setpoint and renews the lease on a battery that is not there. That is the SG12RT bug, through the one action nobody thought to guard.
Worse in one respect than before #40: under 1.5.4 a zero command could put an SG inverter into forced mode, and no battery command could take it out again, because non-zero stays refused. Only default mode clears it.
Tests
Four cases, replacing #42's two:
polls=0, the orderRegistry.Addactually produces13049=2,13050=204,13051=0on a confirmed hybrid — the same three FTW asserts, which is the proof that zero takes the inverter over rather than releasing itdriver_initreaches self-consumption on its ownThe last two are load-bearing: they are why refusing is safe. Both are proven to fail when the behaviour is put back — gate
driver_default_modeand both it and #44's boundary test fail; exempt zero again and the first fails.#44 keeps its conclusion and loses its premise
Not gating
set_self_consumptionis still right, for a reason that does not depend on the exemption: an inverter can arrive already holding mode 2 — a container that died mid-force, an installer app, a version that wrote it before restarting — and a hybrid shipped under a device-type codeclassify_device_typehas not been taught lands in the"string"branch too.test_the_release_is_still_reachable_on_a_string_inverternow seeds that state rather than creating it with a zero command, and coversdriver_initas well as the watchdog and cleanup. Theknown_to_have_no_batterycomment and the SG-RT / SG-CX manifest notes are updated to match.Keeps #42's
host_mock.luafix, which was right and independent of the decision.Verified
make check— 3110 passed, 1361 skipped.FTW's entire Go suite passes with 1.5.6 dropped into
drivers/sungrow.lua, includingTestSungrowZeroBatteryCommandForcesIdle. The matching FTW change is already on srcfl/ftw#704's branch: name the device at 4999 and callInitbefore the command, which is the order production always has, plus a paired test that the refusal is deliberate and the release still works on the same unproven device.The pin on srcfl/ftw#704 should move to this commit rather than
12f40d1once this merges.🤖 Generated with Claude Code