Skip to content

Commit c660fa6

Browse files
fix(inverter): don't read Predbat's own dummy entity back as user config
is_real_entity_configured() accepted any value with a domain in it, but create_entity() writes its own 'sensor.{prefix}_{type}_{id}_{field}' id back into self.base.args - which is process-lifetime state, never re-read from apps.yaml. Inverters are rebuilt from scratch on the balance path (execute.py) as well as at startup, so from the second construction the dummy is already sitting in args and read back as though the user had configured it. Recreation is then skipped, leaving created_attributes unpopulated on the new object and the state unrestored if it has gone away; after an inverter type change, writes stay pointed at the stale sensor named for the old type. Add is_own_dummy_entity() and exclude those ids. The type sits in the middle of the id and is matched loosely, so a dummy left from a previous type is still recognised as ours rather than preserved as user intent. Covered by two new cases in test_custom_type_respects_configured_time_entity (rebuild, and rebuild across a type change); both fail with the new guard stubbed out, so they exercise the fix rather than passing vacuously. Patch from the triage bot on #4745, which could not push it itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent ddabc2f commit c660fa6

2 files changed

Lines changed: 71 additions & 6 deletions

File tree

‎apps/predbat/inverter.py‎

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -301,19 +301,42 @@ def create_missing_arg(self, arg, default):
301301
# then looked up as one.
302302
self.base.args[arg] = self.base.args[arg] + [None] * (self.id + 1 - len(self.base.args[arg]))
303303

304+
def is_own_dummy_entity(self, value, entity_name):
305+
"""
306+
Whether value is one of Predbat's own dummy entity ids for this inverter and field, i.e.
307+
something an earlier construction of this inverter left behind in args via create_entity()
308+
rather than anything a user or a component's automatic_config() deliberately pointed at.
309+
310+
create_entity() builds 'sensor.{prefix}_{inverter_type}_{id}_{entity_name}' and writes it
311+
back into self.base.args, which lives for the whole process and is never re-read from
312+
apps.yaml. Inverters are rebuilt from scratch on the balance path (execute.py) as well as at
313+
startup, so from the second construction onwards args already holds that dummy - which has a
314+
domain and would otherwise read back as a genuinely configured entity, skipping the
315+
recreation that re-registers created_attributes and restores the state if it has gone away.
316+
The inverter_type sits in the middle and is matched loosely, so a dummy left over from a
317+
previous type is still recognised as ours rather than stranding writes on the stale sensor.
318+
"""
319+
if not isinstance(value, str):
320+
return False
321+
return value.startswith("sensor.{}_".format(self.base.prefix)) and value.endswith("_{}_{}".format(self.id, entity_name))
322+
304323
def is_real_entity_configured(self, arg):
305324
"""
306325
True if arg already resolves to a real HA entity id for this inverter (contains a domain,
307-
e.g. 'time.foo'), as opposed to being unset or a bare placeholder value such as '23:59:00'
308-
or '00:00:00' with no domain. Used to tell "the user configured this themselves" apart from
309-
"nothing is there yet" before create_missing_arg's own value-vs-list check would conflate the
310-
two - a config item that's present but not a real entity is exactly the case a dummy entity
311-
still needs to be created for.
326+
e.g. 'time.foo'), as opposed to being unset, a bare placeholder value such as '23:59:00' or
327+
'00:00:00' with no domain, or a dummy this inverter created for itself on an earlier
328+
construction. Used to tell "this was configured deliberately" apart from "nothing real is
329+
there yet" before create_missing_arg's own value-vs-list check would conflate the two - a
330+
config item that's present but not a real entity is exactly the case a dummy entity still
331+
needs to be created for.
312332
"""
313333
values = self.base.args.get(arg)
314334
if not isinstance(values, list) or self.id >= len(values):
315335
return False
316-
return is_entity_id(values[self.id])
336+
value = values[self.id]
337+
if self.is_own_dummy_entity(value, arg):
338+
return False
339+
return is_entity_id(value)
317340

