Skip to content

fix(sungrow): stop reading a battery that is not there#28

Merged
frahlg merged 1 commit into
mainfrom
28-sungrow-string-inverter
Jul 26, 2026
Merged

fix(sungrow): stop reading a battery that is not there#28
frahlg merged 1 commit into
mainfrom
28-sungrow-string-inverter

Conversation

@frahlg

@frahlg frahlg commented Jul 26, 2026

Copy link
Copy Markdown
Member

The SG12RT outage, fixed in the driver that actually caused it.

What happened

Sungrow ships two families behind one driver. SH hybrids answer the 13xxx block — battery, running state, energy counters. SG string inverters have no battery and answer none of it.

This driver read that block regardless, on eight separate reads. Because the host fails a whole poll when any single read fails, a string inverter did not report less telemetry — it reported none. Calle's SG12RT lost everything, with 12 of 19 reads failing on every poll.

Worth noting: the earlier fix for this landed on the catalog's sungrow, which was never the driver running at the customer's site. That one has since been replaced by this one, so the fix belongs here.

The fix

The driver already knew how to tell the families apart. driver_fingerprint classifies the device type code for the host's auto-detection, and driver_init read the same register and threw the answer away after logging it. That classification is now shared — one implementation, not two that drift — and the answer is kept.

Measured on the harness:

Device Failed reads per poll, before After
SG12RT (0x2434), string 8 every poll, forever 0 from the first poll
SH10RT (0x0E0E), hybrid 0 0, battery and all
Device type unreadable 1 every poll, forever probes once, settles to 0

Three things found alongside it

Each was a different way of reporting something that was never read.

Bounded retries. Detection and the serial number now give up after three tries. A register retried on every poll forever costs a failed read on every poll forever — the same outage arriving more slowly. Where detection never succeeds the driver probes once and settles rather than guessing.

No fabricated battery. The stream was emitted unconditionally with zeros when its registers had not answered — a false reading on a hybrid, and an entirely invented battery on a string inverter. Now emitted only when the defining read answered, and each energy counter only when that counter answered: a lifetime counter reporting zero looks like a meter that was reset.

The fault channel could not clear. It cleared only on a non-zero running state, but 0x0000 and 0x0040 are both documented as Running. An inverter that recovered into 0x0000 stayed reported as faulted. It now clears on any state actually read, and a string inverter neither raises nor clears since it never reads it.

optional_read also checks every register it asked for, not just the first. A short Modbus reply left the rest nil, and arithmetic on nil ended the whole poll.

The mechanism working as designed

Editing this driver means it is no longer byte-identical to its FTW baseline, so this repository's checks apply to it again — exactly as intended.

That immediately surfaced something: its PV stream emits mppt1_w, mppt2_w and pv_source, real fields from a production-verified driver that the catalog's allowlist had never heard of. Added, not removed.

Tests

drivers/tests/test_sungrow_model_family.py pins all of it, starting with the incident itself: an SG string inverter must cost zero failed reads. Also covered — that a healthy hybrid loses nothing, that an unreadable device type settles, that the fault channel goes 0,1,0,1,0 across running and faulted states, and that a string inverter never claims anything about faults.

make check: 1981 passed, 80 drivers, sandbox and host-API clean.

🤖 Generated with Claude Code

This is the SG12RT outage, fixed in the driver that actually caused it.

Sungrow ships two families behind one driver. SH hybrids answer the 13xxx
block: battery, running state, energy counters. SG string inverters have no
battery and answer none of it. This driver read that block regardless, on eight
separate reads.

Because the host fails a whole poll when any single read fails, a string
inverter did not report less telemetry -- it reported none. A customer's SG12RT
lost everything, with 12 of 19 reads failing on every poll.

The driver already knew how to tell the families apart: driver_fingerprint
classifies the device type code for the host's auto-detection, and driver_init
read the same register and threw the answer away after logging it. That
classification is now shared -- one implementation, not two that drift -- and
the answer is kept.

Measured on the harness: an SG12RT costs zero failed reads from the first poll,
where it cost eight. A healthy SH10RT is unchanged, battery and all.

Three things found alongside it, each its own way of reporting something that
was never read:

