fix(sunsynk): make the settings write actually work — group, Sell flag, encodings - #4589
Merged
Conversation
…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>
Contributor
There was a problem hiding this comment.
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-derivedtime{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.
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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}Enis 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 — bareTrue/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 is0:3. Boolean encoding
Booleans must be the strings
"true"/"false", not bare JSON booleans — the opposite of what solarsynkv3'sReplaceTRUE()helper implied, which was the original basis for the guess.sellTime{n}Enis the exception: it takes the numeric"1"/"0"the API returns for it.saturdayOnflipping correctly in the same write that droppedtime2onis 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 istime{n}on, lower case.Verification
A combined charge and export plan, written through the real component:
All 7 Sunsynk suites and the full
--quickrun 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-casetime{n}onalone, so both likely have the same silently-failing write.deye.pymodels its slots on the same registers and may share the gap; worth checking whetherTimeUseSettingItemhas an equivalent enable that Predbat is not setting.🤖 Generated with Claude Code