318341
def __init__(self, base, id=0, quiet=False):
319342
"""

‎apps/predbat/tests/test_inverter.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ def test_custom_type_respects_configured_time_entity(my_predbat):
5353
writes go via separate hour/minute entities - so the dummy must still be created.
5454
- SF style (has_time_window False): ships a bare placeholder string, not a real entity - the
5555
dummy must still replace it, since there is nothing real to preserve.
56+
57+
And a dummy Predbat wrote into args itself must not read back as user configuration on a later
58+
construction, since args is process-lifetime state and inverters are rebuilt from scratch on the
59+
balance path: the dummy has a domain, so a plain "has a dot" test would skip recreating it.
5660
"""
5761
failed = False
5862
print("Test: test_custom_type_respects_configured_time_entity")
@@ -110,13 +114,51 @@ def test_custom_type_respects_configured_time_entity(my_predbat):
110114
if not (isinstance(got, str) and got.startswith("sensor.")):
111115
print(f"ERROR: test_custom_type_respects_configured_time_entity: SF-style bare placeholder should still be replaced by a dummy sensor, got {got}")
112116
failed = True
117+
118+
# Case 4: rebuild. self.base.args lives for the whole process and is never re-read from
119+
# apps.yaml, and inverters are rebuilt from scratch on the balance path (execute.py), so by
120+
# the second construction args already holds the dummy written by the first. That dummy has
121+
# a domain, so a plain "has a dot" test reads it back as user-configured and skips creation
122+
# - leaving created_attributes unpopulated on the new object and the state unrestored if it
123+
# has gone away.
124+
my_predbat.args["inverter_type"] = ["TEST_CUSTOM_TIME_ENTITY_REBUILD"]
125+
my_predbat.args["inverter"] = {
126+
"charge_time_format": "S",
127+
"has_time_window": True,
128+
"has_charge_enable_time": True,
129+
"has_discharge_enable_time": True,
130+
}
131+
my_predbat.args.pop("discharge_start_time", None)
132+
133+
Inverter(my_predbat, 0, quiet=True)
134+
dummy_id = my_predbat.args["discharge_start_time"][0]
135+
rebuilt = Inverter(my_predbat, 0, quiet=True)
136+
137+
if my_predbat.args["discharge_start_time"][0] != dummy_id:
138+
print(f"ERROR: test_custom_type_respects_configured_time_entity: rebuild should keep the same dummy id {dummy_id}, got {my_predbat.args['discharge_start_time'][0]}")
139+
failed = True
140+
if dummy_id not in rebuilt.created_attributes:
141+
print(f"ERROR: test_custom_type_respects_configured_time_entity: rebuild should re-register {dummy_id} in created_attributes, got {sorted(rebuilt.created_attributes)}")
142+
failed = True
143+
144+
# Case 5: the inverter type changes between constructions (discovery or an apps.yaml edit).
145+
# The dummy id embeds the type, so the one left in args names the old type - it must be
146+
# recognised as Predbat's own and replaced, not preserved as though the user had chosen it.
147+
my_predbat.args["inverter_type"] = ["TEST_CUSTOM_TIME_ENTITY_REBUILD2"]
148+
Inverter(my_predbat, 0, quiet=True)
149+
got = my_predbat.args["discharge_start_time"][0]
150+
if got != "sensor.{}_TEST_CUSTOM_TIME_ENTITY_REBUILD2_0_discharge_start_time".format(my_predbat.prefix):
151+
print(f"ERROR: test_custom_type_respects_configured_time_entity: a stale dummy from a previous inverter type should be replaced, got {got}")
152+
failed = True
113153
finally:
114154
my_predbat.args = saved_args
115155
if saved_def is None:
116156
INVERTER_DEF.pop("TEST_CUSTOM_TIME_ENTITY", None)
117157
else:
118158
INVERTER_DEF["TEST_CUSTOM_TIME_ENTITY"] = saved_def
119159
INVERTER_DEF.pop("TEST_CUSTOM_TIME_ENTITY_SF", None)
160+
INVERTER_DEF.pop("TEST_CUSTOM_TIME_ENTITY_REBUILD", None)
161+
INVERTER_DEF.pop("TEST_CUSTOM_TIME_ENTITY_REBUILD2", None)
120162

121163
return failed
122164

0 commit comments

Comments
 (0)