Bounded retries. Detection and the serial number both give up after three
tries. A register retried on every poll forever costs a failed read on every
poll forever, which is the same outage arriving more slowly. Where detection
never succeeds the driver probes once and settles.

No fabricated battery. The stream was emitted unconditionally with zeros when
its registers had not answered -- a false reading on a hybrid, and an entirely
invented battery on a string inverter. Now emitted only when the defining read
answered, and each energy counter only when that counter answered, because a
lifetime counter reporting zero looks like a meter that was reset.

The fault channel cleared only on a non-zero running state, but 0x0000 and
0x0040 are both documented as Running, so an inverter that recovered into
0x0000 stayed reported as faulted. It now clears on any state actually read,
and a string inverter neither raises nor clears since it never reads it.

Editing this driver means it is no longer byte-identical to its FTW baseline,
so this repository's checks apply to it again -- which is the mechanism working
as designed. Its PV stream emits mppt1_w, mppt2_w and pv_source, real fields
from a production-verified driver that the catalog's allowlist had never heard
of. Added rather than removed.

drivers/tests/test_sungrow_model_family.py pins all of it, starting with the
incident: an SG string inverter must cost zero failed reads.

Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
@frahlg
frahlg merged commit 9acc7e5 into main Jul 26, 2026
5 checks passed
@frahlg
frahlg deleted the 28-sungrow-string-inverter branch July 26, 2026 09:08

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8c2d91bdf0

ℹ️ 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".

Comment thread drivers/lua/sungrow.lua
Comment on lines +138 to +139
if (miss_counts[address] or 0) >= MISSES_BEFORE_SKIP then
return nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve hybrid reads after transient outages

When a known hybrid has three consecutive timeouts—for example, during a 15-second network or inverter restart—this branch permanently suppresses that register for the remainder of the driver session. Unlike an actually absent block on an unidentified model, these registers are expected on a detected hybrid, so battery telemetry, fault state, and energy counters never recover even after communication returns; limit permanent skipping to model probing or reset the misses after connectivity recovers.

Useful? React with 👍 / 👎.

Comment thread drivers/lua/sungrow.lua
Comment on lines +149 to +151
if ok and registers ~= nil then
miss_counts[address] = nil
return registers

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Key optional-read failures by register space

On hybrids, address 13049 is read both as a 30-register input block and as a 3-register holding block, but both operations share this address-only counter. If the input read succeeds while the holding read is unsupported, the input success clears the holding miss on every poll, so the holding read fails forever and continues taking every poll offline; track misses by at least (kind, address) so the holding probe can reach the skip threshold independently.

Useful? React with 👍 / 👎.

Comment thread drivers/lua/sungrow.lua
Comment on lines +406 to +407
if hybrid_block_worth_reading() then
local pvgen_regs = optional_read(13002, 2, "input")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Omit unread PV lifetime energy

For every detected SG string inverter, this condition skips register 13002 but leaves pv_gen_wh at zero, which is then emitted as lifetime_wh. The resulting fresh zero falsely looks like a reset lifetime counter—the same fabricated-counter behavior this change avoids for battery and meter energy—so the field should remain absent unless this read succeeds.

Useful? React with 👍 / 👎.

frahlg added a commit that referenced this pull request Jul 26, 2026
…hem (#29)

The driver release failed on both #27 and #28 with

    error: deye: changed artifact needs a higher version than 1.2.2

and it was right to. The channel refuses to publish changed bytes under a
version it has already published; that immutability is what makes rollback
mean anything. Nothing shipped, which is the correct outcome for a release
that would have replaced 19 drivers' contents behind their own version
numbers.

So the 17 remaining replaced drivers move to a major version (pixii and
sungrow already had). Major rather than patch because these are different
implementations, not fixes, and an operator pinning a version needs to know
that.

bump_driver no longer rewrites the DRIVER table of a driver that is
byte-identical to its FTW baseline. Doing so broke the byte-identity of 17
drivers, which cost them their test exemption and failed 13 checks -- and it
spent the provenance guarantee on a field the tooling already accepts as
differing. The manifest counts this repository's releases; the DRIVER table
counts FTW's.

Verified by building the signed publication locally against the currently
published manifest: 80 drivers, 18 new, 19 bumped, zero version collisions.

Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
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.

1 participant