Skip to content

Commit 5092894

Browse files
Merge pull request #4745 from springfall2008/fix/inverter-respect-configured-time-entity
2 parents 82bee43 + 94a05e9 commit 5092894

2 files changed

Lines changed: 227 additions & 1 deletion

File tree

‎apps/predbat/inverter.py‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
import math
2121
import os
22+
import re
2223
import time
2324
import pytz
2425
from datetime import datetime, timedelta
@@ -329,6 +330,40 @@ def create_missing_arg(self, arg, default):
329330
# then looked up as one.
330331
self.base.args[arg] = self.base.args[arg] + [None] * (self.id + 1 - len(self.base.args[arg]))
331332

333+
def user_configured_entity(self, arg):
334+
"""
335+
The entity id the user gave this inverter for arg in apps.yaml, or None.
336+
337+
"Set in apps.yaml" is judged from args_from_apps_yaml, the snapshot taken before Predbat's
338+
own defaulting touches args: apps.yaml must name an entity (or a re: pattern) for this
339+
inverter. The entity itself is read from the live args, which by now has any re: pattern
340+
resolved, and is never Predbat's own dummy for this slot - so a dummy written on an earlier
341+
refresh never counts, even behind a pattern that matched nothing. A bare placeholder such as
342+
'00:00:00', an unmatched pattern, or a malformed value gives None.
343+
"""
344+
raw = (getattr(self.base, "args_from_apps_yaml", None) or {}).get(arg)
345+
if isinstance(raw, list):
346+
# Per inverter: a list naming inverter 0 only says nothing about inverter 1, whose live
347+
# slot may by now hold the dummy an earlier refresh wrote there
348+
raw = raw[self.id] if self.id < len(raw) else None
349+
elif self.id > 0:
350+
# A single value is inverter 0's, as create_missing_arg() has always treated it
351+
raw = None
352+
# Judged on what apps.yaml itself says, not just that it said something: a placeholder such as
353+
# Sofar's '00:00:00' is set, but the live slot holds Predbat's own dummy from the first cycle on
354+
if not (isinstance(raw, str) and (is_entity_id(raw) or raw.startswith("re:"))):
355+
return None
356+
value = self.base.args.get(arg)
357+
if isinstance(value, list):
358+
value = value[self.id] if self.id < len(value) else None
359+
if not is_entity_id(value) or value.startswith("re:"):
360+
return None
361+
# A re: pattern that matched nothing leaves the slot empty, so from the second cycle on it holds
362+
# the dummy Predbat wrote there - under whichever inverter type was current at the time
363+
if re.fullmatch(r"sensor\.{}_.+_{}_{}".format(re.escape(self.base.prefix), self.id, re.escape(arg)), value):
364+
return None
365+
return value
366+
332367
def __init__(self, base, id=0, quiet=False):
333368
"""
334369
Inverter class
@@ -771,11 +806,19 @@ def refresh_config(self, quiet=False):
771806
self.base.args["inverter_mode"][self.id] = self.create_entity("inverter_mode", "Eco")
772807

773808
if self.inv_charge_time_format != "HH:MM:SS":
809+
# Some formats (H M) decompose the write into separate hour/minute entities and never
810+
# expect the user to set this directly; others (no time window at all) ship a bare
811+
# placeholder string. Either way Predbat needs somewhere to read/write for its own window
812+
# bookkeeping. But if the user has genuinely pointed this at a real entity - a custom
813+
# inverter definition using a non-HH:MM:SS format together with a real time window, e.g.
814+
# #4738 - that's the one to use, not a self-created dummy that silently discards it.
774815
for x in ["charge", "discharge"]:
775816
for y in ["start", "end"]:
776817
entity_name = f"{x}_{y}_time"
818+
# Read before create_missing_arg(), which turns a single value into a list of defaults
819+
configured = self.user_configured_entity(entity_name)
777820
self.create_missing_arg(entity_name, "23:59:00")
778-
self.base.args[entity_name][self.id] = self.create_entity(entity_name, "23:59:00")
821+
self.base.args[entity_name][self.id] = configured if configured else self.create_entity(entity_name, "23:59:00")
779822

780823
# Create dummy idle time entities
781824
if not self.inv_has_idle_time:

‎apps/predbat/tests/test_inverter.py‎

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,188 @@ def test_solis_energy_control(test_name, my_predbat, ha, inverter_type, switch_s
118118
return failed
119119

120120

121+
def test_custom_type_respects_configured_time_entity(my_predbat):
122+
"""
123+
Regression test for issue #4738: for an inverter type whose charge_time_format is not
124+
HH:MM:SS, a real time entity the user set in apps.yaml for charge/discharge start/end time must
125+
be used, not silently replaced by a self-created dummy sensor.
126+
127+
"Set in apps.yaml" is judged from args_from_apps_yaml, the snapshot taken before Predbat's own
128+
defaulting, so a dummy Predbat wrote into args on an earlier refresh never reads back as user
129+
configuration. Since #5126 inverters persist and refresh_config() runs every plan cycle, so the
130+
refresh path is tested on one object as well as fresh construction.
131+
"""
132+
failed = False
133+
print("Test: test_custom_type_respects_configured_time_entity")
134+
135+
# Restored in place: other objects hold a reference to this dict, so rebinding the attribute
136+
# would leave them with the mutated one
137+
saved_args = copy.deepcopy(my_predbat.args)
138+
had_raw = hasattr(my_predbat, "args_from_apps_yaml")
139+
saved_raw = copy.deepcopy(getattr(my_predbat, "args_from_apps_yaml", None))
140+
# Each construction also writes its dummy entities into the shared HA mock
141+
saved_items = dict(getattr(my_predbat.ha_interface, "dummy_items", {}))
142+
test_types = ["TEST_TIME_ENTITY", "TEST_TIME_ENTITY_2"]
143+
saved_defs = {name: copy.deepcopy(INVERTER_DEF.get(name)) for name in test_types}
144+
time_args = ["charge_start_time", "charge_end_time", "discharge_start_time", "discharge_end_time"]
145+
146+
def configure(apps_yaml):
147+
"""Stand in for loading apps.yaml: args and its pre-defaulting snapshot both get the values."""
148+
for arg in time_args:
149+
my_predbat.args.pop(arg, None)
150+
my_predbat.args.update(copy.deepcopy(apps_yaml))
151+
my_predbat.args_from_apps_yaml = copy.deepcopy(apps_yaml)
152+
153+
def dummy(arg, inverter_type="TEST_TIME_ENTITY", inverter_id=0):
154+
"""The entity id create_entity() gives this inverter's dummy."""
155+
return "sensor.{}_{}_{}_{}".format(my_predbat.prefix, inverter_type, inverter_id, arg)
156+
157+
def check(case, arg, expected, inverter_id=0):
158+
"""Report a mismatch in the live args slot."""
159+
got = my_predbat.args[arg][inverter_id]
160+
if got != expected:
161+
print("ERROR: test_custom_type_respects_configured_time_entity: {}: {}[{}] should be {}, got {}".format(case, arg, inverter_id, expected, got))
162+
return True
163+
return False
164+
165+
try:
166+
my_predbat.args["inverter_type"] = ["TEST_TIME_ENTITY"]
167+
my_predbat.args["inverter"] = {"charge_time_format": "S", "has_time_window": True, "has_charge_enable_time": True, "has_discharge_enable_time": True}
168+
my_predbat.args["givtcp_rest"] = None
169+
170+
# Case 1: real entities set in apps.yaml are kept, on construction and on every refresh
171+
configure({arg: ["time.real_" + arg] for arg in time_args})
172+
inv = Inverter(my_predbat, 0, quiet=True)
173+
inv.refresh_config()
174+
for arg in time_args:
175+
failed |= check("apps.yaml list value", arg, "time.real_" + arg)
176+
177+
# Case 2: a single (non-list) value in apps.yaml is kept too - create_missing_arg() turns a
178+
# single value into a list of defaults, so it has to be put back
179+
configure({"discharge_start_time": "time.real_single"})
180+
Inverter(my_predbat, 0, quiet=True)
181+
failed |= check("apps.yaml single value", "discharge_start_time", "time.real_single")
182+
183+
# Case 3: nothing set - the dummy is created
184+
configure({})
185+
inv = Inverter(my_predbat, 0, quiet=True)
186+
failed |= check("unset", "discharge_start_time", dummy("discharge_start_time"))
187+
188+
# Case 4: persisted object - refresh keeps its own dummy (it is not in apps.yaml, so it never
189+
# reads back as configuration) and re-creates the state if HA has lost it
190+
my_predbat.ha_interface.dummy_items.pop(dummy("discharge_start_time"), None)
191+
inv.refresh_config()
192+
failed |= check("refresh after HA lost the dummy", "discharge_start_time", dummy("discharge_start_time"))
193+
if my_predbat.get_state_wrapper(dummy("discharge_start_time")) is None:
194+
print("ERROR: test_custom_type_respects_configured_time_entity: refresh should re-create the dummy state HA lost")
195+
failed = True
196+
197+
# Case 5: the inverter type changes on the persisted object, as it does in production (execute.py
198+
# refreshes rather than rebuilds) - the old type's dummy is replaced by the new type's
199+
my_predbat.args["inverter_type"] = ["TEST_TIME_ENTITY_2"]
200+
inv.refresh_config()
201+
failed |= check("type change on refresh", "discharge_start_time", dummy("discharge_start_time", "TEST_TIME_ENTITY_2"))
202+
my_predbat.args["inverter_type"] = ["TEST_TIME_ENTITY"]
203+
204+
# Case 6: a bare placeholder set in apps.yaml (the shipped Sofar templates do this) is not an
205+
# entity - the dummy replaces it. And it stays a dummy on refresh: apps.yaml did set the key,
206+
# but not to an entity, so the dummy now in the live slot must not be trusted as configured
207+
configure({"discharge_start_time": ["00:00:00"]})
208+
inv = Inverter(my_predbat, 0, quiet=True)
209+
failed |= check("bare placeholder", "discharge_start_time", dummy("discharge_start_time"))
210+
my_predbat.ha_interface.dummy_items.pop(dummy("discharge_start_time"), None)
211+
inv.refresh_config()
212+
if my_predbat.get_state_wrapper(dummy("discharge_start_time")) is None:
213+
print("ERROR: test_custom_type_respects_configured_time_entity: bare placeholder: the dummy was read back as configured on refresh and its lost state not re-created")
214+
failed = True
215+
216+
# Case 7: an entity in args that apps.yaml never set is not user configuration
217+
configure({})
218+
my_predbat.args["discharge_start_time"] = ["time.not_from_apps_yaml"]
219+
Inverter(my_predbat, 0, quiet=True)
220+
failed |= check("entity not from apps.yaml", "discharge_start_time", dummy("discharge_start_time"))
221+
222+
# Case 8: apps.yaml names inverter 0 only. Inverter 1 gets its own dummy, and it is still
223+
# treated as a dummy on refresh - its live slot then holds an entity id, but apps.yaml set
224+
# nothing for it, so a lost state must be re-created rather than the slot trusted as configured
225+
my_predbat.args["inverter_type"] = ["TEST_TIME_ENTITY", "TEST_TIME_ENTITY"]
226+
configure({"discharge_start_time": ["time.real_inverter0"]})
227+
Inverter(my_predbat, 0, quiet=True)
228+
inv1 = Inverter(my_predbat, 1, quiet=True)
229+
my_predbat.ha_interface.dummy_items.pop(dummy("discharge_start_time", inverter_id=1), None)
230+
inv1.refresh_config()
231+
failed |= check("multi-inverter, inverter 0", "discharge_start_time", "time.real_inverter0", 0)
232+
failed |= check("multi-inverter, inverter 1", "discharge_start_time", dummy("discharge_start_time", inverter_id=1), 1)
233+
if my_predbat.get_state_wrapper(dummy("discharge_start_time", inverter_id=1)) is None:
234+
print("ERROR: test_custom_type_respects_configured_time_entity: multi-inverter: inverter 1's dummy was read back as configured and its lost state not re-created")
235+
failed = True
236+
237+
# Case 8b: a single value in apps.yaml is inverter 0's - inverter 1 gets its own dummy and
238+
# keeps treating it as one on refresh
239+
configure({"discharge_start_time": "time.real_single"})
240+
Inverter(my_predbat, 0, quiet=True)
241+
inv1 = Inverter(my_predbat, 1, quiet=True)
242+
my_predbat.ha_interface.dummy_items.pop(dummy("discharge_start_time", inverter_id=1), None)
243+
inv1.refresh_config()
244+
failed |= check("single value, inverter 0", "discharge_start_time", "time.real_single", 0)
245+
failed |= check("single value, inverter 1", "discharge_start_time", dummy("discharge_start_time", inverter_id=1), 1)
246+
if my_predbat.get_state_wrapper(dummy("discharge_start_time", inverter_id=1)) is None:
247+
print("ERROR: test_custom_type_respects_configured_time_entity: single value: inverter 1's dummy was read back as configured and its lost state not re-created")
248+
failed = True
249+
my_predbat.args["inverter_type"] = ["TEST_TIME_ENTITY"]
250+
251+
# Case 9: a malformed value (a nested list) must not crash the refresh; the dummy is used
252+
configure({"discharge_start_time": [["time.nested"]]})
253+
Inverter(my_predbat, 0, quiet=True)
254+
failed |= check("malformed value", "discharge_start_time", dummy("discharge_start_time"))
255+
256+
# Case 10: a re: pattern that matched nothing. auto_config() deletes a single unmatched key and
257+
# leaves None in an unmatched list slot, so the first cycle writes the dummy there - and from
258+
# then on the pattern must not make that dummy read back as the user's entity
259+
for shape, apps_yaml_value, live_value in (("single", "re:(time\\.nothing_.*)", None), ("list", ["re:(time\\.nothing_.*)"], [None])):
260+
configure({})
261+
my_predbat.args_from_apps_yaml = {"discharge_start_time": apps_yaml_value}
262+
if live_value is not None:
263+
my_predbat.args["discharge_start_time"] = live_value
264+
inv = Inverter(my_predbat, 0, quiet=True)
265+
failed |= check("unmatched re: ({})".format(shape), "discharge_start_time", dummy("discharge_start_time"))
266+
my_predbat.ha_interface.dummy_items.pop(dummy("discharge_start_time"), None)
267+
inv.refresh_config()
268+
if my_predbat.get_state_wrapper(dummy("discharge_start_time")) is None:
269+
print("ERROR: test_custom_type_respects_configured_time_entity: unmatched re: ({}): the dummy was read back as configured and its lost state not re-created".format(shape))
270+
failed = True
271+
my_predbat.args["inverter_type"] = ["TEST_TIME_ENTITY_2"]
272+
inv.refresh_config()
273+
failed |= check("unmatched re: ({}), type change".format(shape), "discharge_start_time", dummy("discharge_start_time", "TEST_TIME_ENTITY_2"))
274+
my_predbat.args["inverter_type"] = ["TEST_TIME_ENTITY"]
275+
276+
# Case 11: a re: pattern that did match is the user's entity, and stays so across refreshes
277+
configure({})
278+
my_predbat.args_from_apps_yaml = {"discharge_start_time": ["re:(time\\.matched_.*)"]}
279+
my_predbat.args["discharge_start_time"] = ["time.matched_x"]
280+
inv = Inverter(my_predbat, 0, quiet=True)
281+
inv.refresh_config()
282+
failed |= check("matched re:", "discharge_start_time", "time.matched_x")
283+
finally:
284+
my_predbat.args.clear()
285+
my_predbat.args.update(saved_args)
286+
if had_raw:
287+
my_predbat.args_from_apps_yaml = saved_raw
288+
elif hasattr(my_predbat, "args_from_apps_yaml"):
289+
del my_predbat.args_from_apps_yaml
290+
items = getattr(my_predbat.ha_interface, "dummy_items", None)
291+
if items is not None:
292+
items.clear()
293+
items.update(saved_items)
294+
for name, saved_def in saved_defs.items():
295+
if saved_def is None:
296+
INVERTER_DEF.pop(name, None)
297+
else:
298+
INVERTER_DEF[name] = saved_def
299+
300+
return failed
301+
302+
121303
def test_support_feedin_first_is_opt_in():
122304
"""
123305
support_feedin_first says the inverter's Freeze Export really is a "Feed-in First" mode (load,
@@ -4950,6 +5132,7 @@ def run_inverter_tests(my_predbat_dummy):
49505132
failed |= test_foxess_support_discharge_freeze_matches_foxcloud()
49515133
failed |= test_support_feedin_first_is_opt_in()
49525134
failed |= test_has_solis_energy_control_is_opt_in()
5135+
failed |= test_custom_type_respects_configured_time_entity(my_predbat)
49535136
ha = my_predbat.ha_interface
49545137

49555138
time_now = my_predbat.now_utc.strftime("%Y-%m-%dT%H:%M:%S%z")

0 commit comments

Comments
 (0)