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
104 changes: 97 additions & 7 deletions apps/predbat/deye.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -59,6 +60,7 @@
DEYE_RESTORE_MAX_CONTROL,
DEYE_CACHE_STATIC,
DEYE_CACHE_CONFIG,
DEYE_CACHE_TOU,
DEYE_CACHE_RATINGS,
DEYE_CACHE_CONTROL,
)
Expand Down Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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).

Expand Down Expand Up @@ -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", {})
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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"],
Expand All @@ -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,
}

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1339,14 +1420,23 @@ 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:
if await self.fetch_battery_config(sn):
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.
Expand Down
40 changes: 38 additions & 2 deletions apps/predbat/deye_const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
1 change: 1 addition & 0 deletions apps/predbat/tests/test_deye_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down
35 changes: 31 additions & 4 deletions apps/predbat/tests/test_deye_const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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():
Expand Down
Loading
Loading