Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 28 additions & 7 deletions apps/predbat/sunsynk.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
Registers each discovered Sunsynk inverter as a ``SunsynkCloud`` Predbat inverter,
publishing monitoring sensors and DEYE-style schedule control entities. Predbat drives
those entities through the generic Inverter class; this module derives the Sunsynk work
mode internally and applies it by read-modify-write of the whole settings object.
mode internally and applies it by read-modify-write of the System Mode settings group
(the write endpoint silently discards anything larger - see SUNSYNK_SYSTEM_MODE_FIELDS).

Three auth methods: ``password`` (RSA-encrypted login, the default), ``password_legacy``
(the pre-2025 plaintext login, opt-in) and ``oauth`` (token injected by Predbat.com).
Expand Down Expand Up @@ -57,6 +58,8 @@
SUNSYNK_SOLAR_SELL_FIELD,
SUNSYNK_TOU_ENABLE_FIELD,
SUNSYNK_SERIAL_FIELD,
SUNSYNK_SYSTEM_MODE_FIELDS,
SUNSYNK_DERIVED_SLOT_FIELDS,
SUNSYNK_DAY_FIELDS,
TOU_FIELD,
TOU_SLOT_COUNT,
Expand Down Expand Up @@ -527,7 +530,11 @@ async def fetch_device_data(self, sn):
return values

async def fetch_settings(self, sn):
"""Read the whole settings object, which is both config and the write baseline."""
"""Read the whole settings object: config, plus the baseline the write group is built from.

The read returns everything (350 keys on a real inverter); only the System Mode subset
of it is ever posted back. See SUNSYNK_SYSTEM_MODE_FIELDS.
"""
data = await self._get("settings_read", sn=sn)
if data:
self.device_settings[sn] = data
Expand Down Expand Up @@ -702,11 +709,11 @@ def _self_use_slot(self, start_time, reserve, self_use_power):
the battery serving the house for the whole interval and push the load onto the
grid. Self-use slots cover most of the day, so this is the default state.
"""
return {"time": start_time, "power": int(self_use_power), "soc": int(reserve), "grid_charge": False}
return {"time": start_time, "power": int(self_use_power), "soc": int(reserve), "grid_charge": False, "sell": False}

def _action_slot(self, start_time, state):
"""Build a slot realising a derived control state."""
return {"time": start_time, "power": int(state["power"]), "soc": int(state["slot_soc"]), "grid_charge": bool(state["grid_charge"])}
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"))}

def build_tou_slots(self, schedule, current_soc, self_use_power):
"""Build exactly TOU_SLOT_COUNT ordered slots covering 24h from the schedule windows.
Expand Down Expand Up @@ -848,13 +855,23 @@ def _owned_payload(self, sn, schedule, current_soc, now_minutes):
payload[TOU_FIELD["power"].format(n=index)] = encode_setting(TOU_FIELD["power"].format(n=index), slot["power"])
payload[TOU_FIELD["soc"].format(n=index)] = encode_setting(TOU_FIELD["soc"].format(n=index), slot["soc"])
payload[TOU_FIELD["grid_charge"].format(n=index)] = encode_setting(TOU_FIELD["grid_charge"].format(n=index), slot["grid_charge"])
# The per-slot Sell flag ("Sell" in the app). It MUST be 1 for a forced export
# slot, and every per-slot flag must be present in the payload or the API
# silently discards them all - see TOU_FIELD.
# Through encode_setting like every other owned field, so wire encoding stays in
# one place. Passed as 1/0 rather than a bool: sellTime{n}En is deliberately NOT
# in SUNSYNK_BOOL_FIELDS (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".
payload[TOU_FIELD["sell"].format(n=index)] = encode_setting(TOU_FIELD["sell"].format(n=index), 1 if slot["sell"] else 0)
return payload

