diff --git a/apps/predbat/deye.py b/apps/predbat/deye.py index da9a7f92b..ba1cfdd2b 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, @@ -59,6 +60,7 @@ DEYE_RESTORE_MAX_CONTROL, DEYE_CACHE_STATIC, DEYE_CACHE_CONFIG, + DEYE_CACHE_TOU, DEYE_CACHE_RATINGS, DEYE_CACHE_CONTROL, ) @@ -114,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 = {} @@ -487,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). @@ -524,7 +562,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", {}) @@ -574,11 +615,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"]: False, 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"]: False, + 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.""" @@ -736,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"], @@ -755,8 +815,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, } @@ -1215,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.""" @@ -1267,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 @@ -1339,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: @@ -1347,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 d57f96d4d..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 @@ -212,16 +213,51 @@ # 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). +# +# 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 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. +# +# 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_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_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 456284bfb..15b64d792 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,203 @@ 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") + # 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}}, + {"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 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 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 @@ -886,6 +1083,12 @@ 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), + ("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