Skip to content

fix(sunsynk): make the settings write actually work — group, Sell flag, encodings - #4589

Merged
springfall2008 merged 2 commits into
mainfrom
fix/sunsynk-system-mode-write
Aug 19, 2026
Merged

fix(sunsynk): make the settings write actually work — group, Sell flag, encodings#4589
springfall2008 merged 2 commits into
mainfrom
fix/sunsynk-system-mode-write

Conversation

@springfall2008

Copy link
Copy Markdown
Owner

First live write test against a real Sunsynk inverter. Control could never have worked before this — three separate defects each independently blocked it, and every one is invisible from the API's responses, which report {"code":0,"msg":"Success","success":true} for writes it silently discards.

The inverter was returned to its original settings after every probe, verified each time.

1. The endpoint discards an oversized object

Posting all 350 settings keys returned success and changed nothing — confirmed twice, including a probe that altered a single field and preserved every original string type. Posting only the System Mode group made the identical change persist immediately.

The whole-object read-modify-write the design was built on is simply wrong for this API. Predbat now sends that group only, which is also safer: battery, grid and generator settings are never transmitted by a schedule write, so a schedule write cannot disturb them.

2. The per-slot Sell flag was missing entirely

sellTime{n}En is the third per-slot flag in the app, alongside Grid Charge and Gen Charge. It must be 1 for a forced export slot, so export windows could never have armed.

Worse, its absence from the payload also made the API silently drop time{n}on. Grid charge failed to write on six consecutive attempts — bare True/False, "true"/"false", "1", 1, "True", "on" — while the rest of each write persisted every time. The API validates the per-slot field set as a whole and drops the flags if it is incomplete.

With all the flags present they are independent, proven in a single write by setting grid charge on a slot whose Sell flag is 0, and the Sell flag on a slot whose grid charge is 0:

slot2  gridChg=true  En=0  ->  both landed exactly as sent
slot4  gridChg=false En=1  ->  both landed exactly as sent

3. Boolean encoding

Booleans must be the strings "true"/"false", not bare JSON booleans — the opposite of what solarsynkv3's ReplaceTRUE() helper implied, which was the original basis for the guess. sellTime{n}En is the exception: it takes the numeric "1"/"0" the API returns for it.

saturdayOn flipping correctly in the same write that dropped time2on is what proved string booleans work in general and isolated the problem to the per-slot set.

Also: a server-derived field

time{n}On (capital O) must never be written. It changed from '0' to '65' on a write that never mentioned it, and writing '1' produced '65' too. It is not the boolean it resembles; the writable grid-charge flag is time{n}on, lower case.

Verification

A combined charge and export plan, written through the real component:

 slot  start   cap   gridChg  sell   power
  1    00:00    20%   false    0     8000
  2    02:00    90%   true     0     3000   <- charge window
  3    04:00    20%   false    0     8000
  4    05:00    20%   false    0     8000
  5    16:00    30%   false    1     2500   <- export window
  6    19:00    20%   false    0     8000
  • 33/33 owned fields written and read back exactly
  • 0 of the 350 settings keys lost
  • nothing outside the System Mode group changed
  • inverter restored to its original settings, 33/33

All 7 Sunsynk suites and the full --quick run pass. New tests pin the group restriction, the Sell flag's presence on every slot and its value on export slots, the quoted-boolean encoding, and that the server-derived field is never transmitted.

Note for the DEYE component

Neither solarsynkv3 nor synkctl writes sellTime{n}En — both use the lower-case time{n}on alone, so both likely have the same silently-failing write. deye.py models its slots on the same registers and may share the gap; worth checking whether TimeUseSettingItem has an equivalent enable that Predbat is not setting.

🤖 Generated with Claude Code

…g, encodings

First live write test against a real inverter. Control could never have
worked before this: three separate defects each blocked it.

1. The write endpoint silently discards an oversized object. Posting all
   350 settings keys returned {"code":0,"msg":"Success","success":true} and
   changed nothing, twice, including a probe altering a single field with
   every original type preserved. Posting only the System Mode group made
   the identical change persist at once. Predbat now sends that group.
   This is also safer: battery, grid and generator settings are never
   transmitted by a schedule write, so they cannot be disturbed.

2. The per-slot Sell flag, sellTime{n}En, was missing entirely. It is the
   third per-slot flag in the app alongside Grid Charge and Gen Charge, and
   it must be 1 for a forced export slot - so export windows could never
   have armed. Worse, its ABSENCE from the payload made the API silently
   drop time{n}on too: grid charge failed to write on six consecutive
   attempts across every encoding tried, while the rest of each write
   persisted. The API validates the per-slot field set as a whole. With the
   flag present the three are independent, proven by setting grid charge on
   a slot whose sell flag is 0 and vice versa in one write.

3. Boolean fields must be the strings "true"/"false", not bare JSON
   booleans - the opposite of what solarsynkv3's ReplaceTRUE() implied.
   sellTime{n}En is the exception, taking the numeric "1"/"0" the API
   returns for it.