def build_settings_payload(self, sn, schedule, current_soc, now_minutes=None):
"""Build the full settings object to POST for one inverter.

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

Expand Down
69 changes: 60 additions & 9 deletions apps/predbat/sunsynk_const.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,25 +94,75 @@
}

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

# NEVER write this. time{n}On (capital O) is server-derived: it changed from '0' to '65' on
# a write that did not mention it at all, and writing '1' to it also produced '65'. It is
# not the boolean it resembles, and the writable grid-charge flag is time{n}on (lower case).
SUNSYNK_DERIVED_SLOT_FIELDS = tuple(f"time{n}On" for n in range(1, TOU_SLOT_COUNT + 1))

SUNSYNK_DAY_FIELDS = ["mondayOn", "tuesdayOn", "wednesdayOn", "thursdayOn", "fridayOn", "saturdayOn", "sundayOn"]

# The settings/set endpoint accepts ONLY the "System Mode" group of fields. CONFIRMED
# live (inverter 2405116013, 2026-08-19): posting the full 350-key object returned
# {"code":0,"msg":"Success","success":true} and changed NOTHING, twice, including a probe
# that altered a single field and preserved every original string type. Posting just these
# 53 keys with the same single change persisted immediately.
#
# So the whole-object read-modify-write this component originally used could never have
# worked - every write was silently accepted and discarded. Predbat now sends this group
# only, carrying through the fields inside it that it does not own (safetyType, battMode,
# energyMode, zeroExportPower, solarMaxSellPower, pvMaxLimit, sellTime{n}Volt,
# genTime{n}on). Everything outside the group - battery, grid, generator settings - is
# never transmitted at all, so it cannot be disturbed.
#
# Field list taken from solarsynkv3's DetermineSettingCategory, which posts the same group.
SUNSYNK_SYSTEM_MODE_FIELDS = frozenset(
["sn", "safetyType", "battMode", "solarSell", "pvMaxLimit", "energyMode", "peakAndVallery", "sysWorkMode", "zeroExportPower", "solarMaxSellPower"]
+ [f"sellTime{n}" for n in range(1, TOU_SLOT_COUNT + 1)]
+ [f"sellTime{n}Pac" for n in range(1, TOU_SLOT_COUNT + 1)]
+ [f"sellTime{n}Volt" for n in range(1, TOU_SLOT_COUNT + 1)]
+ [f"sellTime{n}En" for n in range(1, TOU_SLOT_COUNT + 1)]
+ [f"cap{n}" for n in range(1, TOU_SLOT_COUNT + 1)]
+ ["mondayOn", "tuesdayOn", "wednesdayOn", "thursdayOn", "fridayOn", "saturdayOn", "sundayOn"]
+ [f"time{n}on" for n in range(1, TOU_SLOT_COUNT + 1)]
+ [f"genTime{n}on" for n in range(1, TOU_SLOT_COUNT + 1)]
)

# Top-level settings keys Predbat owns.
SUNSYNK_WORKMODE_FIELD = "sysWorkMode"
SUNSYNK_SOLAR_SELL_FIELD = "solarSell"
SUNSYNK_TOU_ENABLE_FIELD = "peakAndVallery"
SUNSYNK_SERIAL_FIELD = "sn"

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

# Values that mean False when Sunsynk hands a flag back as a string.
Expand All @@ -122,13 +172,14 @@
def encode_setting(name, value):
"""Serialise one settings value the way Sunsynk expects it on the wire.

Boolean fields (per-slot grid charge, day-of-week enables) go bare; every other
field is quoted, because Sunsynk returns and accepts its numerics as strings.
Everything is quoted. The boolean fields (per-slot grid charge, day-of-week enables)
become the strings "true"/"false" rather than bare JSON booleans: the API silently
discards a bare boolean while accepting the rest of the same write. See
SUNSYNK_BOOL_FIELDS.
"""
if name in SUNSYNK_BOOL_FIELDS:
if isinstance(value, str):
return value.strip().lower() not in SUNSYNK_FALSE_STRINGS
return bool(value)
truthy = value.strip().lower() not in SUNSYNK_FALSE_STRINGS if isinstance(value, str) else bool(value)
return "true" if truthy else "false"
return str(value)


Expand Down
15 changes: 8 additions & 7 deletions apps/predbat/tests/test_sunsynk_const.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,14 +152,15 @@ def test_encode_setting_types():
if day not in SUNSYNK_BOOL_FIELDS:
print(f"ERROR: day field {day} must be declared a boolean field")
failed = True
# Booleans are sent QUOTED - a bare JSON boolean is silently discarded by the API.
cases = [
("time1on", True, True),
("time1on", "true", True),
("time1on", 1, True),
("time1on", False, False),
("time1on", "false", False),
("time1on", 0, False),
("mondayOn", True, True),
("time1on", True, "true"),
("time1on", "true", "true"),
("time1on", 1, "true"),
("time1on", False, "false"),
("time1on", "false", "false"),
("time1on", 0, "false"),
("mondayOn", True, "true"),
("cap1", 95, "95"),
("cap1", "95", "95"),
("sellTime1", "02:00", "02:00"),
Expand Down
Loading
Loading