- Open HACS in Home Assistant
- Three dots menu -> Custom repositories
- Add
https://github.com/LucMathlin/ha-sunsynk-modbuswith category Integration - Search for "Sunsynk Three-Phase Hybrid" and install
- Restart Home Assistant
- Copy
custom_components/sunsynk_modbus/into your Home Assistantconfig/custom_components/directory - Restart Home Assistant
Settings -> Devices & Services -> Add Integration, search for "Sunsynk", then enter the Modbus TCP host, port and unit ID of your inverter.
Requires Modbus TCP connectivity to the inverter, either via its built-in dongle or an
RS485-to-TCP gateway. pymodbus>=3.11.2 is installed automatically.
Home Assistant custom integration for the Sunsynk SUN-12K-SG04LP3 (Deye-derived low-voltage three-phase hybrid) over Modbus TCP.
Battery modes are implemented as declarative desired state with continuous reconciliation, not fire-and-forget writes. The integration re-reads the registers each mode governs on every slow poll, diffs actual against desired, and writes only the deltas. Settings changed on the inverter LCD or pushed by the SolarMAN cloud app are corrected automatically instead of silently diverging.
Once built, Predbat drives it. The integration exposes every sensor
Predbat's Sunsynk type (inverter_type: SK) needs natively, so no template
sensors are required.
Build status: stage 2 of 4 complete. The integration loads in Home Assistant and provides the full read-only sensor set. The mode engine, reconciler and control platforms arrive in stages 3 and 4. No code path writes to the inverter yet. See Build stages.
Three controls. Open the device page, pick a mode, and if it is a force mode type a wattage. Everything else — peak shaving, slot writes, write ordering, reconciliation — is machinery you should never have to see.
| Control | What it does |
|---|---|
select.sunsynk_mode |
Auto, Hold, Force charge, Force discharge |
number.sunsynk_force_charge_power |
watts, used by Force charge |
number.sunsynk_force_discharge_power |
watts, used by Force discharge |
These three carry no entity category, so they are the only things on the device
page's main controls section. Everything else is CONFIG or DIAGNOSTIC and stays
out of the way, including off auto-generated dashboards.
| Category | Entities |
|---|---|
CONFIG |
work mode, prog1–6 charge options, battery max charge/discharge current, slot SOC, auto export power |
DIAGNOSTIC |
inverter enabled (register 80), Predbat enabled, reconciler status and coupling reports on the mode select, clock skew, dry-run state |
Paste this into a dashboard. The wattage box only appears for the mode it belongs to, so selecting Force charge surfaces one input and nothing else.
type: vertical-stack
cards:
- type: entities
title: Sunsynk
entities:
- entity: select.sunsynk_mode
name: Mode
- type: conditional
conditions:
- condition: state
entity: select.sunsynk_mode
state: Force charge
card:
type: entities
entities:
- entity: number.sunsynk_force_charge_power
name: Charge power
- type: conditional
conditions:
- condition: state
entity: select.sunsynk_mode
state: Force discharge
card:
type: entities
entities:
- entity: number.sunsynk_force_discharge_power
name: Discharge power
- type: glance
title: Now
columns: 3
entities:
- entity: sensor.sunsynk_battery_soc
name: Battery
- entity: sensor.sunsynk_battery_power
name: Battery power
- entity: sensor.sunsynk_total_solar
name: Solar
- entity: sensor.sunsynk_load_power
name: Load
- entity: sensor.sunsynk_grid_ct_power
name: Grid
- entity: sensor.sunsynk_inverter_power
name: Inverter
- type: entities
title: Today
entities:
- entity: sensor.sunsynk_day_pv_energy
name: PV
- entity: sensor.sunsynk_day_battery_charge
name: Battery charged
- entity: sensor.sunsynk_day_battery_discharge
name: Battery discharged
- entity: sensor.sunsynk_day_grid_import
name: Imported
- entity: sensor.sunsynk_day_grid_export
name: Exported
- entity: sensor.sunsynk_day_load_energy
name: LoadEntity IDs assume the device is named Sunsynk; adjust the prefix if you renamed
it.
Why conditional visibility rather than making the entities unavailable. Marking
an entity unavailable conventionally means "cannot reach the device", so using it
for "not applicable" makes the entity look broken, breaks history continuity — you
would lose the record of the wattage you last used — and produces recorder
warnings. The card gives an identical result without any of that. If you would
rather have it enforced at entity level so it holds on every dashboard including
auto-generated ones, that is a small change to the two number entities; say so and
it can be swapped.
Nothing to template. Configure it with:
| Slot | Entity |
|---|---|
| Grid consumption | sensor.sunsynk_total_grid_import |
| Return to grid | sensor.sunsynk_total_grid_export |
| Solar production | sensor.sunsynk_total_pv_energy |
| Battery in | sensor.sunsynk_total_battery_charge |
| Battery out | sensor.sunsynk_total_battery_discharge |
Battery power is a single signed sensor (negative = charging, verified from the
daily counters), with battery_charge_power, battery_discharge_power,
grid_import_power and grid_export_power provided as unsigned directional
sensors for anything that needs them split.
apps.yaml.new in the repository root is a rebuilt Predbat config for this
integration. It is a document, not a deployment — nothing has been connected
or tested against Predbat.
Predbat only has control while the mode select is Auto. Any other mode
overrides it — Hold and the Force modes take ownership of the same registers — and
the override lasts until the auto-revert timeout returns to Auto, or you do.
switch.sunsynk_predbat_enabled reads off whenever an override is active.
Predbat has never been able to export on this system. discharge_start_service
was byte-identical to charge_stop_service and charge_freeze_service: it
disabled grid charge, enabled grid peak shaving and turned the timer off.
Since peak shaving suppresses TOU dispatch, the discharge path was a no-op. Not
misconfigured at the margins — it could not work.
Whoever wrote it had already found the peak-shaving problem empirically.
charge_start_service disables peak shaving before enabling grid charge, which is
exactly the finding this project arrived at from the register side without knowing
the config existed. Independent corroboration.
TOU was disabled unconditionally. switch.sunsynk_12kw_use_timer is turned off
in all four services, consistent with register 146 having its day bits clear.
pv_power pointed only at the AUX-coupled inverter. The Sunsynk's own PV was
omitted entirely, so Predbat has been planning against a fraction of real
generation. Now sensor.sunsynk_total_solar, which is PV1 + PV2 + AUX.
There was no charge_limit at all, so Predbat could not set an SOC target
either. Added.
| Key | Old | New |
|---|---|---|
| the four services | three uncoordinated entities | the work-mode select, which owns 178 bits 4–5 + slot power with ordering guarantees |
load_today, pv_today, battery_voltage, load_power |
sunsynk_12kw_* |
our equivalents |
battery_power |
a _corrected template |
our signed sensor, battery_power_invert: false |
pv_power |
AUX only | total_solar |
soc_kw |
sunsynk_battery_soc_kwh |
soc_percent from register 588 |
grid_power |
external meter | our CT, with grid_power_invert: true |
inverter_limit_charge/discharge |
hardcoded 11000 |
our native sensors |
timed_charge/discharge_current |
sunsynk_12kw_* |
our 108/109 numbers |
battery_min_soc |
a number entity | our sensor (register 115 is read-only here) |
charge_limit |
absent | number.sunsynk_set_soc_timezone1 |
export_limit |
absent | 15000, matching register 143 |
Three physically separate sources feed this site, with no overlap:
| Source | Sensor | Register |
|---|---|---|
| Sunsynk DC MPPTs (PV1/PV2) | today_pv_energy |
529 |
| Solis 8 kW, AC-coupled on the AUX port | today_aux_port_energy |
536 |
| Separate 500 W array | trailer_solar_pv_today_s_consumption |
n/a, its own integration |
inverter_limit: 20000 is the Sunsynk's 12 kW plus the Solis 8 kW, which is why it
sits above the 15 kW export ceiling in register 143 and why export_limit: 15000 is
set.
An early revision of this config omitted the AUX energy counter from pv_today,
which under-read generation by roughly 13.5 kWh/day and left pv_today inconsistent
with pv_power — the latter uses total_solar, which is PV1 + PV2 + AUX. Both now
include AUX.
If the Solis integration is ever restored, do not add its energy sensors to
pv_today. The whole Solis integration is currently unavailable in Home
Assistant, so register 536 is the only working measurement of that inverter's
output. Should it come back, solis_8kw_inverter_energy_today,
solis_generated_today and similar measure the same generation as register 536 and
would double-count. The choice is one or the other, not both — and register 536 has
the advantage of being measured at the point the Sunsynk actually sees it.
Grid sensors: split them. Keep the external meter for import_today and
export_today — cumulative, unsigned, and what you are billed on, so no sign risk
and better accuracy for totals. Move grid_power to our CT, because sign is the
thing that breaks planning and ours is the only convention we have proven
(negative = export, established from counter movement). That requires
grid_power_invert: true.
If you would rather keep the meter for grid_power too, determine its sign first:
watch sensor.db_1_active_power during a known export event — a Force discharge at
2 kW will do it — and set grid_power_invert: true if it reads negative while
exporting, false if positive. Do not guess; an inverted grid sign makes Predbat
plan backwards while every sensor still looks plausible.
export_limit: 15000 — yes, set it. Register 143 caps export at 15000 W while
inverter_limit is 20000. Without it Predbat plans export the inverter will clip
and over-forecasts export revenue.
soc_max: 64 — leave it. Your real bank figure, and independent confirmation
that register 102's 1256 Ah (~67 kWh at 53.5 V) is roughly 5 % optimistic. Another
reason soc_percent is preferable to soc_kw.
inverter_clock_skew_* — leave at zero. The integration syncs the clock at
setup and daily. If skew reappears the sync has failed; fix that rather than
compensating here.
battery_rate_max: 11000 — flagged, not changed. The inverter is rated
12000 W and the new rate sensors report 12000. It is used for charge-curve
detection, so 12000 may be more accurate. Your call.
The integration's default max grid import would throttle Predbat's grid
charging to 100 W. Auto sets register 191 from number.sunsynk_max_grid_import,
default 100 W for self-consumption. Peak shaving holds import at that value, so a
Predbat charge slot would be capped at 100 W.
charge_start_service therefore raises it to 15000 first and charge_stop_service
restores it to 100. That works within the design — Predbat sets entity state, the
reconciler enforces it — but it does mean the import cap is effectively mode state
when Predbat is driving. Worth revisiting if it proves awkward.
The old config failed silently for months. Confirm Predbat is actually driving the inverter rather than repeating that:
- Delete two obsolete template sensors before installing.
sensor.sunsynk_battery_capacity_shutdowncollides with an entity this integration generates — if the template still exists, ours becomes..._shutdown_2and thebattery_min_socline silently points at the old template.sensor.sunsynk_battery_soc_kwhdoes not collide but is retired by the move tosoc_percent. Both are replaced natively. - Check every
# VERIFYline against the entity registry. Entity IDs are deterministic — the config flow has no name field, so the device name is alwaysSunsynkand IDs are<domain>.sunsynk_<entity name slug>. They only differ if you rename the device in the UI afterwards (HA offers to rewrite IDs) or if something already owns the ID. - Confirm the mode select is in Auto. Nothing Predbat does will stick otherwise.
- Watch a charge slot.
number.sunsynk_max_grid_importshould go to 15000,select.sunsynk_work_modeto Limited to Home, the prog selects to "Allow Grid & Gen", and grid import should actually rise. If import sits near 100 W the cap did not lift. - Watch an export slot — this is the one that never worked.
select.sunsynk_work_modeshould read Selling First, andsensor.sunsynk_grid_ct_powershould go clearly negative. If the battery does not discharge, peak shaving did not release. - Check the reconciler is not fighting Predbat.
select.sunsynk_modeattributes carryreconcile_statusandpending_writes. In Auto, repeated non-emptypending_writesfor 108, 109 or 166 means the two are arguing. - Check for register couplings. The mode select's
register_couplingsattribute lists anything the firmware moved that we did not write. - Sanity-check
pv_power. It should now match total generation, not a fraction of it. Compare against the sum of PV1, PV2 and AUX.
These need a running Home Assistant and time, so they are not done:
- Drive the four modes from the device page. The modes have been run live against the inverter through the reconciler, but never through the entity layer or on the slow-poll timer.
- Overnight soak in Auto. Also the only way to confirm load-following discharge: every attempt to test it was defeated by generation exceeding load.
- Fire the reversion timeout for real. Unit-tested only; the timer uses Home Assistant's scheduler.
The integration imports cleanly against Home Assistant 2024.12.5 (all twelve modules), but has never been loaded as an integration.
Everything below this point is the register work: what was measured, what contradicted the documentation, and why the design is shaped the way it is. None of it is needed to use the integration.
| Model | SUN-12K-SG04LP3 |
| Serial | 2408072495 |
| Device type (register 0) | 5 — low-voltage three-phase hybrid |
| Rated power (registers 20, 21) | 12000 W |
| Protocol (register 2) | 260 |
| Endpoint | 10.20.0.71:502, unit ID 1 |
| Framing | standard Modbus TCP (MBAP). RTU-over-TCP not needed |
| Verified | 2026-08-15 |
Battery type is 1 (lithium, BMS-managed). The AUX/generator port is configured as a micro-inverter (register 133 = 2), so the site has AC-coupled PV in addition to the DC MPPTs.
Every finding below was measured on the live inverter, read-only, using
function code 0x03. Scripts are in scripts/.
32-bit word order is low word first. Rated power read reg20=0xD4C0,
reg21=0x0001 → 0x0001D4C0 = 120000 × 0.1 = 12000 W exactly. Reversing
the words gives 356 MW. Cross-checked against all six lifetime energy counters,
which decode to self-consistent values (PV 5696.7 kWh, battery charge
3040.6 kWh, discharge 2797.2 kWh, import 2894.0 kWh, export 3544.2 kWh, load
4785.2 kWh) low-word-first and to absurd values reversed.
Register 80 polarity: 1 = running. Resolved without writing. Register 80 reads 1 while the inverter is demonstrably running: overall state (500) = 2 "Normal", relay status (552) = 0x0D with the inverter and grid relays closed, 1489 W flowing through the AUX port and the BACKUP port energised at 240 V on all three phases. The vendor blog claiming the inverse is wrong, as the register file suspected. The switch can ship enabled.
Only 2 MPPTs are populated. PV3/PV4 power, voltage and current (674, 675, 680–683) all read zero. They are not exposed. PV1 also reads 0 W at 13.1–13.7 V, so only PV2 has a live string on this install — worth confirming that is expected rather than a dead string.
The 671–673 register conflict resolves in favour of the PV block. Register 671 reads 0 and 672 reads 0 while 673 reads 630 W at 251.5 V × 2.4 A = 604 W. The MPPT interpretation of 672–683 is correct; generator currents are not there. Generator/AUX power comes from 664–667, which read 494/496/499 W summing to the 1489 W reported by 667.
Function code 0x10 for all writes. Implemented; 0x06 is never emitted, even for single-register writes.
1. Temperatures need a −100 offset that the register file omits. The register file gives scale 0.1 with no offset. Applied literally, the inverter reports a 127.4 °C battery and a 151 °C radiator. With the −100 offset the same raw values give 27.4 °C battery, 51.1 °C radiator, 25.0 °C DC transformer, all stable across repeated samples and physically plausible. Implemented with the offset (registers 586, 540, 541, 217). This matches how kellerza/sunsynk decodes Deye temperatures.
2. The 32-bit mirror registers at 687–708 are not implemented on this firmware. Brief §5 instructs using them in preference to the 16-bit forms. They are dead:
| Register | Reads | 16-bit equivalent |
|---|---|---|
| 687–690 (grid power) | 0x0000 |
622–625, correct |
| 691–695 (inverter power) | 0xFFFF |
633–636, correct |
| 696–699 (UPS load) | 0x0000 |
640–643, correct |
| 700–708 (grid inner, CT) | 0x0000, one 0xFFFF |
604–608, 616–619, correct |
| 656–659 (load power mirror) | 0x0000 |
650–653, correct |
0xFFFF/0x0000 regardless of actual power is the classic unimplemented-register
signature. Following the brief literally produces an integration whose power
sensors all read zero.
The 16-bit registers are correct and self-consistent — verified by phase sums (load 317+69+25 = 411 = register 653; inverter −177−428−474 = −1079 = register 636; AUX 494+496+499 = 1489 = register 667) and by V×A cross-checks.
The overflow concern behind the brief's instruction does not apply here: the 16-bit signed range is ±32767 W and this is a 12 kW inverter, so no register in the set can wrap. Per-phase power peaks near 4 kW, total near 12 kW.
Implemented using the 16-bit registers. Flagging rather than burying it, since it is a direct departure from the brief.
3. BMS current (register 216) is scaled 1 A, not 0.01 A. The register file says 0.01. Sampled against the battery's own current register over six polls:
| Register 216 | Battery current (591, scale 0.01) |
|---|---|
| 21 | −22.39 A |
| 21 | −21.35 A |
| 20 | −21.39 A |
| 20 | −20.29 A |
| 20 | −20.40 A |
| 19 | −19.88 A |
Scale 0.01 would report 0.21 A against a real 22 A. Implemented as scale 1. Note register 216 does not appear to carry sign — it stayed positive throughout a discharge. Registers 212/213 (560 A) and 218/219 (580 A) are plausible only as whole amps too.
4. "Negative scale" means signed, not sign-inverted. Brief §5 writes battery current as "591, ×-0.01 signed", which could be read as multiplying by −0.01. The register file's own convention says a negative scale marks a signed register and the magnitude applies. Hardware agrees with the register file: 53.50 V × −11.68 A = −625 W against register 590 reading −599 W. Inverting the sign would make a discharging battery read positive while its power register reads negative. Implemented as signed, scale magnitude, no inversion — so discharge is negative for both current and power.
5. The time-of-use schedule is inert. Resolved: the owner does not use the inverter's schedule, so the day bits were never set. The integration now takes full ownership of the TOU block — see Time-of-use ownership.
The original schedule, as found:
| Slot | Start | Power | SOC | Flags | Source | Mode |
|---|---|---|---|---|---|---|
| 1 | 19:00 | 12000 W | 100 % | 0x0001 |
grid | none |
| 2 | 07:00 | 15000 W | 20 % | 0x0000 |
none | none |
| 3–6 | 19:00 | 12000 W | 20 % | 0x0000 |
none | none |
Register 146 read 0x0001: master enable set, all seven day bits clear. Slots
3–6 all shared slot 1's 19:00 start, so "the slot covering the current
wall-clock time" was ambiguous for the whole evening window. Both problems
disappear under the ownership model below.
One note on how this was diagnosed, recorded because the reasoning was weaker than it looked. The original evidence was that slot 1 asks for a grid charge to 100 % from 19:00 while the day's import was only 0.2 kWh. That does not actually prove the slot never fired: the reading was taken around midday, so 19:00 had not yet come round. The conclusion was right for a different reason — the day bits were never set. Time-dependent behaviour needs evidence gathered inside the relevant window.
6. Export was misconfigured, not constrained. Load limit (142) = 2 "Zero export" with solar export (145) = 0. The site has DNO permission for full export, so this is a wrong setting rather than a limit to design around. Its effect was not confined to battery discharge: with zero export active the inverter also throttled surplus PV. On the day of testing, 11.5 kWh generated by midday against 0.0 kWh exported, while lifetime export stands at 3544 kWh. Correcting 142 recovers curtailed generation on any day the battery fills, independent of the mode engine. The integration now corrects 142 once at setup; 145 follows on its own (see finding 9).
7. Battery sign convention: negative = CHARGING. This corrects an error in the stage 1 report, which had it backwards. The daily counters settle it, because they are unambiguous in a way a power balance is not:
| Time | SOC | 514 day charge | 515 day discharge | 590 battery power |
|---|---|---|---|---|
| 11:22 | 93 % | 5.9 kWh | 4.9 kWh | −1573 W |
| 12:10 | 94 % | 6.4 kWh | 4.9 kWh | −1230 W |
| 13:00 | 95 % | 7.1 kWh | 4.9 kWh | −1841 W |
The charge counter rose by 1.2 kWh and SOC by 2 points while the discharge counter did not move at all, with register 590 negative at every sample. The battery was charging throughout.
Do not try to establish this from a power balance. With an AC-coupled source on the AUX port there are two unknowns — the battery direction and the AUX direction — and both sign conventions can be made to balance to within ~100 W. An earlier version of the probe script compared the two residuals and would have picked the wrong answer.
8. AUX port sign convention: positive = generation. This follows once the
battery direction is known. With the battery absorbing 1535 W and load at
607 W against only 695 W of DC PV, the missing ~1.4 kW can only be arriving from
the AUX port. Consistent with register 133 = 2 and with day_aux_energy rising.
9. Registers 142 and 145 move together. Writing 142 = 0 and nothing else, register 145 (Solar Sell) moved from 0 to 1 on its own. The firmware couples them.
What this establishes: writing 142 is sufficient to enable export, and the integration must not write 145 at all. What it does not establish is that 142 alone is the gate — 145 changed at the same moment, so the two cannot be separated by this test. Either could be the mechanism.
Either way the conclusion for the write path is the same, and stronger for being narrower: 145 is removed from the allowlist. The firmware manages it, and a reconciler that formed an opinion about it would be second-guessing the inverter.
This coupling is also why the reconciler now diffs the entire settings image around every write rather than just the registers it targeted — see Catching register couplings. Assume more exist.
10. AC-coupling configuration, reported as requested.
| Register | Raw | In plain words |
|---|---|---|
| 133 Generator port usage | 2 | The AUX terminal carries a micro-inverter, not a generator |
| 131 Gen AC couple frequency high | 5500 = 55.00 Hz | The frequency the inverter shifts to in order to curtail the AUX inverter |
| 178 bits 0–1 | 2 = "Disable" | The microinverter export-to-grid cutoff is switched off, so AUX generation is permitted to reach the grid. |
The register-dump wording for 178 reads backwards, which is why it is spelled out above: the cutoff is disabled, therefore export from the AUX unit is allowed.
Register 131 at 55.00 Hz looks wrong for a UK G99 installation. A coupled inverter on a G99 connection would be expected to trip on over-frequency at around 52 Hz, with LFSM-O power reduction beginning near 50.4 Hz. A curtailment threshold set above the trip point cannot curtail: the AUX unit disconnects first, then sits out an anti-islanding reconnect delay, and may cycle. To be reviewed against the AUX inverter's frequency-watt curve and set by hand. It stays out of the allowlist.
This is the direct reason Hold no longer zeroes the charge limit — see the Hold safety question.
11. Registers 108 and 109 accept a written 0. They do not clamp.
Verified directly: 108..109 = [0, 0] was written, read back as [0, 0], and
battery power fell to between −2 and −6 W — idle, from 617 W of charging. Hold
genuinely holds. If they had clamped, Hold would have silently not held, which
is why this was worth confirming explicitly rather than inferring from behaviour.
The whole-image diff across both the zeroing write and the restore to 240/240 found no couplings. Unlike 142, these two registers have no side effects.
12. Grid CT sign: negative = EXPORT, positive = import.
Resolved from counter movement, as required — not from a power balance. With Hold at 0 there was no battery sink, so the surplus had to go somewhere:
| Day export counter (521) | rose 0.1 kWh |
| Day import counter (520) | static |
| Mean CT over the window | −546 W |
So a negative CT reading means power flowing out to the grid. Predbat expects
grid_power negative for import and positive for export, which is the opposite,
therefore grid_power_invert: true.
This is the second sign convention that would have made Predbat plan backwards while every sensor still looked plausible. The battery one matched; this one does not.
13a. Grid peak shaving is enabled with a 100 W threshold.
Later correction. This finding originally claimed peak shaving was "the self-consumption mechanism". That was inference and it was wrong — see finding 19. Peak shaving gates dispatch, but what makes the battery serve house load is the slot power register granting permission to discharge.
| Register | Value | Meaning |
|---|---|---|
| 178 bits 4–5 | 0b11 |
Grid peak-shaving enabled |
| 191 | 100 W → raised to 15000 W | Peak shaving threshold |
Peak shaving holds grid import below the threshold by discharging the battery. That fits every measurement taken across this whole investigation: the grid CT has never exceeded roughly 94 W of import in any test, in any mode, and sat between 3 and 68 W throughout the three-phase dispatch test.
Why it was set that way: 191 = 100 W and 142 = Zero Export are one decision, not two settings. This inverter was commissioned to be islanded from the grid in both directions. The export half has been reversed (finding 6); 191 is the import half.
An untested hypothesis, held loosely. Grid charging has never happened despite register 130 (grid charge enabled) = 1 and register 127 (grid charge start SOC) = 30 %, with the battery regularly below that, and day import has been 0.2 kWh. A 100 W import cap would explain it.
But that is a mechanism that fits, asserted without a test — the same shape of reasoning as the day-bits hypothesis, which turned out to be wrong. Registers 127 and 130 may equally be constraint-only, exactly like the TOU slots. Both possibilities produce zero import, so the observation does not distinguish them. It needs a test, not an argument.
What is not in doubt is that a 100 W import cap and a request to charge from grid at kilowatt scale cannot both hold, so it has to be raised before Force charge can be tested at all.
This would also fight Predbat, which plans grid import at cheap rates. A firmware-enforced import cap would override that silently, and Predbat would see its plan simply not happen.
Decision: raise the threshold, keep the feature. 191 is set to 15000 W to match
the export ceiling in register 143. Register 178 bits 4–5 stay at 0b11 (enabled)
and 178 stays out of the allowlist. Same effect on the cap, one register instead of
two, reversible, and it leaves a real peak-shaving threshold available later — which
matters with two inverters behind a 15 kW combined limit.
13b. Force discharge via the TOU block does not work on this unit.
Tested twice, and the registers are not the problem:
| Test | 146 | Result |
|---|---|---|
| 1 | 0x0001 (day bits clear) |
No discharge across 15 samples over 7 minutes |
| 2 | 0x00FF (all day bits set) |
No discharge across 12 samples over 6 minutes |
In both cases the writes landed and read back correctly — slot power [2000] × 6,
slot SOC floor 20, charge source cleared — and the battery carried on charging at
500–760 W throughout. The day-bits hypothesis is therefore disproved: setting
them changes nothing, so something else gates dispatch.
A three-phase controlled test settled it. Control first, then two treatments back to back so conditions were as close to matched as possible:
| Metric | A control (Auto) | B slot power | C mode bits General |
|---|---|---|---|
| mean battery power | -892 W | -976 W | -1138 W |
| max discharge seen | -831 W | -927 W | -1023 W |
| mean grid CT | 34 W | 38 W | 5 W |
| mean generation | 1396 W | 1537 W | 1878 W |
| mean load | 416 W | 473 W | 640 W |
| discharge counter | unmoved | unmoved | unmoved |
The battery charged throughout all three phases and never discharged once. In every phase battery charge power tracked generation minus load to within about 90 W - textbook self-consumption, unaffected by anything written.
Being straight about the weakness in this data: generation rose 34 % and load 54 % across the phases, so the differences in mean battery power are explained by the drifting surplus, not by the treatments. But the conclusion does not rest on those differences. It rests on the battery never discharging at all, in any phase, and the discharge counter never moving. A commanded 2 kW discharge would show as positive battery power. It never appeared.
What this rules in and out:
- Slot power is a cap, not a target. Writing 2000 W to the active slot lowered a ceiling from 15000 W. A lower ceiling commands nothing.
- The mode bits are not the gate. Setting the active slot to General was
accepted and read back as 4, and changed nothing. So
_preserve_mode_bitsis indeed a no-op dressed as caution, but fixing it would not help either. - Mode 0 is "unset", not "off". Confirmed by analogy within register 178, where
every field uses
0b10for disable and0b11for enable. Neither encoding uses0b00, and the DRM and US-grounding-fault fields both read0b00on this unit, having never been written. - Nothing in the TOU block commands discharge. The slot registers only ever constrain. This was the third explanation and it is the one that survives.
At the time this looked like Force discharge being unbuildable. It was not: the slot registers were being suppressed by grid peak shaving, which findings 17 and 18 establish. The conclusion that the TOU block "only constrains" was right about what the registers do and wrong about why nothing happened. Read on.
14. Force charge works, once the peak-shaving cap is raised.
Sequenced test, one variable at a time, with a control before and after the 191 write so the register change was isolated from the mode change:
| Metric | A: 191 = 100 | B: 191 = 15000, still Auto | C: Force charge 2000 W |
|---|---|---|---|
| mean grid CT | 14 W | 122 W | 766 W import |
| max import | 42 W | 255 W | 911 W |
| mean battery power | -958 W | -1438 W | -1849 W |
| mean generation | 1690 W | 2041 W | 1843 W |
| mean load | 612 W | 495 W | 591 W |
Phase A never exceeded 42 W of import against a 100 W cap. Phase B, with the cap raised and nothing else changed, showed transient import to 255 W. Phase C drew a sustained 700-911 W from the grid, and the day import counter moved from 0.2 to 0.3 kWh.
Drift does not explain this one. Generation fell from 2041 W to 1843 W between phases B and C while battery charge power rose from 1438 W to 1849 W. The extra charge power cannot have come from solar; the power balance closes only with the measured ~730 W of grid import (1843 gen + 730 import = 2573 in, against 591 load + 1849 battery = 2440 out, leaving ~130 W of conversion loss).
So the loosely-held hypothesis from finding 13a is now tested and confirmed: the 100 W peak-shaving threshold was suppressing grid import, and with it grid charging. It was not registers 127/130 being constraint-only.
Undershoot: 6 %. 2000 W requested, 128 set to 37 A, battery charged at 1849 W. Brief section 3 predicted roughly 10 %; the measurement is inside that, and no compensation is applied.
A register-semantics detail that matters for Predbat. Register 128 appears to cap total battery charge current, not grid-sourced current alone. The battery charged at 1849 W, about 35 A against the 37 A limit, with solar supplying the majority and the grid topping up to the limit. So Force charge sets a total charge rate and the inverter fills it from whatever is available.
So Force charge at N watts means "charge at N from any source", not "import N". Equivalent at night; materially different in daylight, where solar supplies most of it and the grid only tops up.
Predbat uses a different mechanism for the same job. Its timed_charge_current
drives register 108, not 128. So Predbat's charge control and this integration's
Force charge are not the same lever, and may not behave identically - 108 is the
battery's own charge ceiling, 128 is the grid-charge setpoint. Needs a compatibility
check once Predbat is wired in, comparing what Predbat asks for against what the
battery actually does.
One thing this test cannot separate: phase C wrote 128 and the TOU slot registers together, so it does not establish which produced the import. Given the TOU block was proved constraint-only in finding 13b, 128 is the likely mechanism, but that has not been isolated. It does not block shipping Force charge, which works as a whole.
15. Register 191 raised to 15000 W. No couplings.
Written once, read back as 15000, and a whole-image diff across the write found no
other register changed - notably not 178 and not 104. Register 178 bits 4-5 remain
0b11, so peak shaving stays enabled with a threshold matching the export ceiling.
16. Register 104 appears inert now that 142 = 0. Read-only so far.
Register 104 (System Zero Export power) reads 20 W and the map gives it scale -1, meaning it is a signed register. The hypothesis worth testing is that a negative value commands export at a target, making it the export-side counterpart to 191 and the discharge setpoint that is missing from the TOU block.
What read-only observation establishes:
- 104 has been stable at 20 W throughout, including across the 142 and 191 writes. No coupling either way.
- It is not currently regulating the connection point. Over eight samples the CT swung from -195 W to +351 W, mean 89 W, while 104 sat at 20 W. Under the old 100 W peak-shaving cap the CT was held tightly (max 42 W); with that cap raised the connection point floats freely and 104 is not holding it anywhere.
- This also answers an earlier open question: 104 does not appear to retain any effect once 142 = 0.
Its sign behaviour cannot be established without writing to it, and that test was
dropped rather than run: someone who has documented this inverter family in
depth reports that zero export power cannot be set to a negative value, noting
explicitly that it would have been a neat way to introduce export if it were
allowed. A negative write would be rejected or clamped. 104 stays in
WRITE_FORBIDDEN; the read-only observations above stand as useful negative
results.
17. Grid peak shaving was suppressing all discharge and export. Raising the threshold was not enough; the feature had to be disabled.
Two steps, one variable each.
Step 1 - 191 already at 15000, nothing else changed. Force discharge still did nothing. Battery stayed between -806 and -1197 W (charging) across 8 samples, mean -974 W against a control mean of -1113 W. The discharge and export counters did not move. So the threshold was not the gate for discharge, only for import.
Step 2 - 178 bits 4-5 set to 0b10 (disable), 191 left at 15000. The effect was immediate and much larger than expected. See finding 18 for what happened before Force discharge was even applied.
With peak shaving disabled, Force discharge then worked precisely:
| Metric | Control (Auto, shaving on) | Auto, shaving off | Force discharge 2000 W |
|---|---|---|---|
| mean battery power | -940 W | +9971 W | +2029 W |
| max discharge | -861 W | +11656 W | +2036 W |
| mean grid CT | +76 W | -10550 W | -2825 W (export) |
| mean load | 417 W | 655 W | 548 W |
| mean generation | 1420 W | 1508 W | 1388 W |
2000 W requested, 2029 W delivered - 1.4 % over. Not the ~10 % undershoot the brief predicted for the charge path; discharge tracks the setpoint closely.
The power balance closes: generation 1388 W + battery 2029 W = 3417 W in, against load 548 W + export 2825 W = 3373 W out, leaving ~44 W. Discharge exports when load does not absorb it, which is the requirement, and the export configuration did not need to change to achieve it.
This also confirms two earlier conclusions independently, both now backed by Deye's own manual for this model: slot power is documented as the maximum discharge power of battery allowed, and with the grid present the battery only discharges when time-of-use is ticked. Unconstrained the inverter dumped at 11.6 kW; with slot power at 2000 W it delivered 2029 W. Slot power is the control.
No CT blindness. Load power (653) tracked UPS load (643) exactly in every sample of every phase, including while exporting 12 kW. The reported selling-first failure mode where the inverter stops reading the CT did not occur here. The guard that would have aborted the test never triggered.
No couplings from the 178 write; the whole-image diff was clean.
18. Disabling peak shaving made the inverter dump the battery to grid at 11.6 kW, in Auto, with nothing else changed.
This is the finding that matters most, and it was not what the test was looking for.
The moment 178 bits 4-5 went to 0b10, with no mode applied and the TOU block untouched, the battery went from charging at 837 W to discharging at 11656 W and the connection point went to 12037 W of export. It held there for the whole phase.
The cause is the owner's existing schedule. The active slot at the time was slot 2: start 07:00, power 15000 W, SOC floor 20 %, charge source none. Register 146 bit 0 is set, so time-of-use is ticked. That slot has been commanding "discharge at up to 15 kW down to 20 % SOC" every day since commissioning - and grid peak shaving at 100 W was the only thing suppressing it.
So the commissioned configuration was doing more than islanding the connection point. It was masking an aggressive export schedule underneath.
The design consequence is serious: disabling peak shaving as a standing configuration is not safe on its own. With it disabled, Auto would dump the battery to grid at full power whenever SOC is above the slot floor. Any decision to disable it permanently has to come with control of slot power in Auto, and what slot power 0 means has not been tested - it may mean "no discharge" or it may mean "unlimited".
Options, none of them chosen yet:
- Disable peak shaving permanently and have Auto hold slot power at a safe value. Needs slot-power-zero semantics established first.
- Toggle 178 bits 4-5 per mode: enabled in Auto and Hold, disabled only for Force discharge. This makes a feature switch into mode state, which is the pattern that was deliberately rejected for 142 and 145.
- Leave peak shaving enabled and accept that Force discharge cannot work.
Register 178 was unlocked for the test only, via a scoped override in the test
script. It remains in WRITE_FORBIDDEN in the integration and was restored to
0x0EBA, with peak shaving re-enabled, before the script exited.
- BMS vs inverter limit arbitration. BMS reports 580 A (218/219) and 560 A
(212/213) against the inverter's own 240 A (108/109), so the inverter limit is
the binding one and
min(108, 218)is safe either way. The direction of arbitration still cannot be established read-only. - Grid CT sign convention (import positive or negative). Still open, and harder to force than expected — see below.
- What slot power 0 means - no discharge, or unlimited? This gates whether peak shaving can be disabled permanently. See finding 18.
- Predbat's 108 versus our 128 for charge control. See finding 14.
- Whether the TOU day bits matter once peak shaving is resolved. The earlier test showed no effect, but peak shaving was suppressing discharge at the time, so necessary-but-not-sufficient has not been ruled out. Keep the setup-time write to 146 and retest.
- Do the slot charge-source bits actually prevent simultaneous import and export? Predbat's template sets "Limited to Home" during charging specifically to stop export while charging. The plan drops that in favour of the charge-source bits, which must be verified rather than assumed from semantics.
- Hold with the AUX inverter producing and the grid down. See the safety question below.
- Curtailment priority when slot power plus AUX generation would exceed 143 (15000 W): does the inverter throttle battery output or the AUX unit first?
The plan was that a live Hold would settle it: pinning the battery at a 15 A charge ceiling should leave the house importing in a known direction. It did not work, and the reason is worth recording.
Under Hold, with the battery at 96 % and generation running, the inverter held the connection point between 3 and 44 W across 13 samples over 6 minutes. The battery absorbed 445–613 W, which was exactly the surplus — PV around 380 W plus AUX around 890 W, less roughly 600 W of load. The inverter actively regulates the connection point to zero and uses the battery as the balancing term. Neither counter moved, so there was nothing to resolve the sign against.
Hold only forces import if the battery cannot absorb the surplus, which needs either generation below load, or a battery sink smaller than the surplus. At 15 A the sink is around 800 W, which exceeded the surplus for the whole test window.
Two ways to get a determinate flow, neither yet run:
- After sunset. With generation near zero and discharge pinned at 0, the house must import roughly load plus whatever the battery accepts. No new risk and no new decision; it just needs the test to run in the evening. This is the original premise, simply at the right time of day.
- Force discharge. Discharging into a house that cannot absorb it forces export at a known magnitude, resolving the sign within a couple of minutes at 2 kW. It also exercises whether TOU-based force discharge works at all, which is a stage 4 item regardless. The battery is at 96 %, so there is headroom.
Recommendation: Force discharge, since it answers two questions at once. Until the
sign is known, grid_power_invert cannot be set and dry run stays on.
Hold sets both current limits to 0, so during a grid outage the battery is not available as a sink for the AC-coupled inverter on the AUX port. Register 131 puts the curtailment threshold at 55.00 Hz, above that inverter's likely G99 over-frequency trip, so it would trip rather than curtail.
Until the islanding test has been run, close that window with an automation. The grid-connected bit is register 552 bit 2, exposed as an attribute of the relay status sensor.
automation:
- alias: "Sunsynk: leave Hold when the grid drops"
description: >
Hold zeroes the battery current limits, which removes the only local sink
for the AC-coupled inverter on the AUX port. On grid loss, return to Auto
so the battery can absorb that generation.
mode: single
triggers:
- trigger: template
value_template: >
{{ state_attr('sensor.sunsynk_relay_status', 'grid_relay') == false }}
for: "00:00:10"
conditions:
- condition: not
conditions:
- condition: state
entity_id: select.sunsynk_mode
state: "Auto"
actions:
- action: select.select_option
target:
entity_id: select.sunsynk_mode
data:
option: "Auto"
- action: notify.persistent_notification
data:
title: "Sunsynk left Hold"
message: >
Grid connection lost while in Hold. Returned to Auto so the battery
can absorb AC-coupled generation.The relay_status sensor is disabled by default, so enable it first. The 10
second delay avoids reacting to a momentary relay bounce. Note this depends on the
integration still running: it is a mitigation, not a substitute for the inverter
curtailing correctly, which is what the islanding test establishes.
Decided: HOLD_CHARGE_CURRENT = 0. Hold holds. Owner's call, risk accepted
pending the islanding test.
The residual risk is real and documented rather than dismissed: with no charge headroom the battery is not available as a sink for the AC-coupled inverter on the AUX port. Register 131 puts the curtailment threshold at 55.00 Hz, above that inverter's likely G99 over-frequency trip of around 52 Hz with LFSM-O response from near 50.4 Hz, so during a grid outage in Hold the AUX unit would trip rather than curtail smoothly, then sit out an anti-islanding reconnect delay.
Two things reduce the exposure in the meantime:
- the grid-loss automation above, which returns the mode to Auto within 10 seconds of the grid relay opening;
- the islanding test itself, held for the owner's attendance: with the AUX unit producing above backup load, open the grid supply and observe whether frequency rises and AUX output drops cleanly.
The value is a single named constant in modes.py so the decision lives in one
place.
The inverter was found 36 minutes behind UTC, which on BST is 96 minutes behind wall clock, while register 228 reported time synchronisation as enabled. Whatever should be disciplining the clock is not doing so — most likely there is no working cloud attachment on the dongle, since that is normally the time source.
This is not cosmetic. The inverter's own midnight is what rolls its daily energy
counters — registers 514, 520, 521, 526 and 529. Predbat's template sources
load_today and pv_today from those registers, so with a 96 minute skew it sees
the day boundary at 01:36 local while pricing runs on real midnight.
Registers 62–64 are therefore in the allowlist, and the integration syncs the clock from Home Assistant once at setup and then daily. Identical slots mean a clock correction cannot disturb dispatch, so the risk is minimal.
Two implementation details worth knowing:
- The write is not read-back verified against the value written, because seconds advance between the write and the verify. It is verified by re-reading and checking the residual skew is within 15 seconds.
- A skew under 60 seconds is left alone, so the daily sync does not write for no reason.
Encoding, verified against a live read of 6664, 3851, 5669 = 2026-08-15 11:22:37:
| Register | Contents |
|---|---|
| 62 | (year − 2000) << 8 | month |
| 63 | day << 8 | hour |
| 64 | minute << 8 | second |
Writing 142 also moved 145. A diff limited to the registers the reconciler targeted cannot see that, so it would have gone unnoticed had it not shown up in a before/after snapshot taken for another reason.
Every reconciliation pass that writes anything is now bracketed by a full image
of the settings space, and the whole image is diffed. Any register that changed
without being written is reported as a coupling: logged at warning level with a
human-readable name, surfaced as an entity attribute, and accumulated across
passes in Reconciler.known_couplings.
| Imaged | 0–21, 60–200, 205–240, 336–345 |
| Excluded from the image | everything from 500 up — telemetry, which changes every poll by design |
| Excluded from the diff | 62–64 (the inverter clock, which changes every second), 114 (charge efficiency), 210–220 (BMS telemetry) |
The exclusions matter: without them a real coupling would be buried under the clock ticking. Passes that write nothing are not bracketed, so the extra transactions are only paid when something actually changes.
PV1 (MPPT 1) looks like an open input, not a dark string. It reads 0 W at 13.1–13.7 V across every sample. That is nowhere near a single panel's open circuit voltage, so MPPT1 most likely has nothing connected, or has a disconnected or failed string. PV2 meanwhile behaves normally (379–630 W at 247–257 V). Worth checking on the DC side: half the array's capacity may be sitting idle.
The integration takes full ownership of the time-of-use block. The owner does not use the inverter's schedule, so there is no user intent to preserve beyond a courtesy restore.
There is no active-slot tracking. Every slot holds the same values, so it does not matter which slot the inverter honours — the 19:00 ambiguity, slot-boundary edge cases, and the risk of a mode change landing exactly as a slot rolls over all disappear.
First activation normalises the slots, seeding from the minimum. The inverter held a non-uniform block — slot SOCs of 100, 20, 20, 20, 20, 20 — where the 100 in slot 1 was a remnant of the abandoned 19:00 grid-charge configuration. The slot-SOC entity therefore seeds from the minimum across all six slots, not from slot 1, so the block normalises to 20.
The asymmetry decides it. A resting target of 100 means any failure grid-charges toward full at peak rate, silently and repeatedly. A resting target of 20 means the battery sits still. This is also the fallback state whenever Predbat is not running, which is exactly when the conservative value matters.
registers.seed_slot_soc implements it, and a test asserts it finds the low value
whichever slot holds it.
A mode change is three atomic block writes:
| Registers | Contents | Transactions |
|---|---|---|
| 154–159 | slot power | one FC 0x10 |
| 166–171 | slot SOC | one FC 0x10 |
| 172–177 | charge source and mode | one FC 0x10 |
Per-register write frequency is the same as rotating a single slot would be, so EEPROM wear does not get worse, and each block is now atomic where a per-slot scheme was not.
| Register | Set to | Why |
|---|---|---|
| 148–153 | 00:00 / 04:00 / 08:00 / 12:00 / 16:00 / 20:00 | Six distinct ascending times. The upstream definitions enforce ascending order with interlocks between adjacent slots, so five equal times are not safe. |
| 146 | 0x00FF |
Master enable plus all seven day bits, so the block actually executes. |
| 142 | 0 (Allow export) | Corrected configuration. Full export is permitted. |
| 191 | 15000 W | The import half of the same decision. Matches the export ceiling; peak shaving stays enabled. |
146 and 148–153 are never rewritten after setup except on explicit request. 142 is corrected configuration rather than mode state, so the reconciler does not toggle it. 145 is never written at all — the firmware sets it when 142 changes.
The user's original TOU block and export configuration are snapshotted to restore-state before anything is written, and restored on uninstall. An integration that eats a schedule and cannot give it back is bad manners.
| Register | Value found | Note |
|---|---|---|
| 143 Export limit power | 15000 W | G99 connection ceiling. Set by hand. On the forbidden list permanently. |
| 104 Zero export power | 20 W | Only meaningful while zero export is active. |
Predbat ──┐ (the planner)
▼
Home Assistant entity state (the desired state)
├── select.sunsynk_mode Auto / Hold / Force charge / Force discharge
├── number charge + discharge current, slot SOC
├── select prog1-6 charge options
└── switch.sunsynk_inverter_enabled diagnostic, opens the AC relays
│
▼
mode engine (modes.py) entity state -> register targets
│
▼
reconciler diff, write deltas only, read back
│
▼
SunsynkModbusClient one client, one lock, spaced writes
│
▼ Modbus TCP, FC 0x03 / 0x10
SUN-12K-SG04LP3
This matters because Predbat writes the same registers the mode engine wants: 108, 109, 166 and 172–177. A reconciler that recomputed its own desired values would revert Predbat on every poll, Predbat would set them again, and the two would fight over EEPROM-backed registers several times an hour, indefinitely.
So the rule is: the reconciler's job is to make the inverter match Home Assistant entity state. Both Predbat and the user set entity state. The reconciler enforces whatever the entities currently hold and never invents a value. Drift correction against the inverter LCD and the SolarMAN cloud app is preserved, because those change the inverter without changing entity state. The write-fight is not, because Predbat changes entity state and the reconciler follows it.
Consequently there is no snapshot-and-restore of 108/109. That design assumed the integration owned those values; it does not. The snapshot pattern applies only to the one-time TOU courtesy snapshot at setup.
| Tier | Default | Contents | Cost measured |
|---|---|---|---|
| Fast | 10 s | battery, PV, grid, load, inverter, AUX, temperatures, state | 3 transactions, 97 registers, ~565 ms |
| Slow | 60 s | settings, TOU block, energy counters, BMS, faults | 6 transactions, 120 registers, ~840 ms |
Reads are batched into contiguous ranges by registers.plan_blocks, which
bridges gaps up to 12 registers and caps transactions at 100 registers. The fast
tier collapses to (500, 1), (540, 2), (586, 94).
- One
AsyncModbusTcpClient, oneasyncio.Lockcovering every read and write. - Writes use FC 0x10 exclusively. FC 0x06 is accepted by this inverter but transactions are silently dropped.
- Consecutive writes are spaced, 3 s by default and configurable.
- Every write is read back and compared. A mismatch retries once, then surfaces as an entity attribute.
- Reconnect uses exponential backoff from 1 s to a 300 s ceiling.
Writes are gated by an explicit allowlist in const.WRITE_ALLOWLIST,
enforced in the client's write path. A caller cannot write a register outside it,
in dry-run or live mode:
| Permitted | Purpose | When written |
|---|---|---|
| 62–64 | inverter clock | once at setup, then daily |
| 80 | inverter enable | diagnostic switch only, never by a mode |
| 108, 109 | battery max charge / discharge current | on entity state change |
| 128 | grid charge current | Force charge |
| 142 | load limit / export configuration | once at setup |
| 146 | TOU master enable and day bits | once at setup |
| 148–153 | TOU slot start times | once at setup |
| 154–159 | TOU slot power | on mode change |
| 166–171 | TOU slot SOC | on mode change |
| 172–177 | TOU slot charge source and mode | on mode change |
const.WRITE_FORBIDDEN names registers that must never be written under any
circumstances, as a second check independent of the allowlist:
| Forbidden | Reason |
|---|---|
| 99–101, 118–120 | battery protection voltages |
| 104 | zero export power, report only |
| 111 | battery type |
| 131 | AUX droop threshold, to be set by hand |
| 143 | G99 export ceiling (15000 W), set by hand |
| 145 | Solar Sell — the firmware moves it when 142 changes |
| 182–184 | grid code, frequency, phases |
| 336 | parallel configuration |
The two sets are asserted disjoint by a unit test. Attempting a write span that starts inside the allowlist but runs into anything else is also rejected.
| Stage | Contents | Status |
|---|---|---|
| 1 | modbus_client.py, registers.py, const.py, read-only verification |
complete |
| 2 | manifest.json, __init__.py, config_flow.py, coordinator.py, sensor.py |
complete |
| 3 | modes.py mode engine and reconciler, predbat.py override, dry-run |
complete |
| 4 | controller.py, select.py, number.py, switch.py, control-loop selector, reconciler wiring |
code complete, live verification pending |
| Entity | Register(s) | Notes |
|---|---|---|
select Mode |
— | Auto / Hold / Force charge / Force discharge |
select Work mode |
178 bits 4–5 + 154–159 | The control-loop selector. Predbat's exact option strings |
select Prog1–6 charge option |
172–177 | Predbat's exact option strings; writes coalesced |
number Force charge power |
→ 128 | W, converted to amps at live voltage |
number Force discharge power |
→ 154–159 | W |
number Battery max charge current |
108 | Predbat's timed_charge_current |
number Battery max discharge current |
109 | Predbat's timed_discharge_current |
number Set SOC timezone1 |
166–171 | Predbat's charge_limit; mirrors to all six |
switch Inverter enabled |
80 | Diagnostic only, with the BACKUP-port warning |
switch Predbat enabled |
— | Reads off during an override; fallback gate for automations |
The reconciler runs after every slow poll, which is what corrects drift from the LCD or the cloud app, and writes only when a delta exists.
Stages 2 and 3 do not write to the inverter during normal operation. The mode engine and reconciler are exercised only through unit tests so far; the control entities that feed them arrive in stage 4, and dry run is on by default.
The single exception is the authorised one-off export correction (142 = 0), which
was executed deliberately via scripts/sunsynk_export_test.py after a dry run.
Offline, no hardware and no Home Assistant required:
.venv/bin/python -m pytest tests_sunsynk_modbus -c tests_sunsynk_modbus/pytest.ini228 tests covering 32-bit word order, signed decoding, the temperature offset, enum and missing-value handling, encode round-trips, block planning, serial and bitfield decoding, the TOU setup snapshot, the computed Predbat sensors, allowlist and forbidden-list enforcement, lock serialisation, write spacing, read-back retry, mode-to-register translation for all four modes, the reconciler diff, and write planning.
The reconciler tests assert the properties that matter rather than the implementation: that Auto adopts Predbat's values instead of reverting them, that a second pass over an already-correct inverter writes nothing, that a change made on the inverter's LCD is corrected, and that a silently dropped write surfaces as a mismatch instead of being swallowed.
The tests live in tests_sunsynk_modbus/ rather than tests/ because the
existing tests/conftest.py installs an autouse Home Assistant fixture for the
Sofar integration, which would force a HA dependency on these tests.
Expected values in the tests are the raw values read from the live inverter, so a decode regression fails the suite rather than merely looking different.
Read-only, function code 0x03 only:
| Script | Purpose |
|---|---|
scripts/sunsynk_probe.py |
full register dump with decode and word-order/offset comparisons |
scripts/sunsynk_sample.py |
repeated sampling to correlate registers and pin down scales |
scripts/sunsynk_verify_decode.py |
runs the integration's own table and decoder against hardware with 19 consistency assertions |
scripts/sunsynk_export_probe.py |
reports the export configuration, BMS limit scales and the grid CT baseline |
scripts/sunsynk_sign_probe.py |
settles the battery and AUX sign conventions from the daily counters; reports 131 and 178 |
scripts/sunsynk_ct_monitor.py |
long-running monitor that reports the CT sign on the first grid event |
scripts/sunsynk_export_test.py |
the one authorised write: 142 = 0, with before/after capture. Defaults to dry run |
scripts/sunsynk_image_check.py |
proves the settings image is stable, so coupling reports are not false positives |
scripts/sunsynk_mode_dryrun.py |
dry-runs all four modes against live values and prints the exact register diffs |
scripts/sunsynk_ct_resolve.py |
resolves the CT sign via a live Hold, restoring 108/109 afterwards |
scripts/sunsynk_force_test.py |
live Force charge/discharge test with full TOU restore |
scripts/sunsynk_dispatch_probe.py |
read-only: peak shaving, register 178 field by field, active slot |
scripts/sunsynk_dispatch_test.py |
control plus two treatments, to tell dispatch from self-consumption |
scripts/sunsynk_peakshave_test.py |
raises 191, then tests Force charge, with a control either side of the write |
scripts/sunsynk_discharge_retest.py |
Force discharge retest, with the CT-blindness guard and optional peak-shaving disable |
A caution learned the hard way: an earlier version of the export probe used
regs.get(addr, 0) and reported unread registers as "0.0 kWh", which looked
exactly like a daily energy counter resetting mid-afternoon. The accessor now
raises on a register the read blocks did not cover. Do not default missing
Modbus data to zero.
The inverter is on a remote network. Reach it with an SSH tunnel through the authorised jump host:
ssh -N -L 127.0.0.1:15020:10.20.0.71:502 proxmox-jump
.venv/bin/python scripts/sunsynk_verify_decode.py --port 15020Predbat's built-in Sunsynk type (inverter_type: SK) was written against
kellerza/sunsynk. Its template normally requires four hand-built template
sensors and a dummy reserve entity. This integration exposes all of them
natively.
| Predbat key | This integration | Source |
|---|---|---|
battery_voltage |
battery_voltage |
register 587 |
battery_power |
battery_power |
register 590 |
pv_power |
total_solar |
PV1 + PV2 + AUX micro-inverter |
load_power |
load_power |
register 653 |
load_today |
day_load_energy |
register 526 |
pv_today |
day_pv_energy |
register 529 |
soc_kw |
battery_energy |
SOC × 102 Ah × live voltage |
soc_max |
battery_capacity_energy |
102 Ah × live voltage |
battery_min_soc |
battery_capacity_shutdown |
register 115 |
charge_rate |
charge_rate |
108 × voltage, capped at rating |
discharge_rate |
discharge_rate |
109 × voltage, capped at rating |
inverter_limit_charge |
battery_max_charge_rate |
min(108, 218) × voltage, capped |
inverter_limit_discharge |
battery_max_discharge_rate |
min(109, 219) × voltage, capped |
timed_charge_current |
number on 108 |
stage 4 |
timed_discharge_current |
number on 109 |
stage 4 |
charge_limit |
number on slot SOC |
stage 4, writes all six slots |
charge/discharge_*_service |
select prog1–6 charge option |
stage 4 |
The four rate sensors are capped at the inverter's AC rating. 240 A at 53.5 V is 12850 W, which a 12 kW unit cannot deliver; reporting the uncapped figure would have Predbat plan for power the hardware does not have.
Predbat's apps.yaml documentation states the conventions it expects, and
provides inversion flags where a sensor disagrees.
| Quantity | Predbat expects | This inverter | Setting |
|---|---|---|---|
battery_power |
positive = discharge, negative = charge | negative = charging (verified from the daily counters) | battery_power_invert: false |
grid_power |
negative = import, positive = export | negative = export (verified from counter movement) | grid_power_invert: true |
Battery power matches Predbat's expectation, so no inversion is needed. This is worth having checked: had the convention been inverted, Predbat would have planned backwards — charging when it meant to discharge — while every sensor still looked plausible.
Grid power is inverted relative to Predbat's expectation, so
grid_power_invert: true is required. Established from 520/521 counter movement
under a Hold-at-0, not from a power balance.
Two further notes from the same documentation:
battery_voltage: use the live sensor (register 587), following the template.
Predbat's documentation describes this field as the nominal maximum battery
voltage, but its own Sunsynk template points it at a live-voltage sensor. The
template is right for this hardware: Predbat uses the value to convert its watt
plan into amps for timed_charge_current, which is the same conversion Force
charge performs. A nominal maximum would overstate available power at every SOC
below full, with the error growing as the pack drains. The documentation is
probably describing GivEnergy behaviour. Discrepancy recorded, live value used.
soc_percent is the recommended input, not soc_kw. Register 588 gives the
percentage directly, so Predbat does its own conversion using soc_max and no
error in register 102 is compounded into the energy figure. Both are exposed;
prefer soc_percent.
Register 102 reads 1256 Ah, which at 53.5 V is about 67 kWh. That does not cleanly match a Fogstar bank: those are typically 15.5–16 kWh modules, so four would land near 62–64 kWh.
This matters more than a cosmetic discrepancy. soc_max feeds directly into
Predbat's planning, so if 102 is a commissioning approximation then every charge
and export window Predbat plans is sized wrong by that margin — around 5 %, and
in the optimistic direction. It is on WRITE_FORBIDDEN; to be verified against
the installed pack rating and set by hand.
Until then, treat battery_energy and battery_capacity_energy as approximate,
which is another reason to feed Predbat soc_percent rather than soc_kw.
| Inverter clock (registers 62–64) | 13:08:47 |
| Local wall clock | 14:44:45 BST |
| Register 228 time synchronisation | reads "Enable" |
The inverter is running about 36 minutes behind UTC, and since local time is BST it is 96 minutes behind wall clock. Time synchronisation is enabled and evidently not working, or is syncing to something that is itself wrong.
The identical-slots design makes this harmless for the modes — every slot carries the same content, so it does not matter when the inverter thinks a slot begins. That is an unplanned benefit of the decision rather than a reason to ignore the clock.
It is not harmless for Predbat, which plans in real time and has
inverter_clock_skew_start / _end settings for exactly this. Registers 62–64
are not in the allowlist, so the integration will not correct it. Worth fixing at
source: either get time sync working or set the clock by hand.
battery_energy and battery_capacity_energy derive from register 102
(1256 Ah, giving ~67 kWh at 53.5 V). 102 is a configured value rather than a BMS
reading, so those two sensors are only as accurate as that setting.
select.sunsynk_mode keeps four options, layered above Predbat:
| Mode | Predbat | Registers the mode governs |
|---|---|---|
| Auto | authoritative for the current limits | owns the control loop (shaving + slot power); enforces entity state for everything else |
| Hold | parked | 108 = 0, 109 = 0. Verified: they accept 0. TOU untouched |
| Force charge (W) | parked | 128 = W ÷ voltage clamped; 108 raised to fit; 166–171 = 100; 172–177 source = grid. Verified working, 6 % undershoot — finding 14 |
| Force discharge (W) | parked | 154–159 = W; 166–171 = floor; 172–177 source = none; 109 raised to fit |
Entering a non-Auto mode sets select.predbat_mode to Monitor; returning to Auto
restores whatever it was. The prior mode goes to restore-state rather than
memory, and on startup in Auto it is restored unconditionally, so a crash during
an override cannot leave Predbat parked indefinitely. Every non-Auto mode carries
a configurable auto-revert timeout, default 60 minutes.
Parking Predbat costs one inverter reset. Predbat's documentation is explicit that changing its mode makes it reset the inverter — disabling charge and discharge, resetting both rates to full power, and resetting the reserve. It does this deliberately, to hand control back to the inverter's own scheduling. Two consequences the implementation has to respect:
- Ordering is mandatory. Park Predbat first, then reconcile. Reconcile first and Predbat's reset lands afterwards and wins.
- Reconcile immediately, not on the next poll. Otherwise the inverter sits at Predbat's default full rates for up to one slow-poll interval. For Hold that means a battery still free to charge and discharge at full power for up to 60 seconds.
async_apply_mode_transition in predbat.py owns that ordering so no caller has
to remember it. switch.predbat_set_read_only carries the same reset behaviour
and is therefore not a cleaner substitute.
select.predbat_manual_charge, manual_export, manual_demand,
manual_freeze_charge and manual_freeze_discharge are architecturally more
appealing — driving the planner rather than disabling it. They do not fit:
- They operate in half-hour slots, selected up to 48 hours ahead. An override meant to last five minutes, or an arbitrary period, does not map onto them.
- They take no wattage. The brief's Force charge (W) and Force discharge (W) are watt-specified, which these cannot express.
- They do not override the Predbat mode. Predbat still has to be in Control charge or Control charge & discharge for a manual charge to happen, so it also remains free to write the rate registers.
They are a good fit for a future slot-aligned feature — "charge in the 02:00 slot" — but they cannot carry a watts-specified manual override. Option C ships.
charge_start_service sets prog1 through prog5 to "Allow Grid & Gen",
charge_stop_service to "Allow Gen", and discharge_start_service to "No Grid
or Gen". It acts as a live controller, not a scheduler — the same architecture
the identical-slots decision arrives at independently.
Two consequences worth knowing:
- Slot 6 is not in the template. All six selects are exposed, and slot 6 is mirrored automatically so the block stays uniform. Predbat's template needs no change.
- Five sequential service calls must not become five EEPROM writes. Setting a prog select schedules a coalesced write rather than writing immediately. Predbat's five calls arrive within milliseconds and collapse into a single FC 0x10 block write of 172–177.
select.sunsynk_work_mode is set to "Limited to Home" while charging and
"Selling First" while discharging. Those option strings come from the
single-phase Sunsynk definitions. On this low-voltage three-phase unit the
same register (142) is "Load Limit" with options "Allow Export" / "Essentials" /
"Zero Export". Predbat's calls would fail against a select carrying the
three-phase names.
Since 142 is now fixed at "Allow Export" as corrected configuration, the
work-mode lines are unnecessary: what stops the battery exporting during a charge
is the slot charge-source bits, not 142. The recommendation is to delete the
select.sunsynk_work_mode entries from apps.yaml and leave 142 alone.
Addresses, scales and R/W flags come from sunsynk_3ph_12kw_modbus_registers.md
in the repository root, which was generated from
kellerza/sunsynk three-phase definitions.
Corrections listed under Verification findings were
established on this specific unit and firmware; the temperature offset, the
register 216 scale and the dead 32-bit mirrors are departures from that file.
Content was rephrased for compliance with licensing restrictions.
This repository already contains custom_components/sunsynk_inverter/, which
targets the same hardware and can write registers 108, 109, 128, 130, 142, 143
and 145 through its optional control entities. Two integrations writing the same
registers will fight: this one reconciles continuously, so it will revert
anything the other one sets. It also writes with FC 0x06, which this platform
drops silently.
Do not run both against the same inverter. Either leave
sunsynk_inverter's controls disabled (its default) and use it for sensors
only, or remove it once this integration reaches stage 4.
Predbat is not wired in yet, deliberately. The entity surface is kept
Predbat-compatible and the mapping table above is maintained, but the apps.yaml
config comes after the integration has real manual use behind it.
The reason is single-variable testing. Adding a second autonomous writer to registers 108, 109, 166 and 172–177 before the mode engine is trusted would make every future anomaly ambiguous, and a quiet inverter with one writer is what produced every real finding recorded in this document.
Two things were found and fixed during the interface build that would otherwise have made a Predbat-less system behave differently from a Predbat-driven one:
- Auto originated nothing.
_targets_autoonly emitted registers for which an entity held a value, and slot power was never seeded, so on a bare system Auto would have left the installer's 15000 W slot 2 in place — the latent dump condition would have survived. The controller now seeds slot power to 0 as its own default rather than reading it from the inverter. - The work-mode select was scoped as Predbat's route in. It is now owned by the mode engine with a safe default of Limited to Home, because a Predbat-less Auto otherwise has no defined control loop.
Where a decision trades manual usability against Predbat convenience, manual wins.
19. Auto left the battery idle while the house imported — and why the first two explanations were both wrong
The first live Auto run sat at 98 % SOC importing 285 W with the battery at 11 W. Two hypotheses were offered and both were wrong, which is worth recording because the sharpening is what found the real cause.
Hypothesis 1 (mine): peak shaving is the self-consumption mechanism. A mechanism claim.
Hypothesis 2 (owner's): 191 is a threshold the battery is driven to maintain — at 100 W it covered nearly all load, at 15000 W it has nothing to do. A value claim, and a much more useful one if true.
The test. Sweep 191 across four values with everything else held constant — shaving enabled, slot power 0, 108/109 at 240. If import settles at the threshold, hypothesis 2 holds.
| 191 set to | mean import | mean battery | mean deficit |
|---|---|---|---|
| 15000 W | 236 W | 11 W | 161 W |
| 100 W | 308 W | 10 W | 230 W |
| 300 W | 186 W | 6 W | 117 W |
| 0 W | 133 W | 8 W | 64 W |
Import tracked the deficit, not the threshold, and the battery never moved off 6–11 W at any setting. Neither hypothesis survives.
The actual cause: slot power 0 forbids discharge, and that includes the ordinary load-following discharge Auto needs. This follows directly from the earlier verified finding that slot power 0 means no discharge — the mistake was treating "safe resting value" as compatible with "self-consumption", when forbidding discharge forbids the discharge you want.
The installer's configuration worked because slot power 15000 permitted discharge while peak shaving at 100 W decided how much was used. Two registers, two jobs:
| Register | Job |
|---|---|
| 154–159 slot power | the battery's permission to discharge, and its ceiling |
| 191 (with shaving on) | how much grid import to allow before using that permission |
Fix: Auto sets slot power to a configurable discharge permission (default 12000 W, never 0) and owns 191 as a configurable maximum grid import (default 100 W). Protection against the latent dump condition does not come from zeroing slot power — it comes from the reconciler asserting peak shaving enabled on every pass, which is precisely what the installer's setup lacked.
Still unconfirmed: that Auto now makes the battery serve load. Two attempts were defeated by generation exceeding load (deficits of −135 W and −67 W). The overnight soak will settle it, and 191's threshold semantics need re-testing now that discharge is permitted at all.
Its second reconcile pass wrote {128: 37} having just written 38. The target is
computed from live battery voltage, so ordinary drift flips the rounded amp value
and an EEPROM register gets rewritten once a minute — exactly what the reconciler
exists to prevent.
Fixed with a 2 A deadband: the existing value is kept when the new target is within tolerance. A test sweeps 53.45–53.62 V and asserts no rewrite.
Auto seeded slot flags by mirroring slot 1's "Allow Grid" to all six slots. That was benign only because register 127 starts grid charge below 30 % SOC and the battery happened to be at 98 %. Now seeded to "No Grid or Gen", with the mode bits preserved, and Force charge raises it deliberately — consistent with how slot power is handled.
Symptom. Battery charging pinned around 1200 W. Batteries not reaching full by end of day, and solar exporting while the battery still had room.
Measured, 2026-08-21. Battery 53.60 V, 95 % SOC, charging at 1223 W, with 997 W on the DC MPPTs plus 6942 W on the AUX port against 350 W of load — 7.6 kW of surplus, of which 4.5 kW was exporting.
The candidate ceilings, converted to watts at the live pack voltage:
| Register | Meaning | Value | Ceiling |
|---|---|---|---|
| 108 | Battery max charge current | 24 A | 1286 W |
| 128 | Grid charge current (caps total, finding 14) | 220 A | 11792 W |
| 218 | BMS max charge current | 580 A | 31088 W |
| 212 | BMS charge current limit | 560 A | 30016 W |
1223 W is 95 % of 1286 W. Charge power was pinned at register 108.
Confirmed by experiment, not inference. A single bracketed write of 108 = 240:
t V SOC batt W chg W 108 128 108xV
20 53.58 95 -915 915 24 220 1286 <- control
44 53.62 95 -1908 1908 240 220 12869 <- released
55 53.59 95 -1111 1111 24 220 1286 <- pushed back
Charge power doubled immediately. Then 108 returned to 24 after 55 s without being written by the test — so the integration itself was enforcing it. The whole settings image showed no couplings; the only registers that moved were 64 (clock seconds), 215 and 216 (BMS telemetry), all of which drift on their own. State was left byte-identical because the integration performed the restore itself.
Root cause, a four-step chain. Every step was individually defensible:
charge_currentwasNone, so Auto omitted 108 from its targets entirely and did not manage it.- Force charge wrote 108 down to its own computed grid current — 24 A for a ~1.3 kW request.
- Returning to Auto left it there. Nothing restored it.
- A Home Assistant restart seeded
charge_currentfrom live register 108, which laundered the Force-charge leftover into persisted desired state. From then on Auto actively enforced 24 A on every reconcile, and no manual fix would stick.
Step 4 is the nasty one. Before the restart this was a stale register; after it, it was a stated preference, indistinguishable from one the owner had typed in.
The original hypothesis was wrong. Finding 14 records that 128 caps total charge current rather than grid-sourced current alone, and 128 is written only by Force charge, so 128 looked like the obvious culprit. It read 220 A and was not binding. 128 escaped only by luck — it was left over from a 12 kW Force charge rather than a small one. Had the last Force charge been the 1.3 kW one, 128 would have been at 24 A too and the same symptom would have had two causes.
Fix. DEFAULT_CHARGE_CURRENT / DEFAULT_DISCHARGE_CURRENT /
GRID_CHARGE_CURRENT_OPEN in const.py, all 240 A:
- Auto owns 108, 109 and 128 unconditionally, falling back to the permissive constants when no entity value is set. An explicit limit from the user or Predbat is still honoured exactly.
- Hold owns them too, blocking charge with 108 = 0 while holding 128 open. Both registers cap total charge current, so zeroing both buys nothing, and leaving 128 alone keeps the commonest manual transition from rewriting an EEPROM register twice.
- 108 and 109 are no longer seeded from the inverter. This is the same rule
already applied to
slot_power, which was excluded from seeding for exactly this reason — 108 and 109 were simply left out of it. - Persisted setpoints are versioned. Version 2 drops the stored
charge_currentanddischarge_currentonce, with a loud log line, because a laundered value cannot be told apart from a deliberate one.
The generalisation, which is the real lesson. This is the third instance of one bug class:
| Register | What happened |
|---|---|
| 191 | Force charge never raised it, so grid charge was throttled to 100 W |
| 178 | never polled, so its base was fabricated and five unrelated fields were wiped |
| 108 | Force charge lowered it, Auto omitted it, and a restart made Auto enforce the leftover |
Auto and Hold are the resting modes; every Force mode is transient and exits into one of them, on the reversion timer if not by hand. So the invariant is:
A register that any mode writes must be given a defined value by every resting mode.
test_resting_modes_own_every_register_any_mode_writes enforces this
mechanically, so a fourth instance fails in CI rather than on the roof. Slot SOC
(166–171) and slot flags (172–177) are exempt: Auto governs them from entity
state that Predbat owns, and Hold composes with the existing TOU block by design.
Residual, not yet addressed. slot_soc and prog_flags are still seeded from
the inverter, so they remain launderable in principle. Lower stakes — the Force
modes write 20 % and 100 % respectively, and 20 % equals the configured floor —
but it is the same shape of problem.
Scripts. scripts/sunsynk_chargecap_probe.py computes which ceiling binds and
compares it against observed charge power. scripts/sunsynk_chargecap_test.py
performs the bracketed write test and reports both whether 108 is the cap and
whether anything is enforcing it.
23. Predbat's charge rate latched at 1291 W — apps.yaml fed a written register back as a hardware limit
Diagnosed from inside Home Assistant via the REST API (scripts/ha_query.py),
after Modbus alone could not distinguish two candidate causes.
What the API showed.
| Entity | Value | Last changed |
|---|---|---|
number...battery_max_charge_current |
24 | 2026-08-28T14:00:19 |
number...battery_max_discharge_current |
205 | 2026-08-29T15:00:19 |
sensor.predbat_sk_0_charge_rate |
1291.0 W | 2026-08-28T18:20:09 |
sensor.predbat_sk_0_discharge_rate |
11000.0 W | 2026-08-29T15:00:09 |
sensor...battery_max_charge_rate |
1285 W | live |
select.predbat_mode |
Control charge & discharge | — |
205 A × 53.6 V = 11000 W and 24 A × 53.5 V = 1285 W. Predbat owns both registers. It was rewriting the discharge limit every cycle and had not touched the charge limit for 25 hours.
The latch. apps.yaml had:
timed_charge_current: number...battery_max_charge_current # WRITES register 108
inverter_limit_charge: sensor...battery_max_charge_rate # = min(108, 218) x VSo Predbat wrote register 108, then read the result back as the inverter's hardware charge ceiling:
Predbat writes 108 = 24 A
-> battery_max_charge_rate reads 1285 W
-> Predbat believes the inverter can only charge at 1285 W
-> it never requests more, so 108 is never raised
Self-reinforcing, and only escapable by an external write. The discharge side had the identical fault and escaped only because Predbat happened to keep rewriting 109 at a high value.
This was my error in the apps.yaml rebuild. The comment there claimed the sensor reported "min(inverter limit, BMS limit)", describing it as a capability. It is a readback of the configured setting.
How it compounded with finding 22. Two bugs, and the second made the first
permanent. Finding 22 leaves a low value in 108 after a Force charge. Ordinarily
that is transient. But inverter_limit_charge then reports the leftover as the
hardware ceiling, Predbat clamps its own request to it and writes that clamped
value into the number entity, and at that point the leftover has been promoted
into persisted desired state that Predbat itself maintains. A stale register
became a stated preference in two hops.
Fix. Two new computed sensors that exclude 108 and 109 by construction:
battery_charge_capability= min(MAX_BATTERY_CURRENT, BMS 218) × V, capped at the AC ratingbattery_discharge_capability= min(MAX_BATTERY_CURRENT, BMS 219) × V, capped
apps.yaml points inverter_limit_charge / inverter_limit_discharge at these.
battery_max_charge_rate is unchanged and still correct as a diagnostic — it
answers "what is the inverter configured to do", which is a different question
from "what can it do". Pointing Predbat's hardware limit at it was the bug, not
the sensor.
The rule. A value the controller writes must never also be the value it reads
to decide what is possible. Worth checking every remaining apps.yaml key against
this: any entity that appears in both an input mapping and a service block is a
candidate latch.
Tests. test_charge_capability_ignores_register_108 and
test_discharge_capability_ignores_register_109 fail if a capability sensor ever
starts tracking the configured limit again.
Home Assistant had not restarted. 821 entities shared a last_changed of
2026-08-27T09:17:07, so core had been up since then and the updated integration
files were on disk but never imported. Copying files into custom_components and
reloading the integration does not reload Python modules; only a full restart
does. Worth checking last_changed on a few unrelated entities before concluding
that a code change did not work.
There is a second writer to the inverter. A "Sunsynk / Deye Inverter Add-on (multi)" is installed and exposes write controls:
| Entity | Register |
|---|---|
switch.sunsynk_12kw_hybrid_use_timer |
146 — this integration owns it |
switch.sunsynk_12kw_hybrid_solar_export |
145 — in WRITE_FORBIDDEN |
switch.sunsynk_12kw_hybrid_grid_charge_enabled |
130 |
switch.sunsynk_12kw_hybrid_priority_load |
141 |
Most of its sensors read unknown, so it is probably not polling successfully,
but the switches are live. Two things writing 145 and 146 is the conflict this
file already warns about under "Conflict with the existing sunsynk_inverter
integration", now confirmed present rather than hypothetical.
Related and unresolved: register 128 moved from 220 to 240 during the 2026-08-29 investigation, at a time when this integration's new code was provably not loaded and the mode had been Auto since 2026-08-27. No code path here explains it. The add-on is the obvious suspect but this is not established. Until it is, treat 128 as potentially written by something else.
25. Register 142 decides whether the inverter sees the building or only its own load port — and I set it wrong
Symptom, 2026-09-05. A 2.4 kW air-conditioner on the main board drew from the grid while the battery sat at 96 % SOC and cheerfully powered the load terminals. The inverter's own grid terminal was pinned near 20 W, neither importing nor exporting. Grid CT clamps are at the meter, so the inverter could see the load; it simply did nothing about it.
Ruled out first, each by single-register A/B test with restore:
| Register | Hypothesis | Result |
|---|---|---|
| 109 = 205 A | discharge current limited | 10,968 W available. Not it |
| 544 | DRM derate | 0x0001 only; no derate. My first bit table was invented and wrong |
| 141 = 0 | "Priority Load" wrong way round | flipped to 1: import 2429 -> 2422 W. No effect |
| 104 = 20 W | zero-export setpoint throttling | raised to 15000: terminal 23 -> 54 W. No effect |
| 178 = 0x0EBA | peak shaving disabled | documented mask 0x0030, value 48 = ENABLED. Not it |
The cause. Register 142, with the AC running and the battery at 96 %:
142 = 0 grid CT 474 W load port 662 W battery 705 W
142 = 2 grid CT 40 W load port 886 W battery 910 W
Applied permanently: grid import settled at 35-49 W.
So the documented names are misleading:
| 142 | Documented | Actual behaviour |
|---|---|---|
| 0 | Allow Export | serves ONLY its own load terminals, ignores the grid CT |
| 2 | Zero Export | regulates the grid CT to near zero: whole-building compensation |
2 does not mean "refuse to export", it means "regulate at the meter". Register 104 is that loop's setpoint, which is exactly why testing 104 while 142 = 0 did nothing — the loop it configures was not running.
This was my error. The note in const.py read "the site has DNO permission for
full export; zero export was a misconfiguration", and 142 was changed from the
installer's 2 to 0. The installer was right. I inferred intent from the register's
name instead of testing what it did — the same mistake as the DRM bit table and
the AUX register offsets, all three in this one investigation.
Why it hid for weeks. Building load was around 500 W and mostly on the load terminals, so the CT loop had nothing to do and its absence was invisible. The AC was the first substantial main-board load since the change. "It worked yesterday" was true but misleading: yesterday's load was a flat ~500 W all day and grid import averaged 8 W, so the inverter was never asked.
Still to verify, in daylight. Whether 142 = 2 suppresses export. It could not be tested at night with no generation. Register 143 is 15000 W and 104 is 20 W, so if export is suppressed the next experiment is raising 104 to see whether the CT loop will follow the meter and export beyond it. If the two turn out to be mutually exclusive on this firmware, it becomes a real policy choice between whole-house coverage and export revenue.
Code. The integration does not write 142 and should not: the choice is site
policy, it interacts with the DNO agreement, and writing 142 can move 145. The old
constants LOAD_LIMIT_ALLOW_EXPORT / SOLAR_EXPORT_ON were dead — declared but
never wired to anything, so the "corrected once at setup" comment described code
that did not exist. Replaced with LOAD_LIMIT_FOLLOW_GRID_CT and
LOAD_LIMIT_LOAD_PORT_ONLY, named for what they do.
Watch out: the old sunsynk_inverter integration has a select that writes 142.
If it is ever enabled alongside this one, it can silently undo this.
Predbat needs no change. Its four service blocks touch prog1-6 (172-177),
work_mode (178 plus slot power), max_grid_import (191), the two current limits
(108, 109) and charge_limit (166-171). None of them go near 142, so Predbat
could not have caused finding 25 and cannot cause it again.
The actual gap was that nothing owned 142 and nothing watched it. It was in
WRITE_ALLOWLIST and polled as a diagnostic sensor, but no mode had an opinion
about it, so it kept whatever last wrote it. That is the identical failure to 108,
191 and 178 - the fourth instance of the same bug class.
What changed.
-
DesiredState.load_limit, applied by_apply_site_policyin every mode, not just the resting ones. Register 142 is not mode state, but it still has to be owned by something. -
select.sunsynk_..._load_limit, a CONFIG entity. Options carry the measured behaviour rather than the manufacturer's misleading names:Value Label Behaviour 0 Allow export (load port only) serves only its load terminals, ignores the grid CT 1 Essentials untested on this unit 2 Zero export (follow grid CT) regulates at the meter: whole-building compensation -
Seeded from the inverter on first load, not defaulted. Which mode a site wants depends on its CT wiring and DNO agreement, so inventing a value would override the installer. This is safe to seed, unlike 108 and 109, precisely because no mode writes it and so there is no leftover to launder.
-
Selecting "load port only" logs a warning naming finding 25, because that is the setting that caused it.
-
Attributes surface
inverter_value,awaiting_reconcile,managed, and register 104 - which only does anything in follow-grid-CT mode, saving the next person the experiment that established it.
Consequence worth understanding. Once adopted, 142 is enforced on every
reconcile. The Home Assistant select becomes the authority, so the old
sunsynk_inverter integration, the Deye add-on and the installer app can no
longer change it without being put back within a poll. That is the point, but it
does mean a deliberate change has to be made through Home Assistant.
Tests. All four modes apply it when managed and none touch it when unmanaged; a disagreeing inverter produces a delta while an agreeing one produces no write; the option labels round-trip; and the labels are asserted to mention "grid CT" and "load port" so the manufacturer's bare names cannot creep back and mislead the next reader.
Owner-reported 2026-09-05. With 142 = 2 ("Zero export", following the grid CT) a Force discharge only powers the loads and will not export. Exporting needs 142 = 0. Force charge works in either value.
This completes finding 25. Neither value is simply correct; each gives up one capability for the other:
| 142 | Grid CT | Export | Whole-building compensation |
|---|---|---|---|
| 0 | ignored | yes | no - main-board loads fall to the grid |
| 2 | followed | no | yes |
It also shows the naming I chose in finding 26 was itself misleading.
LOAD_LIMIT_LOAD_PORT_ONLY for value 0 is true of its CT behaviour but hides that
0 is the value export depends on, which reads as nonsense in the Force discharge
path. Renamed to carry both halves:
LOAD_LIMIT_ALLOW_EXPORT_NO_CT= 0LOAD_LIMIT_ZERO_EXPORT_FOLLOW_CT= 2
and the select labels are now "Allow export (ignores grid CT)" and "Zero export (follows grid CT)", with a test asserting both mention the CT. Three passes to get this register's naming honest, each time because a name described half of what it does.
Logic. A new wants_to_export(desired) predicate, and _apply_site_policy
overrides 142 to 0 when it is true:
| Mode | 142 | Verified |
|---|---|---|
| Auto, Limited to Home | owner's value | yes |
| Auto, Selling First | 0 | no - inferred from the same mechanism |
| Hold | owner's value | no |
| Force charge | owner's value | yes, owner reports unaffected |
| Force discharge, > 0 W | 0 | yes |
| Force discharge, 0 W | owner's value | falls back to self-consumption, so not exporting |
Returning to Auto drops the override and the owner's value applies again. No snapshot is needed, for the same reason as 108, 109 and 191: the override is computed from desired state, never stored.
Unmanaged sites are left alone. If 142 has not been adopted yet there is no recorded baseline to restore, so forcing 0 would orphan it permanently - finding 22 exactly. An exporting mode logs a warning and leaves the register untouched instead.
Write ordering matters here and is tested. Disabling peak shaving starts dispatch, so 178 is written last and 142 (the export permission) must already be in place. Re-enabling shaving stops dispatch, so 178 goes first and 142 is restored after. Two tests assert the orderings directly rather than trusting that address-sorted order happens to be right.
Consequence to understand. While a Force discharge is running, whole-building compensation is suspended: main-board loads draw from the grid for the duration. That is unavoidable on this firmware - the inverter cannot follow the CT and export at the same time. It is bounded by the reversion timer.
Still unverified: whether Auto + Selling First genuinely needs the override. It is inferred from the same mechanism rather than measured, and is marked as such in the code. Also untested: what "Essentials" (1) does on this unit, and whether Hold should follow the owner's value or force export.