From a43048871a906e09693f2181e33eeab8e05d1361 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Wed, 19 Aug 2026 11:40:56 +0100 Subject: [PATCH 1/4] fix(deye): send touDays, so the TOU programme actually runs 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) --- apps/predbat/deye.py | 15 +++++- apps/predbat/deye_const.py | 22 +++++++++ apps/predbat/tests/test_deye_control.py | 63 ++++++++++++++++++++++++- 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/apps/predbat/deye.py b/apps/predbat/deye.py index da9a7f92b..a05d33be7 100644 --- a/apps/predbat/deye.py +++ b/apps/predbat/deye.py @@ -38,6 +38,7 @@ TOU_FIELD, TOU_SLOT_COUNT, TOU_FILLER_TIMES, + DEYE_TOU_DAYS, DEYE_ORDER_MAX_POLLS, DEYE_BUSY_CODES, DEYE_BUSY_MARKERS, @@ -524,7 +525,10 @@ def derive_control_state(self, schedule, current_soc): measures at the inverter's own output instead, so on a CT-clamp install it would stop the battery serving anything not wired to the inverter and the shortfall would come from the grid. Confirmed on Sunsynk hardware, which sits behind the same - registers. + registers, and since confirmed on DEYE's own side: the official + clientcode/strategy/dynamic_control_self_consumption.py sample is exactly + ZERO_EXPORT_TO_CT with solarSellAction on, and the fully-charge sample uses the same + mode with a high slot SOC. """ reserve = int(schedule.get("reserve", 0)) charge = schedule.get("charge", {}) @@ -755,8 +759,17 @@ def build_dynamic_payload(self, sn, schedule, current_soc, now_minutes=None): # the slot SoC targets, not from this flag. Turning it off is never useful here, # only harmful, so it is not derived at all. derive_control_state still carries # solar_sell because build_tou_slots uses it to classify action-vs-self-use slots. + # + # DEYE's own samples agree: three of the four strategy/dynamic_control_*.py + # samples send solarSellAction on, including self-consumption, which pairs it + # with ZERO_EXPORT_TO_CT. Only the fully-charge sample omits it. "solarSellAction": "on", "touAction": "on", + # The days the programme runs on. Predbat re-derives a 24h plan every cycle, so + # every day is one of them; omitting this left the active days at whatever the + # inverter already held, and a programme that names no days is stored and never + # applied. See DEYE_TOU_DAYS. + "touDays": list(DEYE_TOU_DAYS), "timeUseSettingItems": slots, } diff --git a/apps/predbat/deye_const.py b/apps/predbat/deye_const.py index d57f96d4d..b9a10ee67 100644 --- a/apps/predbat/deye_const.py +++ b/apps/predbat/deye_const.py @@ -214,6 +214,15 @@ # TimeUseSettingItem per-slot fields — CONFIRMED from official sample # clientcode/commission/sys_tou_update.py and the strategy/* samples. +# +# This is the COMPLETE per-slot field set: the sample posts exactly these five keys and +# every strategy/dynamic_control_*.py sample carries the same five. Worth stating because +# Sunsynk — the same hardware behind a different cloud — silently discards the per-slot +# flags when the field set is incomplete (sellTime{n}En absent made time{n}on vanish on six +# consecutive writes). DEYE cannot hit that failure mode while every item carries all five, +# and there is no per-slot sell/export enable here to omit: the sell bit exists in the +# underlying register (prog{n} bit 0x40 single-phase / 0x20 three-phase) but the DEYE cloud +# does not expose it in TimeUseSettingItem, deriving export from workMode instead. TOU_FIELD = { "time": "time", "power": "power", @@ -222,6 +231,19 @@ "generate": "enableGeneration", } +# Days the TOU programme runs on. Sent with every control write, all seven of them. +# +# CONFIRMED from the official samples: all four of clientcode/strategy/dynamic_control_*.py +# send this exact list, and the endpoint contract carries touDays[] alongside touAction. +# Predbat previously sent touAction without touDays, which left the active days at whatever +# the inverter already held — and a programme whose days are empty, or which omits the day +# the plan is for, is stored and never applied. +# +# All seven is the only correct value here: Predbat re-derives a 24h programme every cycle +# and has no notion of a day its plan should be dormant. Sunsynk sets its seven +# mondayOn..sundayOn flags true for the same reason (SUNSYNK_DAY_FIELDS). +DEYE_TOU_DAYS = ["SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY"] + # config/battery response field names — CONFIRMED live: # {"maxChargeCurrent": 185, "maxDischargeCurrent": 185, "battLowCapacity": 14, # "battShutDownCapacity": 9, "battCapacity": 1200} diff --git a/apps/predbat/tests/test_deye_control.py b/apps/predbat/tests/test_deye_control.py index 456284bfb..8efcb545b 100644 --- a/apps/predbat/tests/test_deye_control.py +++ b/apps/predbat/tests/test_deye_control.py @@ -10,7 +10,7 @@ from unittest.mock import patch from deye_const import DEYE_WORKMODE, FREEZE_EXPORT_SOC, TOU_FIELD, TOU_SLOT_COUNT, TOU_FILLER_TIMES, DEYE_ORDER_MAX_POLLS -from deye_const import CONFIG_BATTERY_KEYS +from deye_const import CONFIG_BATTERY_KEYS, DEYE_TOU_DAYS from tests.test_deye_api import MockDeye, MOCK_RATED_POWER from tests.test_infra import run_async as run_async_local @@ -854,6 +854,65 @@ async def fake_post(endpoint_key, body): assert not failed, "test_control_write_fails_closed_without_a_self_use_power" +def test_payload_names_every_day_the_schedule_runs_on(): + """The control payload carries touDays for all seven days. + + DEYE's TOU programme only runs on the days named in touDays, and Predbat's plan is a + 24h programme it re-derives every cycle — it has no notion of a day the schedule should + be dormant. Every one of the four official strategy samples + (clientcode/strategy/dynamic_control_*.py) sends the full seven-day list; Predbat sent + none, leaving the active days at whatever the inverter happened to hold. If that is + empty, or missing the day the plan is for, the whole schedule silently never runs — the + slots are stored and simply not applied. Sunsynk, on the same registers, has the same + field as its seven mondayOn..sundayOn flags and Predbat sets all of them there. + """ + failed = False + d = MockDeye().with_rating("INV1") + sched = {"reserve": 10, "charge": {"enable": True, "soc": 95, "power": 3000, "start": "02:00", "end": "05:00"}, "export": {"enable": False, "soc": 0, "power": 0}} + payload = d.build_dynamic_payload("INV1", sched, current_soc=40, now_minutes=3 * 60) + days = payload.get("touDays") + if sorted(days or []) != sorted(DEYE_TOU_DAYS): + print(f"ERROR: touDays must name all seven days, got {days!r}") + failed = True + if len(DEYE_TOU_DAYS) != 7 or any(day != day.upper() for day in DEYE_TOU_DAYS): + print(f"ERROR: DEYE names its days as seven upper-case strings, got {DEYE_TOU_DAYS!r}") + failed = True + # Every payload, not just an active one: the slots are a 24h programme whatever state + # the top level is in. + idle = {"reserve": 10, "charge": {"enable": False, "soc": 0, "power": 0}, "export": {"enable": False, "soc": 0, "power": 0}} + if d.build_dynamic_payload("INV1", idle, current_soc=40, now_minutes=12 * 60).get("touDays") != DEYE_TOU_DAYS: + print("ERROR: an idle payload must still name the days its slots apply on") + failed = True + assert not failed, "test_payload_names_every_day_the_schedule_runs_on" + + +def test_every_slot_carries_the_complete_field_set(): + """Every TOU item carries all five documented fields, none of them omitted. + + Sunsynk, the same hardware behind a different cloud, validates the per-slot field set as + a whole and silently discards the flags when it is incomplete: with one flag left out, + the grid-charge flag vanished on six consecutive writes across every encoding tried + while the rest of each write persisted, and the API reported success throughout. DEYE + cannot hit that while every item carries the full set its own samples post, so this + pins the set rather than trusting each call site to remember it. + """ + failed = False + d = MockDeye().with_rating("INV1") + expected = set(TOU_FIELD.values()) + schedules = [ + {"reserve": 10, "charge": {"enable": True, "soc": 95, "power": 3000, "start": "02:00", "end": "05:00"}, "export": {"enable": False, "soc": 0, "power": 0}}, + {"reserve": 10, "charge": {"enable": False, "soc": 0, "power": 0}, "export": {"enable": True, "soc": 20, "power": 3000, "start": "16:00", "end": "19:00"}}, + {"reserve": 20, "charge": {"enable": True, "soc": 20, "power": 3000, "start": "01:00", "end": "02:00"}, "export": {"enable": False, "soc": 0, "power": 0}}, + {"reserve": 10, "charge": {"enable": False, "soc": 0, "power": 0}, "export": {"enable": False, "soc": 0, "power": 0}}, + ] + for index, sched in enumerate(schedules): + for slot in d.build_dynamic_payload("INV1", sched, current_soc=40, now_minutes=3 * 60)["timeUseSettingItems"]: + if set(slot) != expected: + print(f"ERROR: schedule {index} produced a slot with fields {sorted(slot)}, expected {sorted(expected)}") + failed = True + assert not failed, "test_every_slot_carries_the_complete_field_set" + + def run_deye_control_tests(my_predbat): """Run all DEYE control-logic tests.""" failed = False @@ -886,6 +945,8 @@ def run_deye_control_tests(my_predbat): ("self_use_slot_power", test_self_use_slots_carry_the_inverter_rating), ("freeze_zero_power", test_freeze_states_hold_with_zero_power), ("no_self_use_power_fails_closed", test_control_write_fails_closed_without_a_self_use_power), + ("tou_days", test_payload_names_every_day_the_schedule_runs_on), + ("slot_field_set", test_every_slot_carries_the_complete_field_set), ]: try: if fn(): From 5c12a3fa5754f5a515c1dd9d38c2e20aeccba320 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Wed, 19 Aug 2026 18:56:51 +0100 Subject: [PATCH 2/4] fix(deye): arm the per-slot Sell flag, and correct the claim it did not 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) --- apps/predbat/deye.py | 18 ++++++++-- apps/predbat/deye_const.py | 37 ++++++++++++------- apps/predbat/tests/test_deye_control.py | 47 +++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 15 deletions(-) diff --git a/apps/predbat/deye.py b/apps/predbat/deye.py index a05d33be7..c929202e1 100644 --- a/apps/predbat/deye.py +++ b/apps/predbat/deye.py @@ -578,11 +578,23 @@ def _self_use_slot(self, start_time, reserve, self_use_power): grid. Self-use slots cover every interval Predbat is not actively charging or exporting, so that would be the battery's default state. """ - return {TOU_FIELD["time"]: start_time, TOU_FIELD["power"]: int(self_use_power), TOU_FIELD["soc"]: int(reserve), TOU_FIELD["grid_charge"]: False, TOU_FIELD["generate"]: True} + return {TOU_FIELD["time"]: start_time, TOU_FIELD["power"]: int(self_use_power), TOU_FIELD["soc"]: int(reserve), TOU_FIELD["grid_charge"]: False, TOU_FIELD["generate"]: True, TOU_FIELD["sell"]: False} def _action_slot(self, start_time, state): - """Build a TOU slot realising a derived control state.""" - return {TOU_FIELD["time"]: start_time, TOU_FIELD["power"]: int(state["power"]), TOU_FIELD["soc"]: int(state["slot_soc"]), TOU_FIELD["grid_charge"]: bool(state["grid_charge"]), TOU_FIELD["generate"]: True} + """Build a TOU slot realising a derived control state. + + enableSell is the slot's forced-export flag, so it follows solar_sell: on for the + two export states, off for charging and holding. It is written on self-use slots + too (as False) because the per-slot field set has to be complete — see TOU_FIELD. + """ + return { + TOU_FIELD["time"]: start_time, + TOU_FIELD["power"]: int(state["power"]), + TOU_FIELD["soc"]: int(state["slot_soc"]), + TOU_FIELD["grid_charge"]: bool(state["grid_charge"]), + TOU_FIELD["generate"]: True, + TOU_FIELD["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.""" diff --git a/apps/predbat/deye_const.py b/apps/predbat/deye_const.py index b9a10ee67..7a8631744 100644 --- a/apps/predbat/deye_const.py +++ b/apps/predbat/deye_const.py @@ -212,29 +212,42 @@ # a silent 0.0, so absence is logged rather than swallowed. DEYE_TELEMETRY_REQUIRED = ("soc", "battery_power", "pv_power", "load_power") -# TimeUseSettingItem per-slot fields — CONFIRMED from official sample -# clientcode/commission/sys_tou_update.py and the strategy/* samples. +# TimeUseSettingItem per-slot fields — CONFIRMED from the official Swagger definition +# (GET https://eu1-developer.deyecloud.com/v2/api-docs, the spec behind +# developer.deyecloud.com/api; definitions.TimeUseSettingItem). # -# This is the COMPLETE per-slot field set: the sample posts exactly these five keys and -# every strategy/dynamic_control_*.py sample carries the same five. Worth stating because -# Sunsynk — the same hardware behind a different cloud — silently discards the per-slot -# flags when the field set is incomplete (sellTime{n}En absent made time{n}on vanish on six -# consecutive writes). DEYE cannot hit that failure mode while every item carries all five, -# and there is no per-slot sell/export enable here to omit: the sell bit exists in the -# underlying register (prog{n} bit 0x40 single-phase / 0x20 three-phase) but the DEYE cloud -# does not expose it in TimeUseSettingItem, deriving export from workMode instead. +# The SPEC is the authority here, NOT the sample code. Every sample in +# DeyeCloudDevelopers/deye-openapi-client-sample-code omits enableSell, which reads as if +# the field does not exist — but the spec lists it, and it is the same forced-export +# register bit Sunsynk exposes as sellTime{n}En, where a live write test proved an export +# slot does not arm without it. Each sample is a whole-day single behaviour, so none of +# them ever needed one slot to sell and another not to. +# +# Every field here goes on EVERY slot. Sunsynk — the same hardware behind a different +# cloud — validates the per-slot field set as a whole and silently discards the flags when +# it is incomplete: with sellTime{n}En absent, time{n}on vanished on six consecutive writes +# across every encoding tried, while the rest of each write persisted and the API reported +# success throughout. +# +# The one spec field deliberately NOT written is "voltage" ("If enabled battery voltage +# mode, this field should be set"). Predbat drives SOC targets, not voltage targets, so it +# has nothing to put there and a guessed value would be acted on. Sunsynk leaves its +# equivalent, sellTime{n}Volt, untouched for the same reason. TOU_FIELD = { "time": "time", "power": "power", "soc": "soc", "grid_charge": "enableGridCharge", "generate": "enableGeneration", + "sell": "enableSell", } # Days the TOU programme runs on. Sent with every control write, all seven of them. # -# CONFIRMED from the official samples: all four of clientcode/strategy/dynamic_control_*.py -# send this exact list, and the endpoint contract carries touDays[] alongside touAction. +# CONFIRMED from the official Swagger definition, which types it as an enum of the seven +# upper-case day names and documents it as "If action is on, fill this field with the days +# of the week you want to switch on"; all four of clientcode/strategy/dynamic_control_*.py +# send this exact list too. # Predbat previously sent touAction without touDays, which left the active days at whatever # the inverter already held — and a programme whose days are empty, or which omits the day # the plan is for, is stored and never applied. diff --git a/apps/predbat/tests/test_deye_control.py b/apps/predbat/tests/test_deye_control.py index 8efcb545b..b56ad854d 100644 --- a/apps/predbat/tests/test_deye_control.py +++ b/apps/predbat/tests/test_deye_control.py @@ -913,6 +913,52 @@ def test_every_slot_carries_the_complete_field_set(): assert not failed, "test_every_slot_carries_the_complete_field_set" +def test_export_slots_arm_the_sell_flag(): + """Only export slots carry enableSell, and every slot carries the field. + + enableSell is TimeUseSettingItem's per-slot forced-export enable — the same register bit + Sunsynk exposes as sellTime{n}En, where a live write test proved a forced export slot + does not arm without it. Predbat never sent it, so DEYE export windows had the work mode + and the SOC target but not the slot flag that makes the slot an export slot. + + It is written on every slot, not just export ones: 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. + """ + failed = False + d = MockDeye().with_rating("INV1") + sell = TOU_FIELD["sell"] + export = {"reserve": 10, "charge": {"enable": False, "soc": 0, "power": 0}, "export": {"enable": True, "soc": 20, "power": 3000, "start": "16:00", "end": "19:00"}} + slots = d.build_dynamic_payload("INV1", export, current_soc=80, now_minutes=17 * 60)["timeUseSettingItems"] + armed = [slot for slot in slots if slot[sell]] + if [slot[TOU_FIELD["time"]] for slot in armed] != ["16:00"]: + print(f"ERROR: only the export window should arm the sell flag, got {[s[TOU_FIELD['time']] for s in armed]}") + failed = True + if not armed or armed[0][TOU_FIELD["soc"]] != 20: + print(f"ERROR: the armed slot should be the export target: {armed}") + failed = True + + # A freeze-export holds the battery but still sells, so it arms too. + freeze = {"reserve": 10, "charge": {"enable": False, "soc": 0, "power": 0}, "export": {"enable": True, "soc": FREEZE_EXPORT_SOC, "power": 3000, "start": "16:00", "end": "19:00"}} + frozen = [slot for slot in d.build_dynamic_payload("INV1", freeze, current_soc=80, now_minutes=17 * 60)["timeUseSettingItems"] if slot[sell]] + if len(frozen) != 1 or frozen[0][TOU_FIELD["power"]] != 0: + print(f"ERROR: freeze-export should arm one zero-power sell slot: {frozen}") + failed = True + + # A charge window never sells, and neither does an idle day. + charge = {"reserve": 10, "charge": {"enable": True, "soc": 95, "power": 3000, "start": "02:00", "end": "05:00"}, "export": {"enable": False, "soc": 0, "power": 0}} + idle = {"reserve": 10, "charge": {"enable": False, "soc": 0, "power": 0}, "export": {"enable": False, "soc": 0, "power": 0}} + for name, sched in (("charge", charge), ("idle", idle)): + slots = d.build_dynamic_payload("INV1", sched, current_soc=40, now_minutes=3 * 60)["timeUseSettingItems"] + if any(slot[sell] for slot in slots): + print(f"ERROR: a {name} schedule must not arm any sell slot: {slots}") + failed = True + if any(sell not in slot for slot in slots): + print(f"ERROR: every slot must carry the sell field even when off: {slots}") + failed = True + assert not failed, "test_export_slots_arm_the_sell_flag" + + def run_deye_control_tests(my_predbat): """Run all DEYE control-logic tests.""" failed = False @@ -947,6 +993,7 @@ def run_deye_control_tests(my_predbat): ("no_self_use_power_fails_closed", test_control_write_fails_closed_without_a_self_use_power), ("tou_days", test_payload_names_every_day_the_schedule_runs_on), ("slot_field_set", test_every_slot_carries_the_complete_field_set), + ("sell_flag", test_export_slots_arm_the_sell_flag), ]: try: if fn(): From d991e15ed7daebe459869771c1a2a8ae48863fa1 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Wed, 19 Aug 2026 19:45:41 +0100 Subject: [PATCH 3/4] fix(deye): never switch on generator charging, and keep the owner's setting enableGeneration authorises charging the battery from an EXTERNAL GENERATOR. Predbat wrote True on every slot, which authorised generator charging across the whole day on any system with one wired in - a running cost Predbat cannot see, since it has no model of a generator at all: no fuel price, no run hours, nothing it could plan against. It must never be the thing that starts one. It is equally not Predbat's setting to throw away, so rather than force it off, the inverter's own value is carried through by slot position. The component now reads config/tou on the config tier - an endpoint that has been declared in DEYE_ENDPOINTS since the beginning but never called - and applies those flags to the finished slots, in the same place the SOC floor is clamped, so no slot builder has to invent a value. Unknown fails towards off: a model that rejects config/tou, a serial not yet read, or a short response all leave generator charging off in everything Predbat writes. The read is cached in its own file rather than folded into the existing config cache, so an upgrade with only the old cache present still restores the battery config and simply re-reads the TOU programme on its first config tier. Without any cache, a control write that beat the first config refresh would clear a setting the inverter really holds. This mirrors Sunsynk, which carries genTime{n}on through its read-modify-write untouched for the same reason. Note the read model is not the write model: DeviceTimeOfUseResponse's TimeOfUseItem has no enableSell, so the sell flag cannot be round-tripped this way even though the write model has it. Co-Authored-By: Claude Opus 5 (1M context) --- apps/predbat/deye.py | 75 ++++++++++++++++++-- apps/predbat/deye_const.py | 1 + apps/predbat/tests/test_deye_api.py | 1 + apps/predbat/tests/test_deye_control.py | 91 +++++++++++++++++++++++++ docs/components.md | 1 + 5 files changed, 164 insertions(+), 5 deletions(-) diff --git a/apps/predbat/deye.py b/apps/predbat/deye.py index c929202e1..ba1cfdd2b 100644 --- a/apps/predbat/deye.py +++ b/apps/predbat/deye.py @@ -60,6 +60,7 @@ DEYE_RESTORE_MAX_CONTROL, DEYE_CACHE_STATIC, DEYE_CACHE_CONFIG, + DEYE_CACHE_TOU, DEYE_CACHE_RATINGS, DEYE_CACHE_CONTROL, ) @@ -115,6 +116,7 @@ def initialize( self.station_ids = [] self.device_values = {} self.device_battery_config = {} + self.device_tou_config = {} self.device_capacity = {} self.device_pack_voltage = {} self.device_energy = {} @@ -488,6 +490,41 @@ async def fetch_battery_config(self, sn): self.device_battery_config[sn] = data return data + async def fetch_tou_config(self, sn): + """Read the inverter's current TOU programme, for the one slot field Predbat does not own. + + Only enableGeneration is taken from it (see _generation_flags). The read model + (DeviceTimeOfUseResponse -> TimeOfUseItem) carries no enableSell at all, so the sell + flag cannot be round-tripped this way even though the write model has it. + """ + data = await self._post("config_tou", {"deviceSn": sn}) + if not data.get("success", True): + # Not fatal, and the same config point some models reject outright: generator + # charging then stays off in everything Predbat writes, which is the safe way + # for this to be wrong. + self.log(f"Warn: DEYE config/tou failed for {sn}: {data.get('msg', 'unknown')} - generator charging will be left off in the slots Predbat writes") + return [] + items = data.get("timeUseSettingItems") or [] + self.device_tou_config[sn] = items + return items + + def _generation_flags(self, sn): + """Return the per-slot enableGeneration flags to carry through, one per TOU slot. + + enableGeneration authorises charging the battery from an EXTERNAL GENERATOR. Predbat + has no model of a generator - no fuel cost, no run hours, nothing it could plan + against - so it must never be what switches one on. It is equally not Predbat's + setting to throw away, so the inverter's own value is carried across by slot + position, exactly as the Sunsynk component carries genTime{n}on through its + read-modify-write. + + Defaults to all-off when the programme has not been read, or the model rejects + config/tou: unknown has to fail towards not running someone's generator. + """ + items = self.device_tou_config.get(sn) or [] + flags = [bool(item.get(TOU_FIELD["generate"])) for item in items[:TOU_SLOT_COUNT]] + return flags + [False] * (TOU_SLOT_COUNT - len(flags)) + async def fetch_measure_points(self, sn): """Log the device's measure-point metadata (read-only, first cycle only). @@ -578,7 +615,7 @@ def _self_use_slot(self, start_time, reserve, self_use_power): grid. Self-use slots cover every interval Predbat is not actively charging or exporting, so that would be the battery's default state. """ - return {TOU_FIELD["time"]: start_time, TOU_FIELD["power"]: int(self_use_power), TOU_FIELD["soc"]: int(reserve), TOU_FIELD["grid_charge"]: False, TOU_FIELD["generate"]: True, TOU_FIELD["sell"]: False} + return {TOU_FIELD["time"]: start_time, TOU_FIELD["power"]: int(self_use_power), TOU_FIELD["soc"]: int(reserve), TOU_FIELD["grid_charge"]: False, TOU_FIELD["generate"]: False, TOU_FIELD["sell"]: False} def _action_slot(self, start_time, state): """Build a TOU slot realising a derived control state. @@ -592,7 +629,7 @@ def _action_slot(self, start_time, state): TOU_FIELD["power"]: int(state["power"]), TOU_FIELD["soc"]: int(state["slot_soc"]), TOU_FIELD["grid_charge"]: bool(state["grid_charge"]), - TOU_FIELD["generate"]: True, + TOU_FIELD["generate"]: False, TOU_FIELD["sell"]: bool(state.get("solar_sell")), } @@ -752,6 +789,13 @@ def build_dynamic_payload(self, sn, schedule, current_soc, now_minutes=None): if lifted and sn not in self._soc_floor_warned: self._soc_floor_warned.add(sn) self.log(f"Info: DEYE {sn} raising requested slot SOC to the inverter's {floor}% floor (config/battery battLowCapacity)") + # enableGeneration is the external-generator charge flag, and it is the owner's + # setting rather than Predbat's - carried across by slot position from whatever + # config/tou last reported, defaulting to off. Applied here, on the finished slot + # list, so it lands on the slots the inverter actually receives (the same reason the + # SOC floor is clamped here) and no slot builder has to invent a value. + for slot, generation in zip(slots, self._generation_flags(sn)): + slot[TOU_FIELD["generate"]] = generation return { "deviceSn": sn, "workMode": active["work_mode"], @@ -1240,8 +1284,13 @@ async def save_static(self): return await self.save_cache(DEYE_CACHE_STATIC, {"station_ids": self.station_ids, "device_list": self.device_list}) async def save_config(self): - """Cache the per-device config/battery responses.""" - return await self.save_cache(DEYE_CACHE_CONFIG, self.device_battery_config) + """Cache the per-device config/battery and config/tou responses.""" + saved = await self.save_cache(DEYE_CACHE_CONFIG, self.device_battery_config) + # A separate file so the existing config cache keeps its shape: a restart that finds + # only the old one still restores the battery config, and simply re-reads the TOU + # programme on the first config tier. + await self.save_cache(DEYE_CACHE_TOU, self.device_tou_config) + return saved def _ratings_payload(self): """Return the static per-device ratings in their cached shape.""" @@ -1292,6 +1341,13 @@ async def restore_state(self): self.device_battery_config = config self.mark_refreshed("config", age) + # Restored on the same tier clock as the battery config. Worth keeping across a + # restart: without it the first write of a cycle that beats the config refresh would + # clear a generator setting the inverter really does hold. + tou, _ = await self.load_cache(DEYE_CACHE_TOU) + if isinstance(tou, dict) and tou: + self.device_tou_config = tou + # Ratings are static per install and carry no TTL of their own — device/latest # rewrites them on every live refresh. Restoring them unconditionally is the main # win: automatic_config() can map soc_max/battery_rate_max/inverter_limit at @@ -1364,7 +1420,11 @@ async def refresh_static(self): return True async def refresh_config(self): - """Re-read config/battery for every device and cache it when anything came back.""" + """Re-read config/battery and config/tou for every device, caching whatever came back. + + config/tou is read for the settings Predbat does not own but must not clobber when it + rewrites the programme - see _generation_flags. + """ got_any = False for sn in self.device_list: try: @@ -1372,6 +1432,11 @@ async def refresh_config(self): got_any = True except Exception as e: self.log(f"Warn: DEYE config/battery failed for {sn}: {e}") + try: + if await self.fetch_tou_config(sn): + got_any = True + except Exception as e: + self.log(f"Warn: DEYE config/tou failed for {sn}: {e}") # The clock is started either way so a model that rejects this config point # entirely retries on the tier cadence rather than on every single tick, but the # cache is only written when there is something worth keeping. diff --git a/apps/predbat/deye_const.py b/apps/predbat/deye_const.py index 7a8631744..f173ffcd7 100644 --- a/apps/predbat/deye_const.py +++ b/apps/predbat/deye_const.py @@ -70,6 +70,7 @@ DEYE_CACHE_CONFIG = "config" # device_battery_config DEYE_CACHE_RATINGS = "ratings" # device_capacity, device_pack_voltage, device_rated_power DEYE_CACHE_CONTROL = "control" # applied_payload, pending_orders, order_poll_count +DEYE_CACHE_TOU = "tou" # device_tou_config, the programme the inverter already holds # Telemetry and the energy counters are deliberately NOT cached. The live tier polls every # minute, so a cache would be written 1440 times a day to save at most one tick's gap at diff --git a/apps/predbat/tests/test_deye_api.py b/apps/predbat/tests/test_deye_api.py index fc29e156a..bcae8ad36 100644 --- a/apps/predbat/tests/test_deye_api.py +++ b/apps/predbat/tests/test_deye_api.py @@ -42,6 +42,7 @@ def __init__(self, auth_method="app_credentials", data_center="eu", inverter_sn= self.station_ids = [] self.device_values = {} self.device_battery_config = {} + self.device_tou_config = {} self.device_capacity = {} self.device_pack_voltage = {} self.device_energy = {} diff --git a/apps/predbat/tests/test_deye_control.py b/apps/predbat/tests/test_deye_control.py index b56ad854d..f95af6f0c 100644 --- a/apps/predbat/tests/test_deye_control.py +++ b/apps/predbat/tests/test_deye_control.py @@ -959,6 +959,94 @@ def test_export_slots_arm_the_sell_flag(): assert not failed, "test_export_slots_arm_the_sell_flag" +def test_generator_charging_is_never_switched_on_by_predbat(): + """enableGeneration is off in every slot Predbat writes when the inverter's own value is unknown. + + It authorises charging the battery from an EXTERNAL GENERATOR. Predbat has no model of a + generator — no fuel cost, no run hours, nothing it can plan against — so it must never + be the thing that switches one on. Predbat previously wrote True on every slot, which + authorised generator charging across the whole day on any system with one wired in. + """ + failed = False + d = MockDeye().with_rating("INV1") + generate = TOU_FIELD["generate"] + sched = {"reserve": 10, "charge": {"enable": True, "soc": 95, "power": 3000, "start": "02:00", "end": "05:00"}, "export": {"enable": True, "soc": 20, "power": 3000, "start": "16:00", "end": "19:00"}} + for now_minutes in (3 * 60, 12 * 60, 17 * 60): + slots = d.build_dynamic_payload("INV1", sched, current_soc=40, now_minutes=now_minutes)["timeUseSettingItems"] + if any(slot[generate] for slot in slots): + print(f"ERROR: at {now_minutes} Predbat authorised generator charging: {slots}") + failed = True + if any(generate not in slot for slot in slots): + print(f"ERROR: the field must still be present on every slot: {slots}") + failed = True + assert not failed, "test_generator_charging_is_never_switched_on_by_predbat" + + +def test_generator_charging_the_owner_configured_is_carried_through(): + """The inverter's own enableGeneration survives, slot by slot, rather than being cleared. + + Predbat owns the TOU programme but not this flag, so an owner who has generator charging + configured keeps it. Read from config/tou and carried across by slot position — the same + thing the Sunsynk component does with genTime{n}on through its read-modify-write. + """ + failed = False + d = MockDeye().with_rating("INV1") + generate = TOU_FIELD["generate"] + # The owner runs the generator on the 2nd and 5th slots of the day. + d.device_tou_config["INV1"] = [{"time": f"{n * 4:02d}:00", generate: n in (1, 4), "enableGridCharge": False, "power": 5000, "soc": 20} for n in range(6)] + sched = {"reserve": 10, "charge": {"enable": False, "soc": 0, "power": 0}, "export": {"enable": False, "soc": 0, "power": 0}} + slots = d.build_dynamic_payload("INV1", sched, current_soc=40, now_minutes=12 * 60)["timeUseSettingItems"] + if [slot[generate] for slot in slots] != [False, True, False, False, True, False]: + print(f"ERROR: the owner's generator slots were not carried through: {[s[generate] for s in slots]}") + failed = True + + # A short read is padded rather than trusted, and never invents an enable. + d.device_tou_config["INV1"] = [{generate: True}] + got = [slot[generate] for slot in d.build_dynamic_payload("INV1", sched, current_soc=40, now_minutes=12 * 60)["timeUseSettingItems"]] + if got != [True, False, False, False, False, False]: + print(f"ERROR: a short read should pad with off, got {got}") + failed = True + assert not failed, "test_generator_charging_the_owner_configured_is_carried_through" + + +def test_fetch_tou_config_caches_and_survives_an_unsupported_model(): + """config/tou is read into the cache, and a model that rejects it leaves generator charging off.""" + failed = False + d = MockDeye() + items = [{"time": "00:00", "enableGeneration": True, "enableGridCharge": False, "power": 5000, "soc": 20}] + + async def fake_post(endpoint_key, body): + """Return a TOU read for the config point, mirroring DeviceTimeOfUseResponse.""" + if endpoint_key != "config_tou": + return {"success": True} + return {"success": True, "timeUseSettingItems": items, "touAction": "on"} + + with patch.object(d, "_post", side_effect=fake_post): + got = run_async_local(d.fetch_tou_config("INV1")) + if got != items or d.device_tou_config.get("INV1") != items: + print(f"ERROR: the TOU read should be cached: {d.device_tou_config}") + failed = True + + # Some models answer "config point not supported" — that must not clear what is known, + # nor raise, and the flags fall back to off for a serial that was never read. + async def fake_fail(endpoint_key, body): + """Reject the config point the way a model without it does.""" + return {"success": False, "code": "2106001", "msg": "config point not supported"} + + with patch.object(d, "_post", side_effect=fake_fail): + got = run_async_local(d.fetch_tou_config("INV2")) + if got != []: + print(f"ERROR: a rejected read should report nothing, got {got}") + failed = True + if d.device_tou_config.get("INV1") != items: + print("ERROR: a failure for one serial must not disturb another's cached read") + failed = True + if any(d._generation_flags("INV2")): + print(f"ERROR: an unread serial must default to no generator charging: {d._generation_flags('INV2')}") + failed = True + assert not failed, "test_fetch_tou_config_caches_and_survives_an_unsupported_model" + + def run_deye_control_tests(my_predbat): """Run all DEYE control-logic tests.""" failed = False @@ -994,6 +1082,9 @@ def run_deye_control_tests(my_predbat): ("tou_days", test_payload_names_every_day_the_schedule_runs_on), ("slot_field_set", test_every_slot_carries_the_complete_field_set), ("sell_flag", test_export_slots_arm_the_sell_flag), + ("generation_never_on", test_generator_charging_is_never_switched_on_by_predbat), + ("generation_carried", test_generator_charging_the_owner_configured_is_carried_through), + ("tou_config_read", test_fetch_tou_config_caches_and_survives_an_unsupported_model), ]: try: if fn(): diff --git a/docs/components.md b/docs/components.md index 6240c6c64..8ea0380ac 100644 --- a/docs/components.md +++ b/docs/components.md @@ -781,6 +781,7 @@ Integrates with DEYE (Sunsynk-family) hybrid inverters via the DeyeCloud OpenAPI - Two deployment modes are supported: the self-hosted Home Assistant add-on manages its own DeyeCloud token from developer app credentials (`deye_auth_method: 'app_credentials'`, the default), while Predbat.com injects and refreshes the token for you (`deye_auth_method: 'oauth'`) - DEYE is mode-less like Enphase/Tesla: Predbat only owns the charge window, export window, reserve and target SOCs - the component derives the internal DEYE work mode (`SELLING_FIRST` for export, `ZERO_EXPORT_TO_CT` for charge/hold/idle) automatically from that intent. `ZERO_EXPORT_TO_CT` measures at the grid CT, so the battery serves the whole house without exporting; the stricter `ZERO_EXPORT_TO_LOAD` measures at the inverter's own output and would stop the battery serving anything not wired to it - Solar Sell is always left on. It governs whether surplus PV reaches the grid, not what the battery does, so turning it off outside export windows would curtail spare solar for most daylight hours - export windows are driven by the work mode and the slot SOC targets instead +- Predbat never enables charging from an external generator. The per-slot generator flag is the owner's setting, not Predbat's: it is read from the inverter and carried through unchanged when Predbat rewrites the programme, and left off when it cannot be read - Self-use TOU slots are written at the inverter's rated power. Zero slot power is how DEYE expresses a freeze (the battery neither charges nor discharges), so it is used only for freeze-charge and freeze-export; if neither the inverter rating nor the battery config is known, Predbat skips the control write rather than send a slot power it cannot justify - Freeze-charge is implemented via the reserve, and freeze-export is signalled by setting the export target SOC to 99% (100% already means export is disabled) - Writes are combined into one atomic `strategy_dynamic_control` call per cycle with change detection (no write when the payload is unchanged), and the resulting `orderId` is polled asynchronously until success From 919266ca4c4701926a1ee3f95ec0b0474895a785 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Wed, 19 Aug 2026 19:51:11 +0100 Subject: [PATCH 4/4] test(deye): pin the per-slot wire names against DEYE's published model Review feedback on the slot field-set test: `expected = set(TOU_FIELD.values())` reads the same dict the code under test reads, so it can only ask "does every slot carry the whole set", never "is the set right". An edit to TOU_FIELD moves both sides together and the test stays green. The point stands, and the gap is wider than the field count: nothing pinned the wire NAMES anywhere. test_deye_const_shape only checked that the logical keys existed, so `"sell": "sellEnable"` would have sailed through - and a wrong per-slot key is silent, since the API accepts the write and discards what it does not recognise. That is precisely how enableSell came to be missing. So the names are now transcribed from definitions.TimeUseSettingItem in the published Swagger and compared as an exact mapping, in test_deye_const.py where the other exact-value constants are pinned (TOU_SLOT_COUNT, FREEZE_EXPORT_SOC). Verified to fail by renaming enableSell. The deliberate absence of "voltage" is pinned by the same equality. The control test keeps deriving from TOU_FIELD: with the names pinned in the constants test, that is the right question for it to ask, and duplicating six literals into a second file would just be two places to update. A comment there says where the names are checked. The loose "TOU field is present" loop in test_deye_const_shape is dropped, being strictly weaker than the equality that replaces it. Co-Authored-By: Claude Opus 5 (1M context) --- apps/predbat/tests/test_deye_const.py | 35 ++++++++++++++++++++++--- apps/predbat/tests/test_deye_control.py | 4 +++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/apps/predbat/tests/test_deye_const.py b/apps/predbat/tests/test_deye_const.py index aa1149202..0cbce1250 100644 --- a/apps/predbat/tests/test_deye_const.py +++ b/apps/predbat/tests/test_deye_const.py @@ -14,6 +14,36 @@ from deye_const import DEYE_BASE_URLS, DEYE_ENDPOINTS, DEYE_WORKMODE, DEYE_TELEMETRY_KEYS, TOU_FIELD, TOU_SLOT_COUNT, FREEZE_EXPORT_SOC +# The per-slot wire names, transcribed from definitions.TimeUseSettingItem in DEYE's +# published Swagger (GET https://eu1-developer.deyecloud.com/v2/api-docs). Spelled out here +# rather than derived from TOU_FIELD on purpose: a test that reads the same dict it is +# checking follows a typo or a dropped entry straight into the payload without complaint. +# A wrong per-slot key is silent - the write is accepted and what the API did not recognise +# is discarded - which is how enableSell came to be missing in the first place. +# +# "voltage" is in the model but deliberately not written (battery voltage mode; Predbat +# drives SOC targets), so its absence is part of what this pins. +DEYE_TOU_WIRE_FIELDS = { + "time": "time", + "power": "power", + "soc": "soc", + "grid_charge": "enableGridCharge", + "generate": "enableGeneration", + "sell": "enableSell", +} + + +def test_tou_field_matches_the_published_model(): + """TOU_FIELD is exactly DEYE's documented per-slot model, name for name.""" + failed = False + if TOU_FIELD != DEYE_TOU_WIRE_FIELDS: + missing = {k: v for k, v in DEYE_TOU_WIRE_FIELDS.items() if TOU_FIELD.get(k) != v} + extra = {k: v for k, v in TOU_FIELD.items() if k not in DEYE_TOU_WIRE_FIELDS} + print(f"ERROR: TOU_FIELD no longer matches the published model - wrong/missing {missing}, unexpected {extra}") + failed = True + assert not failed, "test_tou_field_matches_the_published_model" + + def test_deye_const_shape(): """Constants expose the keys the component relies on.""" failed = False @@ -33,10 +63,6 @@ def test_deye_const_shape(): if k not in DEYE_TELEMETRY_KEYS: print(f"ERROR: telemetry key {k} missing") failed = True - for f in ("time", "power", "soc", "grid_charge"): - if f not in TOU_FIELD: - print(f"ERROR: TOU field {f} missing") - failed = True if TOU_SLOT_COUNT != 6: print("ERROR: TOU_SLOT_COUNT must be 6") failed = True @@ -51,6 +77,7 @@ def run_deye_const_tests(my_predbat): failed = False for name, fn in [ ("const_shape", test_deye_const_shape), + ("tou_field_model", test_tou_field_matches_the_published_model), ]: try: if fn(): diff --git a/apps/predbat/tests/test_deye_control.py b/apps/predbat/tests/test_deye_control.py index f95af6f0c..15b64d792 100644 --- a/apps/predbat/tests/test_deye_control.py +++ b/apps/predbat/tests/test_deye_control.py @@ -898,6 +898,10 @@ def test_every_slot_carries_the_complete_field_set(): """ failed = False d = MockDeye().with_rating("INV1") + # Compared against TOU_FIELD, which is what the slot builders use, so this asks "does + # every slot carry the whole set" rather than "are the names right". The names + # themselves are pinned against DEYE's published model in test_deye_const.py, which is + # the check an edit to TOU_FIELD has to get past. expected = set(TOU_FIELD.values()) schedules = [ {"reserve": 10, "charge": {"enable": True, "soc": 95, "power": 3000, "start": "02:00", "end": "05:00"}, "export": {"enable": False, "soc": 0, "power": 0}},