@@ -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+
121303def 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