Also: time{n}On (capital O) is server-derived and must never be written. It
went from '0' to '65' on a write that never mentioned it, and writing '1'
produced '65' as well.

Verified live with a combined charge and export plan: 33/33 owned fields
written and read back exactly, 0 of the 350 keys lost, nothing outside the
group changed, and the inverter restored to its original settings 33/33.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 19, 2026 08:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes Sunsynk schedule/control writes by aligning Predbat’s settings payload with the Sunsynk Cloud API’s real constraints (writeable field group restriction, required per-slot Sell flag, and string-boolean encoding), so that inverter settings changes actually persist.

Changes:

  • Restrict settings writes to the “System Mode” settings group only, avoiding full-settings read/modify/write that the endpoint silently discards.
  • Add and populate the per-slot Sell enable flag (sellTime{n}En) and ensure per-slot flag sets are complete so writes aren’t dropped.
  • Update boolean encoding to the API-required "true"/"false" strings and add tests that pin the new behavior (including never writing server-derived time{n}On).

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
apps/predbat/sunsynk.py Builds payloads from System Mode baseline only; adds per-slot Sell flag handling; avoids leaking server-derived slot fields.
apps/predbat/sunsynk_const.py Adds System Mode field allowlist, derived-field denylist, Sell flag template, and updates boolean encoding behavior.
apps/predbat/tests/test_sunsynk_control.py Expands control/payload tests to enforce group restriction, Sell flag presence/value, string-boolean encoding, and derived-field exclusion.
apps/predbat/tests/test_sunsynk_const.py Updates encoding expectations to match quoted boolean behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread apps/predbat/sunsynk.py Outdated
Comment thread apps/predbat/sunsynk_const.py Outdated
springfall2008 added a commit that referenced this pull request Aug 19, 2026
Follow-up to #4589's note asking whether deye.py shares the per-slot gap
found on Sunsynk. It does not - but the same class of defect is here at
the top level instead.

**touDays was never sent.** DEYE's TOU programme runs only on the days
named in touDays, and Predbat sent touAction without it, leaving the
active days at whatever the inverter already held. A programme whose days
are empty, or which omits the day the plan is for, is stored and never
applied - the same silent failure as Sunsynk's dropped flags, at a
different level. All four official strategy samples
(clientcode/strategy/dynamic_control_*.py) send the full seven-day list,
and the endpoint contract in the design spec has carried touDays[] since
the beginning; it simply never reached the payload. Sunsynk sets its seven
mondayOn..sundayOn flags for exactly this reason.

All seven is the only correct value: Predbat re-derives a 24h programme
every cycle and has no notion of a day its plan should be dormant.

**The per-slot field set is complete, so the Sunsynk failure cannot
occur.** TimeUseSettingItem is exactly {time, power, soc, enableGridCharge,
enableGeneration} in the official commission sample and in all four
strategy samples, and Predbat writes all five on every slot. There is no
sell/export enable to omit: that bit exists in the underlying register
(prog{n} 0x40 single-phase, 0x20 three-phase) but the DEYE cloud does not
expose it per slot, deriving export from workMode instead. A new test pins
the field set rather than trusting each call site to remember it - proven
to fail by dropping one field.

**Two inferences from #4580 are now confirmed from DEYE's own side.**
ZERO_EXPORT_TO_CT for non-export states and solarSellAction on were both
taken from Sunsynk hardware and flagged as unverified on DEYE. The
official self-consumption sample is exactly ZERO_EXPORT_TO_CT with
solarSellAction on; three of the four samples send solarSellAction on.
Recorded in the comments where those decisions live.

Follows up #4589

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…enset the group

Both from Copilot's review of #4589.

The per-slot Sell flag was encoded in-line rather than through
encode_setting(), duplicating wire-encoding logic that every other owned
field routes through one place. It now goes through it, passed as 1/0
rather than a bool: sellTime{n}En is deliberately not in
SUNSYNK_BOOL_FIELDS, because the API returns it as "1"/"0" unlike
time{n}on's "true"/"false", so it falls through to str() - and str(True)
would be "True".

SUNSYNK_SYSTEM_MODE_FIELDS is only ever used for membership tests, so it
becomes a frozenset: O(1) lookups against a ~350-key baseline instead of a
scan, and it cannot be mutated by accident.

Payload output is unchanged - verified the emitted sellTime{n}En values and
types are identical before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
springfall2008 added a commit that referenced this pull request Aug 19, 2026
…ot exist

The previous commit said DEYE's TimeUseSettingItem has no per-slot sell
enable, so the Sunsynk defect in #4589 could not apply here. That was
wrong, and it was wrong because it took the sample code as the contract.

