Skip to content

Commit fb57b1f

Browse files
fix(sunsynk): make the settings write actually work - group, sell flag, 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>
1 parent 004f298 commit fb57b1f

4 files changed

Lines changed: 237 additions & 37 deletions

File tree

apps/predbat/sunsynk.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
Registers each discovered Sunsynk inverter as a ``SunsynkCloud`` Predbat inverter,
1212
publishing monitoring sensors and DEYE-style schedule control entities. Predbat drives
1313
those entities through the generic Inverter class; this module derives the Sunsynk work
14-
mode internally and applies it by read-modify-write of the whole settings object.
14+
mode internally and applies it by read-modify-write of the System Mode settings group
15+
(the write endpoint silently discards anything larger - see SUNSYNK_SYSTEM_MODE_FIELDS).
1516
1617
Three auth methods: ``password`` (RSA-encrypted login, the default), ``password_legacy``
1718
(the pre-2025 plaintext login, opt-in) and ``oauth`` (token injected by Predbat.com).
@@ -57,6 +58,8 @@
5758
SUNSYNK_SOLAR_SELL_FIELD,
5859
SUNSYNK_TOU_ENABLE_FIELD,
5960
SUNSYNK_SERIAL_FIELD,
61+
SUNSYNK_SYSTEM_MODE_FIELDS,
62+
SUNSYNK_DERIVED_SLOT_FIELDS,
6063
SUNSYNK_DAY_FIELDS,
6164
TOU_FIELD,
6265
TOU_SLOT_COUNT,
@@ -527,7 +530,11 @@ async def fetch_device_data(self, sn):
527530
return values
528531

529532
async def fetch_settings(self, sn):
530-
"""Read the whole settings object, which is both config and the write baseline."""
533+
"""Read the whole settings object: config, plus the baseline the write group is built from.
534+
535+
The read returns everything (350 keys on a real inverter); only the System Mode subset
536+
of it is ever posted back. See SUNSYNK_SYSTEM_MODE_FIELDS.
537+
"""
531538
data = await self._get("settings_read", sn=sn)
532539
if data:
533540
self.device_settings[sn] = data
@@ -702,11 +709,11 @@ def _self_use_slot(self, start_time, reserve, self_use_power):
702709
the battery serving the house for the whole interval and push the load onto the
703710
grid. Self-use slots cover most of the day, so this is the default state.
704711
"""
705-
return {"time": start_time, "power": int(self_use_power), "soc": int(reserve), "grid_charge": False}
712+
return {"time": start_time, "power": int(self_use_power), "soc": int(reserve), "grid_charge": False, "sell": False}
706713

707714
def _action_slot(self, start_time, state):
708715
"""Build a slot realising a derived control state."""
709-
return {"time": start_time, "power": int(state["power"]), "soc": int(state["slot_soc"]), "grid_charge": bool(state["grid_charge"])}
716+
return {"time": start_time, "power": int(state["power"]), "soc": int(state["slot_soc"]), "grid_charge": bool(state["grid_charge"]), "sell": bool(state.get("solar_sell"))}
710717

711718
def build_tou_slots(self, schedule, current_soc, self_use_power):
712719
"""Build exactly TOU_SLOT_COUNT ordered slots covering 24h from the schedule windows.
@@ -848,13 +855,19 @@ def _owned_payload(self, sn, schedule, current_soc, now_minutes):
848855
payload[TOU_FIELD["power"].format(n=index)] = encode_setting(TOU_FIELD["power"].format(n=index), slot["power"])
849856
payload[TOU_FIELD["soc"].format(n=index)] = encode_setting(TOU_FIELD["soc"].format(n=index), slot["soc"])
850857
payload[TOU_FIELD["grid_charge"].format(n=index)] = encode_setting(TOU_FIELD["grid_charge"].format(n=index), slot["grid_charge"])
858+
# The per-slot Sell flag ("Sell" in the app). It MUST be 1 for a forced export
859+
# slot, and every per-slot flag must be present in the payload or the API
860+
# silently discards them all - see TOU_FIELD.
861+
payload[TOU_FIELD["sell"].format(n=index)] = "1" if slot["sell"] else "0"
851862
return payload
852863

