Skip to content

Commit 63e8238

Browse files
committed
feat: add experimental local Deye ToU adapter and isolated preview
1 parent d6985b6 commit 63e8238

11 files changed

Lines changed: 3705 additions & 0 deletions

File tree

‎apps/predbat/config.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2113,6 +2113,36 @@
21132113
"charge_discharge_with_rate": True,
21142114
"target_soc_used_for_discharge": True,
21152115
},
2116+
"DEYE": {
2117+
"name": "Deye local ToU",
2118+
"has_rest_api": False,
2119+
"has_mqtt_api": False,
2120+
"output_charge_control": "none",
2121+
"charge_control_immediate": False,
2122+
"current_dp": 0,
2123+
"has_charge_enable_time": False,
2124+
"has_discharge_enable_time": False,
2125+
"has_target_soc": True,
2126+
"has_reserve_soc": False,
2127+
"has_timed_pause": False,
2128+
"charge_time_format": "S",
2129+
"charge_time_entity_is_option": False,
2130+
"soc_units": "%",
2131+
"num_load_entities": 1,
2132+
"has_ge_inverter_mode": False,
2133+
"has_ge_eco_toggle": False,
2134+
"time_button_press": False,
2135+
"clock_time_format": "%Y-%m-%d %H:%M:%S",
2136+
"write_and_poll_sleep": 5,
2137+
"has_time_window": False,
2138+
# Enabled per instance only after explicit commissioning of the ToU hold.
2139+
"support_charge_freeze": False,
2140+
"support_discharge_freeze": False,
2141+
"has_idle_time": False,
2142+
"can_span_midnight": True,
2143+
"charge_discharge_with_rate": False,
2144+
"target_soc_used_for_discharge": True,
2145+
},
21162146
"SK": {
21172147
"name": "Sunsynk",
21182148
"has_rest_api": False,
@@ -2597,6 +2627,8 @@
25972627

25982628
# Apps.yaml validation schema
25992629
APPS_SCHEMA = {
2630+
"deye_local": {"type": "dict"},
2631+
"deye_shadow": {"type": "dict"},
26002632
"currency_symbols": {"type": "string|string_list"},
26012633
"db_enable": {"type": "boolean"},
26022634
"db_days": {"type": "integer"},

‎apps/predbat/deye_local.py‎

Lines changed: 291 additions & 0 deletions
Large diffs are not rendered by default.

‎apps/predbat/deye_local_shadow.py‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""Exercise the DEYE command translator against an isolated HA state copy."""
2+
3+
from copy import deepcopy
4+
from datetime import datetime, timezone
5+
from types import SimpleNamespace
6+
7+
from deye_local import DeyeLocal
8+
9+
10+
class DeyeShadowState:
11+
"""A narrow in-memory interface with no route to a real control service."""
12+
13+
def __init__(self, base, settings):
14+
"""Copy the required observations once; never retain the real base object."""
15+
self.prefix = base.prefix
16+
self.set_read_only = False
17+
self.set_read_only_axle = False
18+
self.manual_soc_max_keep = deepcopy(base.manual_soc_max_keep)
19+
self.timezone = base.get_arg("timezone", "Europe/London")
20+
self.states = {}
21+
self.writes = []
22+
self.report = {}
23+
self.errors = []
24+
self.allowed = {row[field] for row in settings["programs"] for field in ("soc", "power", "charging")}
25+
entities = {entity for row in settings["programs"] for entity in row.values()}
26+
entities.update(settings[key] for key in ("time_of_use", "work_mode", "energy_pattern", "telemetry_time"))
27+
for entity in entities:
28+
self.states[entity] = deepcopy(base.get_state_wrapper(entity, raw=True))
29+
30+
def get_arg(self, name, default=None):
31+
"""Expose only the timezone needed to parse the existing clock reading."""
32+
return self.timezone if name == "timezone" else default
33+
34+
def get_state_wrapper(self, entity, raw=False):
35+
"""Read a copied observation, not Home Assistant or a device."""
36+
return deepcopy(self.states.get(entity))
37+
38+
def call_service_wrapper(self, service, **data):
39+
"""Record the proposed write and simulate acceptance only in this copy."""
40+
entity = data["entity_id"]
41+
if entity not in self.allowed or service not in ("number/set_value", "select/select_option"):
42+
raise ValueError("Unexpected DEYE shadow control")
43+
value = data["value"] if service == "number/set_value" else data["option"]
44+
self.writes.append({"service": service, "entity_id": entity, "value": value})
45+
self.states[entity]["state"] = value
46+
return True
47+
48+
def record_status(self, message, had_errors=False):
49+
"""Retain isolated failures without changing the native planner's status."""
50+
self.errors.append(message)
51+
52+
def dashboard_item(self, entity, state, attributes):
53+
"""Capture adapter output without publishing intermediate simulation states."""
54+
self.report = {"state": state, **attributes}
55+
56+
57+
def preview_deye_local(inverter, target_soc, freeze=False):
58+
"""Publish a non-executing trace alongside the unchanged native controller.
59+
60+
Success means the translator completed against simulated acknowledgements,
61+
not that the real inverter accepted or executed anything. No capabilities,
62+
manual targets, windows, rate intents or actual observations are changed.
63+
"""
64+
base = inverter.base
65+
settings = base.args.get("deye_shadow")
66+
if settings is None:
67+
return None
68+
report = {
69+
"generated_at": datetime.now(timezone.utc).isoformat(),
70+
"execution_enabled": False,
71+
"actuator_signals_modified": False,
72+
"ready_to_activate": False,
73+
"hardware_confirmed": False,
74+
"target_soc": target_soc,
75+
"freeze": bool(freeze),
76+
"native_inverter_type": inverter.inverter_type,
77+
"simulated_acknowledgements": True,
78+
"proposed_writes": [],
79+
}
80+
state = "blocked"
81+
try:
82+
if not isinstance(settings, dict) or settings.get("control", False) is not False:
83+
raise ValueError("deye_shadow cannot enable physical control")
84+
settings = DeyeLocal.validate_settings({**settings, "control": True})
85+
simulated = DeyeShadowState(base, settings)
86+
fake_inverter = SimpleNamespace(
87+
base=simulated,
88+
reserve_percent=inverter.reserve_percent,
89+
battery_rate_max_discharge=inverter.battery_rate_max_discharge,
90+
inv_write_and_poll_sleep=0,
91+
count_register_writes=0,
92+
)
93+
adapter = DeyeLocal(fake_inverter)
94+
adapter.settings = settings
95+
success = adapter.apply(target_soc, freeze)
96+
state = "simulated" if success else "blocked"
97+
report["reason"] = simulated.report.get("reason", "Physical commissioning and executable integration remain required")
98+
report["mode"] = simulated.report.get("mode", "none")
99+
report["proposed_writes"] = deepcopy(simulated.writes)
100+
report["proposed_write_count"] = len(simulated.writes)
101+
report["hold_verified"] = settings.get("hold_verified", False)
102+
if not success and "boundaries must be strictly increasing" in report["reason"]:
103+
# Keep the REAL result blocked. Also exercise the same translator
104+
# under an explicitly labelled table-repair hypothesis, so a legacy
105+
# two-boundary SK mapping does not prevent useful offline traces.
106+
times = [simulated.states[row["time"]]["state"] for row in settings["programs"]]
107+
minutes = [int(stamp[:2]) * 60 + int(stamp[3:5]) for stamp in times]
108+
if len(set(minutes)) == 6:
109+
ordered = [stamp for _, stamp in sorted(zip(minutes, times))]
110+
for row, stamp in zip(settings["programs"], ordered):
111+
simulated.states[row["time"]]["state"] = stamp
112+
simulated.writes.clear()
113+
repaired_success = adapter.apply(target_soc, freeze)
114+
report["table_repair_hypothesis"] = {
115+
"physical_table_changed": False,
116+
"required_before_activation": True,
117+
"ordered_start_times": ordered,
118+
"translation_succeeded": repaired_success,
119+
"proposed_writes": simulated.writes,
120+
}
121+
except Exception as exc:
122+
# A preview must never interrupt SK's actual charge/stop action.
123+
report["reason"] = str(exc) if isinstance(exc, ValueError) else "DEYE shadow input or simulation error"
124+
base.dashboard_item(base.prefix + ".deye_shadow", state=state, attributes=report)
125+
return report

‎apps/predbat/inverter.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,14 @@ def refresh_config(self, quiet=False):
502502
self.battery_scaling_config = self.battery_scaling
503503

504504
self.inverter_type = self.base.get_arg("inverter_type", "GE", indirect=False, index=self.id)
505+
if self.inverter_type == "DEYE":
506+
from deye_local import DeyeLocal
507+
508+
if not getattr(self, "deye_local", None):
509+
self.deye_local = DeyeLocal(self)
510+
self.deye_local.prepare()
511+
else:
512+
self.deye_local = None
505513
# An assumed type is not a chosen one. The user-facing "source returned no data" warnings
506514
# must not name it as though the user had picked it (#4990); those messages re-derive the
507515
# fact at message time (inverter_type_is_assumed) because discovery can set the type later.
@@ -553,6 +561,8 @@ def refresh_config(self, quiet=False):
553561
self.inv_soc_units = INVERTER_DEF[self.inverter_type]["soc_units"]
554562
self.inv_time_button_press = INVERTER_DEF[self.inverter_type]["time_button_press"]
555563
self.inv_support_charge_freeze = INVERTER_DEF[self.inverter_type]["support_charge_freeze"]
564+
if self.deye_local:
565+
self.inv_support_charge_freeze = self.deye_local.settings.get("hold_verified", False)
556566
self.inv_support_discharge_freeze = INVERTER_DEF[self.inverter_type]["support_discharge_freeze"]
557567
# True only for inverters whose Freeze Export really is a "Feed-in First" mode (load, then
558568
# export, then battery) - most just disable charging, so PV above the export limit is clipped
@@ -827,6 +837,9 @@ def refresh_config(self, quiet=False):
827837
self.base.args["idle_start_time"][self.id] = self.create_entity("idle_start_time", "00:00:00")
828838
self.base.args["idle_end_time"][self.id] = self.create_entity("idle_end_time", "00:00:00")
829839

840+
if self.deye_local:
841+
self.deye_local.validate_limits()
842+
830843
def battery_size_tracking(self):
831844
# Battery size determination: fused auto-scaling + find_battery_size logic
832845
# If soc_max is unset, automatically enable battery_scaling_auto
@@ -3387,6 +3400,15 @@ def adjust_charge_immediate(self, target_soc, freeze=False):
33873400
"""
33883401
Adjust from charging or not charging based on passed target soc
33893402
"""
3403+
if self.base.args.get("deye_shadow") is not None:
3404+
try:
3405+
from deye_local_shadow import preview_deye_local
3406+
3407+
preview_deye_local(self, target_soc, freeze)
3408+
except Exception:
3409+
self.log("Warn: DEYE shadow preview failed; native inverter control continues unchanged")
3410+
if getattr(self, "deye_local", None):
3411+
return self.deye_local.apply(target_soc, freeze=freeze)
33903412
# A Solis with a target SoC (FB00) has its Energy Storage Control Switch driven from here on every cycle
33913413
# that is not exporting. It stays on Backup/Reserve - Self-Use with the Battery Reserve bit, which makes the
33923414
# reserve Predbat writes (the Reserved SOC) a real discharge floor, so the existing reserve holds work.
@@ -3436,6 +3458,11 @@ def adjust_export_immediate(self, target_soc, freeze=False):
34363458
"""
34373459
Adjust from exporting or not exporting based on passed target soc
34383460
"""
3461+
if getattr(self, "deye_local", None):
3462+
if target_soc < 100 or freeze:
3463+
self.base.record_status("Error: DEYE local does not support forced export or export freeze", had_errors=True)
3464+
# Idle export must not reset the charge/hold just applied this cycle.
3465+
return
34393466
# FB00's Energy Storage Control Switch on an exporting cycle: Feed-in priority - No Grid Charging for a freeze
34403467
# export (PV goes to the load then the grid ahead of the battery, which still covers the load - the plugin
34413468
# offers no Feed-in priority with the Battery Reserve bit, so the inverter's own minimum SoC is the floor), and

0 commit comments

Comments
 (0)