The official Swagger definition behind developer.deyecloud.com/api
(GET https://eu1-developer.deyecloud.com/v2/api-docs) lists enableSell on
TimeUseSettingItem. Every sample in the sample-code repo omits it - each
one is a whole-day single behaviour, so none ever needed one slot to sell
and another not to - which is exactly why reading the samples instead of
the spec gave the wrong answer.

So DEYE has the same gap Sunsynk did:

- enableSell is the slot's forced-export flag, the same register bit
  Sunsynk exposes as sellTime{n}En, where a live write test proved an
  export slot does not arm without it. Predbat never sent it, so a DEYE
  export window had the selling-first work mode and the SOC target but not
  the flag that makes the slot an export slot.
- It is now written on EVERY slot, False where the state does not sell.
  On Sunsynk an absent per-slot flag made the API silently discard the
  other flags in the same item - grid charge included - on six consecutive
  writes that each reported success.

It follows solar_sell, so it is on for export and freeze-export and off
for charge, freeze-charge, hold and idle - the same mapping the Sunsynk
fix uses.

The spec's "voltage" field stays unwritten: it applies to battery voltage
mode, and Predbat drives SOC targets, so it has nothing to put there.
Sunsynk leaves sellTime{n}Volt alone for the same reason.

The touDays fix in the previous commit is unaffected, and the spec
confirms it independently: touDays is typed as an enum of the seven
upper-case day names, documented as "If action is on, fill this field with
the days of the week you want to switch on".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@springfall2008
springfall2008 merged commit 5eadb51 into main Aug 19, 2026
2 checks passed
@springfall2008
springfall2008 deleted the fix/sunsynk-system-mode-write branch August 19, 2026 18:47
springfall2008 added a commit that referenced this pull request Aug 19, 2026
Follow-up to #4589's note asking whether deye.py shares the per-slot gap
found on Sunsynk. It does not - but the same class of defect is here at
the top level instead.

**touDays was never sent.** DEYE's TOU programme runs only on the days
named in touDays, and Predbat sent touAction without it, leaving the
active days at whatever the inverter already held. A programme whose days
are empty, or which omits the day the plan is for, is stored and never
applied - the same silent failure as Sunsynk's dropped flags, at a
different level. All four official strategy samples
(clientcode/strategy/dynamic_control_*.py) send the full seven-day list,
and the endpoint contract in the design spec has carried touDays[] since
the beginning; it simply never reached the payload. Sunsynk sets its seven
mondayOn..sundayOn flags for exactly this reason.

All seven is the only correct value: Predbat re-derives a 24h programme
every cycle and has no notion of a day its plan should be dormant.

**The per-slot field set is complete, so the Sunsynk failure cannot
occur.** TimeUseSettingItem is exactly {time, power, soc, enableGridCharge,
enableGeneration} in the official commission sample and in all four
strategy samples, and Predbat writes all five on every slot. There is no
sell/export enable to omit: that bit exists in the underlying register
(prog{n} 0x40 single-phase, 0x20 three-phase) but the DEYE cloud does not
expose it per slot, deriving export from workMode instead. A new test pins
the field set rather than trusting each call site to remember it - proven
to fail by dropping one field.

**Two inferences from #4580 are now confirmed from DEYE's own side.**
ZERO_EXPORT_TO_CT for non-export states and solarSellAction on were both
taken from Sunsynk hardware and flagged as unverified on DEYE. The
official self-consumption sample is exactly ZERO_EXPORT_TO_CT with
solarSellAction on; three of the four samples send solarSellAction on.
Recorded in the comments where those decisions live.

Follows up #4589

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
springfall2008 added a commit that referenced this pull request Aug 19, 2026
…ot exist

The previous commit said DEYE's TimeUseSettingItem has no per-slot sell
enable, so the Sunsynk defect in #4589 could not apply here. That was
wrong, and it was wrong because it took the sample code as the contract.

The official Swagger definition behind developer.deyecloud.com/api
(GET https://eu1-developer.deyecloud.com/v2/api-docs) lists enableSell on
TimeUseSettingItem. Every sample in the sample-code repo omits it - each
one is a whole-day single behaviour, so none ever needed one slot to sell
and another not to - which is exactly why reading the samples instead of
the spec gave the wrong answer.

So DEYE has the same gap Sunsynk did:

- enableSell is the slot's forced-export flag, the same register bit
  Sunsynk exposes as sellTime{n}En, where a live write test proved an
  export slot does not arm without it. Predbat never sent it, so a DEYE
  export window had the selling-first work mode and the SOC target but not
  the flag that makes the slot an export slot.
- It is now written on EVERY slot, False where the state does not sell.
  On Sunsynk an absent per-slot flag made the API silently discard the
  other flags in the same item - grid charge included - on six consecutive
  writes that each reported success.

It follows solar_sell, so it is on for export and freeze-export and off
for charge, freeze-charge, hold and idle - the same mapping the Sunsynk
fix uses.

The spec's "voltage" field stays unwritten: it applies to battery voltage
mode, and Predbat drives SOC targets, so it has nothing to put there.
Sunsynk leaves sellTime{n}Volt alone for the same reason.

The touDays fix in the previous commit is unaffected, and the spec
confirms it independently: touDays is typed as an enum of the seven
upper-case day names, documented as "If action is on, fill this field with
the days of the week you want to switch on".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants