Skip to content

fix(sigenergy): stop reporting a battery on a plant that has none — and write down the rule - #73

Merged
frahlg merged 2 commits into
srcfl:mainfrom
HuggeK:fix-sigenergy-phantom-battery
Aug 5, 2026
Merged

fix(sigenergy): stop reporting a battery on a plant that has none — and write down the rule#73
frahlg merged 2 commits into
srcfl:mainfrom
HuggeK:fix-sigenergy-phantom-battery

Conversation

@HuggeK

@HuggeK HuggeK commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Two commits: one driver fix, and the authoring rule it is the worked example of.

The bug

On a Sigenergy plant commissioned without storage, sigenergy reported 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_read already handled that correctly: nil after 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

local battery = {}
if bat_regs then battery.W = host.decode_i32_be(bat_regs[1], bat_regs[2]) end
if soc_regs then battery.SoC_nom_fract = soc_regs[1] / 1000 end
if bat_regs or soc_regs then
    host.emit("battery", battery)
end

The conditional emit ambibox already uses, with the per-field guards from pixii and ferroamp_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.21.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.md already 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 in drivers/lua/GUIDELINES.md:

  • fill each battery field only from a register that answered; emit the DER only when at least one did
  • detect it — not from the model number (one code covers both configurations), and not by asking the operator to declare it (the site nobody told the driver about is exactly the one that reports wrong)
  • config flags stay useful as an override, not as the mechanism
  • record how the absence actually arrives, because it is vendor-specific: silent registers (Sigenergy), a plain zero, a 0xFFFF / 0x7FFFFFFF / NaN sentinel (sma and solis already carry helpers), or a fault
The catalog screen behind that number — and what it does not claim

24 drivers emit both pv and battery. 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 (ferroamp via skip_battery, zap via disable_battery plus 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 on goodwe, growatt, kostal, huawei and foxess: 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.json and refused-write-baseline.json already ratchet their own debt, that is a natural follow-up and I am happy to open it.

Evidence

  • Device: Sigen PV 50M1-HYA (50 kW three-phase C&I hybrid), firmware V100R001C00, commissioned PV-only with no SigenStack fitted.
  • Register source: Sigenergy Modbus Protocol V2.9 (released 2026-05-13), §5.1.
Why this model reaches these registers at all — the HYA/HYB classification

Table 2-1 files Sigen PV (50…125)M1-HYA under the Hybrid Inv. abbreviation, not PV Inv. — plain Sigen PV 50M1 without the -HYA suffix is the PV 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: pdftotext mis-aligns the vertically-centred abbreviation column in Table 2-1, which makes HYA appear to belong to PV 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.

  • The code path is unconditional: if both probe_read calls return nil, the emit cannot fire. That does not depend on the device.
  • Whether a battery-less M1-HYA returns a Modbus exception or simply never answers on 30014/30037 is not confirmed. Either routes through probe_read to nil, so the fix holds for both — but the distinction is unverified.
  • Once the server is enabled I can post the actual probe_read outcome for both registers.
Local checks — what ran and what could not

Ran (Python-only, PYTHONUTF8=1):

  • pytest drivers/tests -k sigenergy45 passed, 8 skipped (the 8 need ./lua55)
  • pytest tests655 passed, 60 skipped, 1 failed
  • tools/validate_manifest.py → OK, and tools/sync_manifests.py --check81 manifests match their source
  • full suite → 3237 passed, 10 failed

Every failure reproduces identically on a clean tree (git stash): FileNotFoundError for the missing ./lua55, plus one sandbox socket error. None touch sigenergy.

Could not run — no C compiler, make, uv or Lua available, and installing them was out of scope:

  • bash tools/build_luac.sh, so luac55 -p never syntax-checked the Lua locally
  • tools/check_sandbox.sh, make package-driver, and the driver_package / ftw_repository modules (need the package extra)

CI was the first thing to actually compile this.

Deliberately not in this PR

  • ders is unchanged, here and everywhere. It describes what the driver can produce, not what one site has — and it reaches the signed artifact, so removing battery from a hybrid's manifest would cost a version and describe the driver less accurately than before.
  • The control path. 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, as AGENTS.md directs for unwatchable sources.

claude and others added 2 commits August 4, 2026 18:02
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 HuggeK changed the title fix(sigenergy): stop reporting a battery on a plant that has none fix(sigenergy): stop reporting a battery on a plant that has none — and write down the rule Aug 4, 2026
@HuggeK
HuggeK marked this pull request as ready for review August 4, 2026 16:27
@frahlg
frahlg merged commit dbb851d into srcfl:main Aug 5, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants