Skip to content

Commit 4546443

Browse files
committed
feat: add shared Deye Solarman template and observed service sequences
1 parent 1faff22 commit 4546443

6 files changed

Lines changed: 779 additions & 4 deletions

File tree

‎apps/predbat/inverter.py‎

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@
5656
_NOT_COMMITTED = object()
5757

5858

59+
class ServiceSequenceError(ValueError):
60+
"""A local validation/observation failure whose diagnostic is safe to log."""
61+
62+
5963
def within_fuzzy(value, target, fuzzy, fuzzy_below=None):
6064
"""
6165
Whether a numeric read counts as target: within fuzzy either side, or within fuzzy_below when it is
@@ -3306,19 +3310,153 @@ def set_current_from_power(self, direction, power):
33063310
new_current = round(power / self.battery_voltage, self.inv_current_dp)
33073311
self.write_and_poll_value(f"timed_{direction}_current", self.base.get_arg(f"timed_{direction}_current", indirect=False, index=self.id), new_current, fuzzy=1)
33083312

3313+
def service_sequence_number(self, value):
3314+
"""Parse a finite numeric service value without accepting booleans as zero/one."""
3315+
if isinstance(value, bool):
3316+
raise ServiceSequenceError("boolean is not a numeric control value")
3317+
number = float(value)
3318+
if not math.isfinite(number):
3319+
raise ServiceSequenceError("non-finite control value")
3320+
return number
3321+
3322+
def service_sequence_matches(self, entity_id, expected):
3323+
"""Compare an HA observation, not a hardware acknowledgement, with a service target."""
3324+
state = self.base.get_state_wrapper(entity_id, refresh=True)
3325+
return self.service_sequence_state_matches(state, expected)
3326+
3327+
def service_sequence_state_matches(self, state, expected):
3328+
"""Match a raw state without substituting a default for unavailable numeric data."""
3329+
if isinstance(expected, float):
3330+
try:
3331+
return self.service_sequence_number(state) == expected
3332+
except (ValueError, TypeError, OverflowError):
3333+
return False
3334+
return state == expected
3335+
3336+
def prepare_service_sequence(self, service_list, data, extra_data):
3337+
"""Resolve and validate every observed step before allowing the first write."""
3338+
actions = {
3339+
"number/set_value": ("value", None),
3340+
"input_number/set_value": ("value", None),
3341+
"select/select_option": ("option", None),
3342+
"input_select/select_option": ("option", None),
3343+
"switch/turn_on": (None, "on"),
3344+
"switch/turn_off": (None, "off"),
3345+
"input_boolean/turn_on": (None, "on"),
3346+
"input_boolean/turn_off": (None, "off"),
3347+
}
3348+
full_data = {**data, **extra_data, "reserve_soc": math.ceil(self.service_sequence_number(self.reserve_percent))}
3349+
steps = []
3350+
for template in service_list:
3351+
if not isinstance(template, dict) or template.get("verify") is not True:
3352+
raise ServiceSequenceError("every step must have verify: true")
3353+
resolved = {}
3354+
for key, value in template.items():
3355+
if key != "verify":
3356+
resolved[key] = self.base.resolve_arg(template, value, indirect=False, index=self.id if isinstance(value, list) else None, default=None, extra_args=full_data)
3357+
service = resolved.get("service")
3358+
if not isinstance(service, str) or service.replace(".", "/") not in actions:
3359+
raise ServiceSequenceError("unsupported observed service")
3360+
service = service.replace(".", "/")
3361+
field, expected = actions[service]
3362+
if set(resolved) != {"service", "entity_id"} | ({field} if field else set()):
3363+
raise ServiceSequenceError("observed services require only service, entity_id and their value/option")
3364+
entity_ids = resolved["entity_id"]
3365+
if not isinstance(entity_ids, str):
3366+
raise ServiceSequenceError("entity_id must resolve to a string (comma-separated for a group)")
3367+
entities = [entity.strip() for entity in entity_ids.split(",")]
3368+
entity_domain = service.split("/")[0]
3369+
if len(set(entities)) != len(entities) or any(not re.fullmatch(entity_domain + r"\.[a-z0-9_]+", entity) for entity in entities):
3370+
raise ServiceSequenceError("invalid or duplicate entity_id for observed service")
3371+
if field == "value":
3372+
expected = self.service_sequence_number(resolved[field])
3373+
elif field == "option":
3374+
expected = resolved[field]
3375+
if not isinstance(expected, str) or not expected:
3376+
raise ServiceSequenceError("option must be a non-empty string")
3377+
for entity in entities:
3378+
state = self.base.get_state_wrapper(entity, refresh=True)
3379+
if state is None or str(state).lower() in ("unknown", "unavailable", "none", ""):
3380+
raise ServiceSequenceError("{} has no available state".format(entity))
3381+
if field == "value":
3382+
self.service_sequence_number(state)
3383+
lower, upper, step = [self.service_sequence_number(self.base.get_state_wrapper(entity, attribute=attribute)) for attribute in ("min", "max", "step")]
3384+
if lower > upper or step <= 0 or not lower <= expected <= upper:
3385+
raise ServiceSequenceError("{} target is outside its numeric bounds".format(entity))
3386+
ticks = (expected - lower) / step
3387+
if not math.isfinite(ticks) or not math.isclose(ticks, round(ticks), rel_tol=0, abs_tol=1e-7):
3388+
raise ServiceSequenceError("{} target does not match its numeric step".format(entity))
3389+
elif field == "option":
3390+
options = self.base.get_state_wrapper(entity, attribute="options")
3391+
if not isinstance(options, list) or expected not in options or state not in options:
3392+
raise ServiceSequenceError("{} has an unsupported option".format(entity))
3393+
elif state not in ("on", "off"):
3394+
raise ServiceSequenceError("{} has an invalid switch state".format(entity))
3395+
service_data = {"entity_id": ",".join(entities)}
3396+
if field:
3397+
service_data[field] = expected
3398+
steps.append((service, service_data, entities, expected))
3399+
return steps
3400+
3401+
def call_service_sequence(self, service, service_list, data, extra_data):
3402+
"""Apply opt-in observed steps in order, stopping on failure without speculative rollback.
3403+
3404+
Repeated entities are intentional (disable, set target, enable). Compare their FINAL
3405+
values first so an unchanged charge does not disable/re-enable on every plan cycle.
3406+
HA can expose cached/optimistic state: this is not a physical confirmation or transaction.
3407+
"""
3408+
try:
3409+
steps = self.prepare_service_sequence(service_list, data, extra_data)
3410+
final = {entity: expected for _, _, entities, expected in steps for entity in entities}
3411+
if all(self.service_sequence_matches(entity, expected) for entity, expected in final.items()):
3412+
return
3413+
observed = {}
3414+
for service_name, service_data, entities, expected in steps:
3415+
if not all(self.service_sequence_matches(entity, value) for entity, value in observed.items()):
3416+
raise ServiceSequenceError("an earlier step no longer reads as observed")
3417+
if not all(self.service_sequence_matches(entity, expected) for entity in entities):
3418+
if not self.base.call_service_wrapper(service_name, **service_data):
3419+
raise ServiceSequenceError("{} was not accepted".format(service_name))
3420+
# Wait on HA only; this does not request faster hardware polling.
3421+
for entity in entities:
3422+
state = self._poll_after_write(entity, lambda state, expected=expected: self.service_sequence_state_matches(state, expected))
3423+
if not self.service_sequence_state_matches(state, expected):
3424+
raise ServiceSequenceError("{} was not observed".format(entity))
3425+
self.count_register_writes += len(entities)
3426+
observed.update({entity: expected for entity in entities})
3427+
if not all(self.service_sequence_matches(entity, value) for entity, value in observed.items()):
3428+
raise ServiceSequenceError("{} was not observed".format(service_name))
3429+
self.log("Inverter {} service {} final state observed in Home Assistant".format(self.id, service))
3430+
except Exception as error:
3431+
# A transport exception must also stop the rest of the sequence. Avoid logging its
3432+
# potentially credential-bearing response; validation errors above are local text.
3433+
reason = str(error) if isinstance(error, ServiceSequenceError) else type(error).__name__
3434+
message = "Warn: Inverter {} service {} sequence stopped: {}".format(self.id, service, reason)
3435+
self.log(message)
3436+
self.base.record_status(message, had_errors=True)
3437+
33093438
def call_service_template(self, service, data, domain="charge", extra_data=None):
33103439
"""
33113440
Call a service template with data
33123441
"""
33133442
if extra_data is None:
33143443
extra_data = {}
33153444
service_list = self.base.args.get(service, "")
3445+
# An explicit empty list handles a deliberate no-op, suppressing the caller's fallback.
3446+
if service_list == []:
3447+
return True
33163448
if not service_list:
33173449
return False
33183450

33193451
if not isinstance(service_list, list):
33203452
service_list = [service_list]
33213453

3454+
if any(isinstance(step, dict) and "verify" in step for step in service_list):
3455+
self.base.last_service_hash.pop(domain, None)
3456+
self.call_service_sequence(service, service_list, data, extra_data)
3457+
# Keep the configured/handled contract, even on failure (never downgrade a freeze).
3458+
return True
3459+
33223460
hash_index = domain
33233461
last_service_hash = self.base.last_service_hash.get(hash_index, "")
33243462
this_service_hash = hash(str(service) + "_" + str(data) + "_" + str(extra_data))
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# -----------------------------------------------------------------------------
2+
# Predbat Home Battery System
3+
# Copyright Trefor Southwell 2026 - All Rights Reserved
4+
# This application maybe used for personal use only and not for commercial use
5+
# -----------------------------------------------------------------------------
6+
"""Exercise the shared YAML through real native inverter and executor paths, without hardware."""
7+
8+
import copy
9+
from datetime import timedelta
10+
from pathlib import Path
11+
from unittest.mock import patch
12+
13+
import yaml
14+
15+
from config import INVERTER_DEF
16+
from inverter import Inverter
17+
from tests.test_infra import reset_inverter
18+
19+
20+
def run_deye_solarman_template_tests(my_predbat):
21+
"""Validate six-row charge/demand, failure barriers and the absence of unrelated writes."""
22+
template_path = Path(__file__).resolve().parents[3] / "templates" / "deye_solarman.yaml"
23+
with template_path.open() as stream:
24+
template = yaml.safe_load(stream)["pred_bat"]
25+
saved_definition = copy.deepcopy(INVERTER_DEF.get("DeyeSolarman"))
26+
saved_call = my_predbat.call_service_wrapper
27+
writes = []
28+
rejected = set()
29+
failed = False
30+
ha = my_predbat.ha_interface
31+
sources = ["select.test_deye_program_{}_charging".format(row) for row in range(1, 7)]
32+
targets = ["number.test_deye_program_{}_soc".format(row) for row in range(1, 7)]
33+
protected = {"time.test_deye_program_{}_time".format(row): "{:02d}:00:00".format((row - 1) * 4) for row in range(1, 7)}
34+
protected.update({"number.test_deye_program_{}_power".format(row): 3000 for row in range(1, 7)})
35+
protected.update(
36+
{
37+
"select.test_deye_work_mode": "Zero Export To CT",
38+
"select.test_deye_time_of_use": "Week",
39+
"number.test_deye_battery_max_charging_current": 59,
40+
"number.test_deye_battery_max_discharging_current": 160,
41+
"number.test_deye_battery_shutdown_soc": 5,
42+
}
43+
)
44+
45+
def check(condition, message):
46+
"""Collect assertions so one failing case does not hide the remaining evidence."""
47+
nonlocal failed
48+
if not condition:
49+
print("ERROR: Deye/Solarman template: " + message)
50+
failed = True
51+
52+
def service_call(service, **data):
53+
"""Model HA's grouped state-setting services, retaining entity attributes."""
54+
entities = data.get("entity_id", "").split(",")
55+
writes.append((service, copy.deepcopy(data)))
56+
if rejected.intersection(entities):
57+
return False
58+
for entity in entities:
59+
check(entity in sources + targets, "unexpected physical write to " + entity)
60+
if entity in sources + targets:
61+
ha.dummy_items[entity]["state"] = data.get("option", data.get("value"))
62+
return True
63+
64+
def rows(source, target):
65+
"""Check all six observations, never just the first row."""
66+
return all(ha.get_state(entity) == source for entity in sources) and all(float(ha.get_state(entity)) == target for entity in targets)
67+
68+
try:
69+
reset_inverter(my_predbat)
70+
my_predbat.args.clear()
71+
my_predbat.args.update(template)
72+
my_predbat.args["deye_entity_prefix"] = "test_deye"
73+
my_predbat.args_from_apps_yaml = copy.deepcopy(my_predbat.args)
74+
my_predbat.last_service_hash = {}
75+
ha.dummy_items.update(protected)
76+
for entity in sources:
77+
ha.dummy_items[entity] = {"state": "Grid", "options": ["Disabled", "Grid", "Generator", "Both"]}
78+
for entity in targets:
79+
ha.dummy_items[entity] = {"state": 40, "min": 0, "max": 100, "step": 1, "unit_of_measurement": "%"}
80+
for suffix, value in (("battery", 30), ("battery_voltage", 51.2), ("battery_temperature", 20), ("battery_power", 0), ("pv_power", 0), ("load_power", 500), ("grid_power", 500)):
81+
ha.dummy_items["sensor.test_deye_" + suffix] = value
82+
my_predbat.call_service_wrapper = service_call
83+
inv = Inverter(my_predbat, 0, quiet=True)
84+
inv.sleep = lambda seconds: None
85+
inv.inv_write_and_poll_sleep = 0
86+
inv.update_status(my_predbat.minutes_now)
87+
my_predbat.inverters = [inv]
88+
check(not writes, "construction must not send hardware services")
89+
check(template["template"] is True, "shipped example must stay in template mode")
90+
check(not inv.inv_support_charge_freeze and not inv.inv_support_discharge_freeze and not inv.inv_has_timed_pause, "unsupported freeze/pause must remain disabled")
91+
check(inv.inv_output_charge_control == "none", "global rate controls must remain unmapped")
92+
check("discharge_start_service" not in template, "forced export must be absent")
93+
94+
# Both window bookkeeping and rate/target updates are private sensors, not ToU rows.
95+
inv.adjust_charge_window(my_predbat.midnight_utc + timedelta(hours=23), my_predbat.midnight_utc + timedelta(hours=25), 23 * 60)
96+
inv.adjust_charge_rate(1000)
97+
inv.adjust_discharge_rate(0)
98+
inv.adjust_battery_target(80, isCharging=True)
99+
check(not writes, "native bookkeeping must not write physical time, current or SOC controls")
100+
for key in ("charge_start_time", "charge_end_time", "discharge_start_time", "discharge_end_time"):
101+
check(my_predbat.get_arg(key, indirect=False, index=0) in inv.created_attributes, "time bookkeeping must use created sensors")
102+
103+
inv.adjust_charge_immediate(80)
104+
check(rows("Grid", 80), "charge must set all six targets and sources")
105+
check([data.get("option", data.get("value")) for _, data in writes] == ["Disabled", 80.0, "Grid"], "charge must observe disable → SOC → enable")
106+
writes.clear()
107+
inv.adjust_charge_immediate(80)
108+
inv.adjust_export_immediate(100)
109+
check(not writes and rows("Grid", 80), "repeated charge and idle export hook must not clobber a charge")
110+
111+
inv.adjust_charge_immediate(0)
112+
check(rows("Disabled", 10), "demand must disable charging and restore the domestic floor, not shutdown")
113+
my_predbat.expose_config("set_reserve_min", 25)
114+
inv.refresh_config()
115+
inv.adjust_charge_immediate(0)
116+
check(rows("Disabled", 25), "demand must follow the current manual domestic minimum")
117+
118+
writes.clear()
119+
rejected.add(targets[-1])
120+
inv.adjust_charge_immediate(90)
121+
check(not any(data.get("option") == "Grid" for _, data in writes), "failed last target must prevent source enable")
122+
rejected.clear()
123+
inv.adjust_charge_immediate(90)
124+
check(rows("Grid", 90), "next cycle must retry a failed sequence")
125+
126+
# Native reset writes 100 to its private target, never to the physical ToU targets.
127+
my_predbat.set_read_only = False
128+
my_predbat.set_charge_window = True
129+
my_predbat.set_export_window = False
130+
my_predbat.reset_inverter()
131+
check(rows("Disabled", 25), "native reset must leave demand, not grid-charge to 100")
132+
133+
# Drive an active native plan, including the trailing discharge-stop hook and rates.
134+
my_predbat.args["template"] = False
135+
my_predbat.inverter_hybrid = True
136+
my_predbat.set_soc_enable = True
137+
my_predbat.set_reserve_enable = False
138+
my_predbat.set_charge_freeze = False
139+
my_predbat.set_charge_low_power = False
140+
my_predbat.set_discharge_during_charge = True
141+
my_predbat.inverter_needs_reset = False
142+
my_predbat.fetch_inverter_data()
143+
inv = my_predbat.inverters[0]
144+
inv.sleep = lambda seconds: None
145+
my_predbat.charge_window_best = [{"start": my_predbat.minutes_now, "end": my_predbat.minutes_now + 60, "average": 1}]
146+
my_predbat.charge_limit_best = [8]
147+
my_predbat.charge_limit_percent_best = [80]
148+
my_predbat.execute_plan()
149+
check(rows("Grid", 80), "native active charge plan must reach all six rows")
150+
my_predbat.charge_window_best = []
151+
my_predbat.charge_limit_best = []
152+
my_predbat.execute_plan()
153+
check(rows("Disabled", 25), "native demand plan must stop grid charging")
154+
writes.clear()
155+
my_predbat.set_read_only = True
156+
my_predbat.execute_plan()
157+
check(not writes, "read-only native plan must not dispatch services")
158+
my_predbat.set_read_only = False
159+
my_predbat.set_charge_window = False
160+
my_predbat.set_export_window = False
161+
my_predbat.set_soc_enable = False
162+
my_predbat.execute_plan()
163+
check(not writes, "Monitor must not dispatch services")
164+
check(all(ha.get_state(entity) == value for entity, value in protected.items()), "all unmapped installation settings must stay untouched")
165+
166+
# A transport ValueError is not one of our local diagnostics. Never echo its body.
167+
with patch.object(my_predbat, "call_service_wrapper", side_effect=ValueError("private-response-marker")), patch.object(inv, "log") as log, patch.object(my_predbat, "record_status") as status:
168+
check(inv.call_service_template("charge_start_service", {"target_soc": 95}) is True, "a transport exception must remain handled")
169+
check(status.called, "transport exception must record an error")
170+
check("private-response-marker" not in str(log.call_args_list) + str(status.call_args_list), "transport response must not leak into diagnostics")
171+
finally:
172+
my_predbat.call_service_wrapper = saved_call
173+
if saved_definition is None:
174+
INVERTER_DEF.pop("DeyeSolarman", None)
175+
else:
176+
INVERTER_DEF["DeyeSolarman"] = saved_definition
177+
print("Deye/Solarman template tests: {}".format("FAILED" if failed else "PASSED"))
178+
return failed

0 commit comments

Comments
 (0)