fix(sigenergy): stop reporting a battery on a plant that has none — and write down the rule - #73
Merged
Merged
Conversation
The two ESS registers (30014 SOC, 30037 power) are the only ones this driver reads that a Sigenergy plant can lack. Every hybrid model in the range is sold without storage as well as with it, and on such a plant neither register answers. probe_read already handled that correctly -- nil after three tries, then it leaves the register alone -- but the emit below it ignored the result, defaulted both fields to zero and published them anyway. Zero is not "no battery": it is an empty pack that can absorb charge, and it is indistinguishable from a real one that has just run flat. Fill each field only from a register that answered, and emit the DER only when at least one did. Matches the conditional emit ambibox uses and the per-field guards in pixii and ferroamp_modbus. A plant with storage is unaffected. Found on a Sigen PV 50M1-HYA commissioned PV-only. Table 2-1 of the Sigenergy Modbus Protocol V2.9 files that model under "Hybrid Inv.", so every register this driver touches is one the model implements -- only the ESS half is silent. ders is deliberately unchanged: the driver does produce a battery on a plant that has one. The control path is left for a separate change. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> Signed-off-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
The existing rules cover a read that failed. This is the case where nothing failed: the device is healthy, every register answered, and the battery still is not there. Nearly every hybrid inverter is sold both with storage and without it, under one model number and one register map, so a PV-only site is not an edge case -- it is half the product line. The SG12RT already cited at the top of WRITING-A-DRIVER.md is this same fact arriving as an outage rather than as a wrong number. The rule: fill each battery field only from a register that answered, emit the DER only when at least one did, and detect it rather than reading it off the model number or asking the operator to declare it. Records how the absence actually arrives, which is vendor-specific: registers that go silent, answer zero, answer 0xFFFF/NaN, or fault. Measured rather than asserted: 24 drivers emit both pv and battery, and 20 emit the battery DER with no guard on whether a battery register answered. ferroamp and zap gate on config or discovery instead, which is why the rule names the difference between that and detection. The other 19 drivers are not touched -- each is its own register map and its own version, and a nineteen-driver sweep is not reviewable. Signed-off-by: HuggeK <48095810+HuggeK@users.noreply.github.com> Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
HuggeK
marked this pull request as ready for review
August 4, 2026 16:27
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two commits: one driver fix, and the authoring rule it is the worked example of.
The bug
On a Sigenergy plant commissioned without storage,
sigenergyreported a battery sitting at 0% SoC drawing 0 W, on every poll, forever.The two ESS registers — 30014 (SOC) and 30037 (power) — are the only ones this driver reads that a Sigenergy plant can actually lack. Every hybrid model in the range is sold without storage as well as with it, and on such a plant neither register answers.
probe_readalready handled that correctly:nilafter three tries, then it leaves the register alone. The emit below it ignored that result, defaulted both fields to zero, and published them anyway.Zero is not "no battery". It is an empty pack that can absorb charge — so the planner can dispatch against storage that is not installed, and every resulting write lands on a register the plant does not implement. It is also indistinguishable from a real pack that has just run flat, so nothing downstream can separate the two afterwards.
The fix
The conditional emit
ambiboxalready uses, with the per-field guards frompixiiandferroamp_modbus. A plant with storage is unaffected — both registers answer, both fields are set, the emit fires exactly as before. A transient read failure now withholds the field instead of publishing a fabricated zero, which is what the existing never fabricate rule already asked for.1.1.2→1.1.3, patch: same registers, same fields, same scales — they are just no longer invented.The rule (second commit)
The existing guidance covers a read that failed. This is the case where nothing failed: the device is healthy, every register answered, and the battery still is not there.
WRITING-A-DRIVER.mdalready cites the SG12RT that "lost all telemetry for weeks because a driver read a battery block on an inverter with no battery" — that is this same hardware fact arriving as an outage. Nothing said what to do with the answer once you probe correctly and find nothing.Added as A hybrid inverter may have no battery in
docs/WRITING-A-DRIVER.md, and rule 8 indrivers/lua/GUIDELINES.md:0xFFFF/0x7FFFFFFF/ NaN sentinel (smaandsolisalready carry helpers), or a faultThe catalog screen behind that number — and what it does not claim
24 drivers emit both
pvandbattery. 20 of them emit the battery DER with no guard on whether any battery register answered.The screen is lexical: strip Lua comments and string literals, track block nesting, and check whether each
host.emit("battery", …)sits inside any guard. It self-checks by requiring block depth to return to 0 at the end of every file, which it does for all 81 drivers — an unbalanced parse would mean the numbers are meaningless.What it is not is a per-driver verdict. Two of the 20 (
ferroampviaskip_battery,zapviadisable_batteryplus API discovery) gate the emit on configuration or discovery rather than on a read result — better than nothing, still not detection, and precisely the distinction the new rule names. The remaining pattern was spot-checked by hand ongoodwe,growatt,kostal,huaweiandfoxess: fields default to zero, emit is unconditional.The other 19 drivers are deliberately untouched. Each is its own register map, its own hardware and its own version, and a sweep changing nineteen drivers at once is not reviewable. If a maintainer wants this ratcheted the way
absent-register-baseline.jsonandrefused-write-baseline.jsonalready ratchet their own debt, that is a natural follow-up and I am happy to open it.Evidence
V100R001C00, commissioned PV-only with no SigenStack fitted.Why this model reaches these registers at all — the HYA/HYB classification
Table 2-1 files
Sigen PV (50…125)M1-HYAunder theHybrid Inv.abbreviation, notPV Inv.— plainSigen PV 50M1without the-HYAsuffix is thePV Inv.entry. So every register this driver touches is one the model implements, and the PV, grid-sensor and PV-curtailment paths are all live on it.That is what made the phantom visible rather than academic: the driver is otherwise entirely healthy on this hardware, and only the ESS half is silent.
One trap for anyone reading the PDF:
pdftotextmis-aligns the vertically-centred abbreviation column in Table 2-1, which makes HYA appear to belong toPV Inv.. The table has to be read as a rendered page to get it right.What was NOT verified on hardware
No register was read from the device for this PR. The site's Modbus TCP server is not enabled yet — port 502 answers with a TCP RST, so there is no listener to poll. The change is justified from the protocol document and from the driver's own control flow, not from a live capture.
probe_readcalls returnnil, the emit cannot fire. That does not depend on the device.probe_readtonil, so the fix holds for both — but the distinction is unverified.probe_readoutcome for both registers.Local checks — what ran and what could not
Ran (Python-only,
PYTHONUTF8=1):pytest drivers/tests -k sigenergy→ 45 passed, 8 skipped (the 8 need./lua55)pytest tests→ 655 passed, 60 skipped, 1 failedtools/validate_manifest.py→ OK, andtools/sync_manifests.py --check→ 81 manifests match their sourceEvery failure reproduces identically on a clean tree (
git stash):FileNotFoundErrorfor the missing./lua55, plus one sandbox socket error. None touch sigenergy.Could not run — no C compiler,
make,uvor Lua available, and installing them was out of scope:bash tools/build_luac.sh, soluac55 -pnever syntax-checked the Lua locallytools/check_sandbox.sh,make package-driver, and thedriver_package/ftw_repositorymodules (need thepackageextra)CI was the first thing to actually compile this.
Deliberately not in this PR
dersis unchanged, here and everywhere. It describes what the driver can produce, not what one site has — and it reaches the signed artifact, so removingbatteryfrom a hybrid's manifest would cost a version and describe the driver less accurately than before.driver_command("battery", …)still writes 40031/40032/40034 on a plant with no ESS, where 40032 and 40034 are not implemented. Same root cause, but control changes carry their own review and hardware bar here — and this fix is what stops the planner being told there is a battery to dispatch in the first place.upstream_docs. Sigenergy serves the protocol PDF behind a 403 to non-browser clients and the newest revision has no stable public URL, so there is nothing durable to watch. Recorded in the driver comment instead, asAGENTS.mddirects for unwatchable sources.