853864
def build_settings_payload(self, sn, schedule, current_soc, now_minutes=None):
854865
"""Build the full settings object to POST for one inverter.
855866
856-
Read-modify-write: start from the last-read settings so every field Predbat does
857-
not own survives verbatim, then overwrite only the slots, mode and flags it does.
867+
Read-modify-write over the System Mode group only: start from the last-read values
868+
for those fields so the ones Predbat does not own survive verbatim, then overwrite the
869+
slots, mode and flags it does. Fields outside the group are never sent - the endpoint
870+
discards an oversized object entirely.
858871
Returns {} when self.device_settings holds no baseline for sn - a payload built
859872
from an empty baseline would contain only the owned keys, and posting it would
860873
drop every installer setting Predbat does not own. This is a public producer, not
@@ -867,7 +880,11 @@ def build_settings_payload(self, sn, schedule, current_soc, now_minutes=None):
867880
return {}
868881
if now_minutes is None:
869882
now_minutes = self._now_minutes()
870-
payload = dict(baseline)
883+
# Only the System Mode group is sent. The endpoint accepts a larger object and then
884+
# silently discards the whole write - see SUNSYNK_SYSTEM_MODE_FIELDS - so restricting
885+
# this is what makes the write land at all. It also means a schedule write can never
886+
# disturb the battery, grid or generator settings: they are simply never transmitted.
887+
payload = {key: value for key, value in baseline.items() if key in SUNSYNK_SYSTEM_MODE_FIELDS and key not in SUNSYNK_DERIVED_SLOT_FIELDS}
871888
payload.update(self._owned_payload(sn, schedule, current_soc, now_minutes))
872889
return payload
873890

apps/predbat/sunsynk_const.py

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -94,25 +94,75 @@
9494
}
9595

9696
# Per-slot field name templates, rendered with n = 1..TOU_SLOT_COUNT.
97+
# Per-slot fields Predbat writes. CONFIRMED live (2026-08-19) that all of "sell",
98+
# "grid_charge" and their siblings must be present in the payload together: with
99+
# sellTime{n}En absent, time{n}on was silently discarded on six consecutive writes across
100+
# every encoding tried, while every other field of the same write persisted. Including it
101+
# made time{n}on stick immediately. The API evidently validates the per-slot field set as a
102+
# whole and drops the flags if it is incomplete.
103+
#
104+
# The three flags are INDEPENDENT once all are present - proven by setting grid charge on a
105+
# slot whose sell flag is 0, and the sell flag on a slot whose grid charge is 0, in one
106+
# write: both landed exactly as sent.
97107
TOU_FIELD = {
98108
"time": "sellTime{n}",
99109
"power": "sellTime{n}Pac",
100110
"soc": "cap{n}",
101111
"grid_charge": "time{n}on",
112+
"sell": "sellTime{n}En",
102113
}
103114

115+
# NEVER write this. time{n}On (capital O) is server-derived: it changed from '0' to '65' on
116+
# a write that did not mention it at all, and writing '1' to it also produced '65'. It is
117+
# not the boolean it resembles, and the writable grid-charge flag is time{n}on (lower case).
118+
SUNSYNK_DERIVED_SLOT_FIELDS = tuple(f"time{n}On" for n in range(1, TOU_SLOT_COUNT + 1))
119+
104120
SUNSYNK_DAY_FIELDS = ["mondayOn", "tuesdayOn", "wednesdayOn", "thursdayOn", "fridayOn", "saturdayOn", "sundayOn"]
105121

122+
# The settings/set endpoint accepts ONLY the "System Mode" group of fields. CONFIRMED
123+
# live (inverter 2405116013, 2026-08-19): posting the full 350-key object returned
124+
# {"code":0,"msg":"Success","success":true} and changed NOTHING, twice, including a probe
125+
# that altered a single field and preserved every original string type. Posting just these
126+
# 53 keys with the same single change persisted immediately.
127+
#
128+
# So the whole-object read-modify-write this component originally used could never have
129+
# worked - every write was silently accepted and discarded. Predbat now sends this group
130+
# only, carrying through the fields inside it that it does not own (safetyType, battMode,
131+
# energyMode, zeroExportPower, solarMaxSellPower, pvMaxLimit, sellTime{n}Volt,
132+
# genTime{n}on). Everything outside the group - battery, grid, generator settings - is
133+
# never transmitted at all, so it cannot be disturbed.
134+
#
135+
# Field list taken from solarsynkv3's DetermineSettingCategory, which posts the same group.
136+
SUNSYNK_SYSTEM_MODE_FIELDS = (
137+
["sn", "safetyType", "battMode", "solarSell", "pvMaxLimit", "energyMode", "peakAndVallery", "sysWorkMode", "zeroExportPower", "solarMaxSellPower"]
138+
+ [f"sellTime{n}" for n in range(1, TOU_SLOT_COUNT + 1)]
139+
+ [f"sellTime{n}Pac" for n in range(1, TOU_SLOT_COUNT + 1)]
140+
+ [f"sellTime{n}Volt" for n in range(1, TOU_SLOT_COUNT + 1)]
141+
+ [f"sellTime{n}En" for n in range(1, TOU_SLOT_COUNT + 1)]
142+
+ [f"cap{n}" for n in range(1, TOU_SLOT_COUNT + 1)]
143+
+ ["mondayOn", "tuesdayOn", "wednesdayOn", "thursdayOn", "fridayOn", "saturdayOn", "sundayOn"]
144+
+ [f"time{n}on" for n in range(1, TOU_SLOT_COUNT + 1)]
145+
+ [f"genTime{n}on" for n in range(1, TOU_SLOT_COUNT + 1)]
146+
)
147+
106148
# Top-level settings keys Predbat owns.
107149
SUNSYNK_WORKMODE_FIELD = "sysWorkMode"
108150
SUNSYNK_SOLAR_SELL_FIELD = "solarSell"
109151
SUNSYNK_TOU_ENABLE_FIELD = "peakAndVallery"
110152
SUNSYNK_SERIAL_FIELD = "sn"
111153

112-
# VERIFY@SPIKE — solarsynkv3 carries a ReplaceTRUE() helper that rewrites the string
113-
# "true" to a bare true before posting, which is strong evidence the API needs real
114-
# JSON booleans for the per-slot and day flags while numeric fields stay quoted
115-
# strings. Declared per field here rather than guessed at each call site.
154+
# CONFIRMED live (inverter 2405116013, 2026-08-19) that these flags must be sent as the
155+
# STRINGS "true"/"false", not as bare JSON booleans.
156+
#
157+
# A write carrying bare booleans was accepted and every other field of it persisted - slot
158+
# times, target SoCs and powers all landed - while time1on and time2on alone were silently
159+
# discarded and read back at their previous values. Sending them quoted, exactly as the read
160+
# returns them, makes them stick.
161+
#
162+
# This is the opposite of what solarsynkv3's ReplaceTRUE() helper implied. That was the
163+
# original basis for guessing bare booleans, and it was wrong.
164+
# sellTime{n}En is deliberately NOT here: it is written as the numeric string "1"/"0",
165+
# which is how the API returns it, unlike time{n}on which uses "true"/"false".
116166
SUNSYNK_BOOL_FIELDS = frozenset([TOU_FIELD["grid_charge"].format(n=n) for n in range(1, TOU_SLOT_COUNT + 1)] + SUNSYNK_DAY_FIELDS)
117167

118168
# Values that mean False when Sunsynk hands a flag back as a string.
@@ -122,13 +172,14 @@
122172
def encode_setting(name, value):
123173
"""Serialise one settings value the way Sunsynk expects it on the wire.
124174
125-
Boolean fields (per-slot grid charge, day-of-week enables) go bare; every other
126-
field is quoted, because Sunsynk returns and accepts its numerics as strings.
175+
Everything is quoted. The boolean fields (per-slot grid charge, day-of-week enables)
176+
become the strings "true"/"false" rather than bare JSON booleans: the API silently
177+
discards a bare boolean while accepting the rest of the same write. See
178+
SUNSYNK_BOOL_FIELDS.
127179
"""
128180
if name in SUNSYNK_BOOL_FIELDS:
129-
if isinstance(value, str):
130-
return value.strip().lower() not in SUNSYNK_FALSE_STRINGS
131-
return bool(value)
181+
truthy = value.strip().lower() not in SUNSYNK_FALSE_STRINGS if isinstance(value, str) else bool(value)
182+
return "true" if truthy else "false"
132183
return str(value)
133184

134185

apps/predbat/tests/test_sunsynk_const.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -152,14 +152,15 @@ def test_encode_setting_types():
152152
if day not in SUNSYNK_BOOL_FIELDS:
153153
print(f"ERROR: day field {day} must be declared a boolean field")
154154
failed = True
155+
# Booleans are sent QUOTED - a bare JSON boolean is silently discarded by the API.
155156
cases = [
156-
("time1on", True, True),
157-
("time1on", "true", True),
158-
("time1on", 1, True),
159-
("time1on", False, False),
160-
("time1on", "false", False),
161-
("time1on", 0, False),
162-
("mondayOn", True, True),
157+
("time1on", True, "true"),
158+
("time1on", "true", "true"),
159+
("time1on", 1, "true"),
160+
("time1on", False, "false"),
161+
("time1on", "false", "false"),
162+
("time1on", 0, "false"),
163+
("mondayOn", True, "true"),
163164
("cap1", 95, "95"),
164165
("cap1", "95", "95"),
165166
("sellTime1", "02:00", "02:00"),

0 commit comments

Comments
 (0)