From 6e30eab454149a2296af0f2a80f3d176842e6fcd Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Tue, 25 Aug 2026 18:57:05 +0100 Subject: [PATCH 1/6] fix(inverter): don't overwrite a real time entity with a dummy sensor For any charge_time_format other than "HH:MM:SS", Predbat unconditionally creates and assigns a self-owned sensor.predbat___ for charge_start_time/charge_end_time/discharge_start_time/discharge_end_time, discarding whatever the user configured - real entity or not. That's correct for the two existing cases: "H M" format (GS/GS_fb00) never expects these to be user-configured at all, since the real writes go via separate hour/minute entities (confirmed against templates/ginlong_solis.yaml); "S" format with no time window (SF/SE/etc) ships a bare placeholder string, not a real entity (confirmed against templates/sofar.yaml) - has_time_window turns out to be read nowhere else, so the placeholder is genuinely inert either way. A custom inverter definition can combine a non-HH:MM:SS format with a real time window and a real, directly user-configured entity - the combination the existing check never anticipated. For those the write path already does a plain write straight to whatever discharge_start_time resolves to; the dummy creation is the only thing standing in the way of it working (#4738). is_real_entity_configured() distinguishes "the user pointed this at a real HA entity" (has a domain, e.g. time.foo) from "this is unset or a bare placeholder" - preserving both existing cases exactly while respecting a genuinely configured one. Co-Authored-By: Claude Opus 5 --- apps/predbat/inverter.py | 24 ++++++++- apps/predbat/tests/test_inverter.py | 81 +++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/apps/predbat/inverter.py b/apps/predbat/inverter.py index ac785da778..bc085fc36e 100644 --- a/apps/predbat/inverter.py +++ b/apps/predbat/inverter.py @@ -137,6 +137,21 @@ def create_missing_arg(self, arg, default): if (arg not in self.base.args) or (not isinstance(self.base.args[arg], list)): self.base.args[arg] = [default, default, default, default] + def is_real_entity_configured(self, arg): + """ + True if arg already resolves to a real HA entity id for this inverter (contains a domain, + e.g. 'time.foo'), as opposed to being unset or a bare placeholder value such as '23:59:00' + or '00:00:00' with no domain. Used to tell "the user configured this themselves" apart from + "nothing is there yet" before create_missing_arg's own value-vs-list check would conflate the + two - a config item that's present but not a real entity is exactly the case a dummy entity + still needs to be created for. + """ + values = self.base.args.get(arg) + if not isinstance(values, list) or self.id >= len(values): + return False + value = values[self.id] + return isinstance(value, str) and "." in value + def __init__(self, base, id=0, quiet=False, rest_postCommand=None, rest_getData=None): """ Inverter class @@ -565,11 +580,18 @@ def __init__(self, base, id=0, quiet=False, rest_postCommand=None, rest_getData= self.base.args["inverter_mode"][id] = self.create_entity("inverter_mode", "Eco") if self.inv_charge_time_format != "HH:MM:SS": + # Some formats (H M) decompose the write into separate hour/minute entities and never + # expect the user to set this directly; others (no time window at all) ship a bare + # placeholder string. Either way Predbat needs somewhere to read/write for its own window + # bookkeeping. But if the user has genuinely pointed this at a real entity - a custom + # inverter definition using a non-HH:MM:SS format together with a real time window, e.g. + # #4738 - that's the one to use, not a self-created dummy that silently discards it. for x in ["charge", "discharge"]: for y in ["start", "end"]: entity_name = f"{x}_{y}_time" self.create_missing_arg(entity_name, "23:59:00") - self.base.args[entity_name][id] = self.create_entity(entity_name, "23:59:00") + if not self.is_real_entity_configured(entity_name): + self.base.args[entity_name][id] = self.create_entity(entity_name, "23:59:00") # Create dummy idle time entities if not self.inv_has_idle_time: diff --git a/apps/predbat/tests/test_inverter.py b/apps/predbat/tests/test_inverter.py index 4739a68c64..cd14bc3ff1 100644 --- a/apps/predbat/tests/test_inverter.py +++ b/apps/predbat/tests/test_inverter.py @@ -40,6 +40,86 @@ def test_foxess_support_discharge_freeze_matches_foxcloud(): return failed +def test_custom_type_respects_configured_time_entity(my_predbat): + """ + Regression test for issue #4738: a custom inverter definition using a non-HH:MM:SS + charge_time_format together with a real time window (has_time_window/has_discharge_enable_time + True) must not have its genuinely user-configured discharge_start_time/discharge_end_time/ + charge_start_time/charge_end_time silently replaced by a self-created dummy sensor. + + Two existing legitimate cases must be unaffected by the fix: + - GS_fb00 style (H M format): discharge_start_time is never user-configured at all - the real + writes go via separate hour/minute entities - so the dummy must still be created. + - SF style (has_time_window False): ships a bare placeholder string, not a real entity - the + dummy must still replace it, since there is nothing real to preserve. + """ + failed = False + print("Test: test_custom_type_respects_configured_time_entity") + + saved_args = copy.deepcopy(my_predbat.args) + saved_def = copy.deepcopy(INVERTER_DEF.get("TEST_CUSTOM_TIME_ENTITY")) + + try: + # Case 1: real entity configured - must be kept, not overwritten + my_predbat.args["inverter_type"] = ["TEST_CUSTOM_TIME_ENTITY"] + my_predbat.args["inverter"] = { + "charge_time_format": "S", + "has_time_window": True, + "has_charge_enable_time": True, + "has_discharge_enable_time": True, + } + my_predbat.args["discharge_start_time"] = ["time.real_discharge_start"] + my_predbat.args["discharge_end_time"] = ["time.real_discharge_end"] + my_predbat.args["charge_start_time"] = ["time.real_charge_start"] + my_predbat.args["charge_end_time"] = ["time.real_charge_end"] + my_predbat.args["givtcp_rest"] = None + + Inverter(my_predbat, 0, quiet=True) + + for arg, expected in [ + ("discharge_start_time", "time.real_discharge_start"), + ("discharge_end_time", "time.real_discharge_end"), + ("charge_start_time", "time.real_charge_start"), + ("charge_end_time", "time.real_charge_end"), + ]: + got = my_predbat.args[arg][0] + if got != expected: + print(f"ERROR: test_custom_type_respects_configured_time_entity: {arg} should stay {expected}, got {got} (overwritten by a dummy entity)") + failed = True + + # Case 2: nothing configured for this arg - dummy creation must still kick in + del my_predbat.args["discharge_start_time"] + Inverter(my_predbat, 0, quiet=True) + got = my_predbat.args["discharge_start_time"][0] + if not (isinstance(got, str) and got.startswith("sensor.")): + print(f"ERROR: test_custom_type_respects_configured_time_entity: unconfigured discharge_start_time should get a dummy sensor, got {got}") + failed = True + + # Case 3: SF-style bare placeholder (no domain) - must still be replaced by a dummy, not kept + my_predbat.args["inverter_type"] = ["TEST_CUSTOM_TIME_ENTITY_SF"] + my_predbat.args["inverter"] = { + "charge_time_format": "S", + "has_time_window": False, + "has_charge_enable_time": False, + "has_discharge_enable_time": False, + } + my_predbat.args["discharge_start_time"] = ["00:00:00"] + Inverter(my_predbat, 0, quiet=True) + got = my_predbat.args["discharge_start_time"][0] + if not (isinstance(got, str) and got.startswith("sensor.")): + print(f"ERROR: test_custom_type_respects_configured_time_entity: SF-style bare placeholder should still be replaced by a dummy sensor, got {got}") + failed = True + finally: + my_predbat.args = saved_args + if saved_def is None: + INVERTER_DEF.pop("TEST_CUSTOM_TIME_ENTITY", None) + else: + INVERTER_DEF["TEST_CUSTOM_TIME_ENTITY"] = saved_def + INVERTER_DEF.pop("TEST_CUSTOM_TIME_ENTITY_SF", None) + + return failed + + def test_support_feedin_first_is_opt_in(): """ support_feedin_first says the inverter's Freeze Export really is a "Feed-in First" mode (load, @@ -2686,6 +2766,7 @@ def run_inverter_tests(my_predbat_dummy): print("**** Running Inverter tests ****") failed |= test_foxess_support_discharge_freeze_matches_foxcloud() failed |= test_support_feedin_first_is_opt_in() + failed |= test_custom_type_respects_configured_time_entity(my_predbat) ha = my_predbat.ha_interface time_now = my_predbat.now_utc.strftime("%Y-%m-%dT%H:%M:%S%z") From 604e43a4437f7197846e194ef4d3d9a62ec588f9 Mon Sep 17 00:00:00 2001 From: Rik Allen <48563392+chalfontchubby@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:42:44 +0100 Subject: [PATCH 2/6] Refactor value check to use is_entity_id function Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/predbat/inverter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/predbat/inverter.py b/apps/predbat/inverter.py index 224b94663a..1d41600bb5 100644 --- a/apps/predbat/inverter.py +++ b/apps/predbat/inverter.py @@ -314,7 +314,7 @@ def is_real_entity_configured(self, arg): if not isinstance(values, list) or self.id >= len(values): return False value = values[self.id] - return isinstance(value, str) and "." in value +return is_entity_id(values[self.id]) def __init__(self, base, id=0, quiet=False): """ From ddabc2fc3f4d3cb2de667470d38f6cc632218695 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Sat, 12 Sep 2026 18:31:08 +0100 Subject: [PATCH 3/6] fix(inverter): restore indentation lost applying a Copilot autofix 604e43a4 took Copilot's suggestion to reuse is_entity_id() in is_real_entity_configured(), but the applied patch put the return at column 0, dedenting it out of the method and making the whole file unparseable - black and ruff both failed on the syntax error rather than on anything stylistic. Restore the indentation and fold the now-unused `value` local into the return, which ruff's F841 would otherwise flag. The refactor itself was correct: is_entity_id() is exactly the isinstance/"." test it replaced. Co-Authored-By: Claude Opus 5 --- apps/predbat/inverter.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/predbat/inverter.py b/apps/predbat/inverter.py index 1d41600bb5..603fc3eb44 100644 --- a/apps/predbat/inverter.py +++ b/apps/predbat/inverter.py @@ -313,8 +313,7 @@ def is_real_entity_configured(self, arg): values = self.base.args.get(arg) if not isinstance(values, list) or self.id >= len(values): return False - value = values[self.id] -return is_entity_id(values[self.id]) + return is_entity_id(values[self.id]) def __init__(self, base, id=0, quiet=False): """ From c660fa67b293734df711f95fdae83a3fc5543468 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Sat, 12 Sep 2026 18:55:48 +0100 Subject: [PATCH 4/6] 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 --- apps/predbat/inverter.py | 35 +++++++++++++++++++----- apps/predbat/tests/test_inverter.py | 42 +++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/apps/predbat/inverter.py b/apps/predbat/inverter.py index 603fc3eb44..8ccbcd532e 100644 --- a/apps/predbat/inverter.py +++ b/apps/predbat/inverter.py @@ -301,19 +301,42 @@ def create_missing_arg(self, arg, default): # then looked up as one. self.base.args[arg] = self.base.args[arg] + [None] * (self.id + 1 - len(self.base.args[arg])) + def is_own_dummy_entity(self, value, entity_name): + """ + Whether value is one of Predbat's own dummy entity ids for this inverter and field, i.e. + something an earlier construction of this inverter left behind in args via create_entity() + rather than anything a user or a component's automatic_config() deliberately pointed at. + + create_entity() builds 'sensor.{prefix}_{inverter_type}_{id}_{entity_name}' and writes it + back into self.base.args, which lives for the whole process and is 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 onwards args already holds that dummy - which has a + domain and would otherwise read back as a genuinely configured entity, skipping the + recreation that re-registers created_attributes and restores the state if it has gone away. + The inverter_type sits in the middle and is matched loosely, so a dummy left over from a + previous type is still recognised as ours rather than stranding writes on the stale sensor. + """ + if not isinstance(value, str): + return False + return value.startswith("sensor.{}_".format(self.base.prefix)) and value.endswith("_{}_{}".format(self.id, entity_name)) + def is_real_entity_configured(self, arg): """ True if arg already resolves to a real HA entity id for this inverter (contains a domain, - e.g. 'time.foo'), as opposed to being unset or a bare placeholder value such as '23:59:00' - or '00:00:00' with no domain. Used to tell "the user configured this themselves" apart from - "nothing is there yet" before create_missing_arg's own value-vs-list check would conflate the - two - a config item that's present but not a real entity is exactly the case a dummy entity - still needs to be created for. + e.g. 'time.foo'), as opposed to being unset, a bare placeholder value such as '23:59:00' or + '00:00:00' with no domain, or a dummy this inverter created for itself on an earlier + construction. Used to tell "this was configured deliberately" apart from "nothing real is + there yet" before create_missing_arg's own value-vs-list check would conflate the two - a + config item that's present but not a real entity is exactly the case a dummy entity still + needs to be created for. """ values = self.base.args.get(arg) if not isinstance(values, list) or self.id >= len(values): return False - return is_entity_id(values[self.id]) + value = values[self.id] + if self.is_own_dummy_entity(value, arg): + return False + return is_entity_id(value) def __init__(self, base, id=0, quiet=False): """ diff --git a/apps/predbat/tests/test_inverter.py b/apps/predbat/tests/test_inverter.py index 8dfb0cbc44..d50ab259b6 100644 --- a/apps/predbat/tests/test_inverter.py +++ b/apps/predbat/tests/test_inverter.py @@ -53,6 +53,10 @@ def test_custom_type_respects_configured_time_entity(my_predbat): writes go via separate hour/minute entities - so the dummy must still be created. - SF style (has_time_window False): ships a bare placeholder string, not a real entity - the dummy must still replace it, since there is nothing real to preserve. + + And a dummy Predbat wrote into args itself must not read back as user configuration on a later + construction, since args is process-lifetime state and inverters are rebuilt from scratch on the + balance path: the dummy has a domain, so a plain "has a dot" test would skip recreating it. """ failed = False print("Test: test_custom_type_respects_configured_time_entity") @@ -110,6 +114,42 @@ def test_custom_type_respects_configured_time_entity(my_predbat): if not (isinstance(got, str) and got.startswith("sensor.")): print(f"ERROR: test_custom_type_respects_configured_time_entity: SF-style bare placeholder should still be replaced by a dummy sensor, got {got}") failed = True + + # Case 4: rebuild. self.base.args lives for the whole process and is never re-read from + # apps.yaml, and inverters are rebuilt from scratch on the balance path (execute.py), so by + # the second construction args already holds the dummy written by the first. That dummy has + # a domain, so a plain "has a dot" test reads it back as user-configured and skips creation + # - leaving created_attributes unpopulated on the new object and the state unrestored if it + # has gone away. + my_predbat.args["inverter_type"] = ["TEST_CUSTOM_TIME_ENTITY_REBUILD"] + my_predbat.args["inverter"] = { + "charge_time_format": "S", + "has_time_window": True, + "has_charge_enable_time": True, + "has_discharge_enable_time": True, + } + my_predbat.args.pop("discharge_start_time", None) + + Inverter(my_predbat, 0, quiet=True) + dummy_id = my_predbat.args["discharge_start_time"][0] + rebuilt = Inverter(my_predbat, 0, quiet=True) + + if my_predbat.args["discharge_start_time"][0] != dummy_id: + 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]}") + failed = True + if dummy_id not in rebuilt.created_attributes: + print(f"ERROR: test_custom_type_respects_configured_time_entity: rebuild should re-register {dummy_id} in created_attributes, got {sorted(rebuilt.created_attributes)}") + failed = True + + # Case 5: the inverter type changes between constructions (discovery or an apps.yaml edit). + # The dummy id embeds the type, so the one left in args names the old type - it must be + # recognised as Predbat's own and replaced, not preserved as though the user had chosen it. + my_predbat.args["inverter_type"] = ["TEST_CUSTOM_TIME_ENTITY_REBUILD2"] + Inverter(my_predbat, 0, quiet=True) + got = my_predbat.args["discharge_start_time"][0] + if got != "sensor.{}_TEST_CUSTOM_TIME_ENTITY_REBUILD2_0_discharge_start_time".format(my_predbat.prefix): + print(f"ERROR: test_custom_type_respects_configured_time_entity: a stale dummy from a previous inverter type should be replaced, got {got}") + failed = True finally: my_predbat.args = saved_args if saved_def is None: @@ -117,6 +157,8 @@ def test_custom_type_respects_configured_time_entity(my_predbat): else: INVERTER_DEF["TEST_CUSTOM_TIME_ENTITY"] = saved_def INVERTER_DEF.pop("TEST_CUSTOM_TIME_ENTITY_SF", None) + INVERTER_DEF.pop("TEST_CUSTOM_TIME_ENTITY_REBUILD", None) + INVERTER_DEF.pop("TEST_CUSTOM_TIME_ENTITY_REBUILD2", None) return failed From 904253cd6c46675c2394ba6e83e00c5ef38043e7 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Sun, 13 Sep 2026 21:35:37 +0100 Subject: [PATCH 5/6] fix(inverter): identify Predbat's own dummies from a registry, not their id format Review feedback on #4745: matching the 'sensor.{prefix}_{type}_{id}_{field}' id shape to recognise a dummy is a pattern that isn't bullet proof, and the question being asked is really "did Predbat put this here itself". create_entity() now records each id it creates in a set on base, which outlives the per-object created_attributes and so survives the rebuilds (startup and the execute.py balance path) that caused the original bug. is_real_entity_configured() consults that registry instead of re-deriving the format. Not keyed off args_from_apps_yaml alone, as suggested: fox.py and gecloud.py set_arg() real select.* time entities they discovered in automatic_config(), which never reach that snapshot, so treating "absent from apps.yaml" as "not configured" would overwrite a genuinely auto-discovered entity with a dummy. New case 6 covers that. Co-Authored-By: Claude Opus 5 --- apps/predbat/inverter.py | 41 ++++++++++++++--------------- apps/predbat/tests/test_inverter.py | 15 +++++++++-- 2 files changed, 33 insertions(+), 23 deletions(-) diff --git a/apps/predbat/inverter.py b/apps/predbat/inverter.py index 8ccbcd532e..31198016a9 100644 --- a/apps/predbat/inverter.py +++ b/apps/predbat/inverter.py @@ -301,40 +301,33 @@ def create_missing_arg(self, arg, default): # then looked up as one. self.base.args[arg] = self.base.args[arg] + [None] * (self.id + 1 - len(self.base.args[arg])) - def is_own_dummy_entity(self, value, entity_name): - """ - Whether value is one of Predbat's own dummy entity ids for this inverter and field, i.e. - something an earlier construction of this inverter left behind in args via create_entity() - rather than anything a user or a component's automatic_config() deliberately pointed at. - - create_entity() builds 'sensor.{prefix}_{inverter_type}_{id}_{entity_name}' and writes it - back into self.base.args, which lives for the whole process and is 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 onwards args already holds that dummy - which has a - domain and would otherwise read back as a genuinely configured entity, skipping the - recreation that re-registers created_attributes and restores the state if it has gone away. - The inverter_type sits in the middle and is matched loosely, so a dummy left over from a - previous type is still recognised as ours rather than stranding writes on the stale sensor. - """ - if not isinstance(value, str): - return False - return value.startswith("sensor.{}_".format(self.base.prefix)) and value.endswith("_{}_{}".format(self.id, entity_name)) - def is_real_entity_configured(self, arg): """ True if arg already resolves to a real HA entity id for this inverter (contains a domain, e.g. 'time.foo'), as opposed to being unset, a bare placeholder value such as '23:59:00' or - '00:00:00' with no domain, or a dummy this inverter created for itself on an earlier + '00:00:00' with no domain, or a dummy Predbat created for itself on an earlier construction. Used to tell "this was configured deliberately" apart from "nothing real is there yet" before create_missing_arg's own value-vs-list check would conflate the two - a config item that's present but not a real entity is exactly the case a dummy entity still needs to be created for. + + "Deliberately" covers a component's automatic_config() as well as apps.yaml: fox.py and + gecloud.py both set_arg() real select.* time entities they discovered, which never appear + in args_from_apps_yaml, so presence in apps.yaml alone is not the question being asked. + + The dummies are recognised from the registry create_entity() records them in rather than by + re-deriving their id format here: self.base.args lives for the whole process and is never + re-read from apps.yaml, and inverters are rebuilt from scratch on the balance path + (execute.py) as well as at startup, so from the second construction onwards args already + holds the dummy - which has a domain and would otherwise read back as genuine + configuration, skipping the recreation that re-registers created_attributes and restores + the state if it has gone away (#4745 review). """ values = self.base.args.get(arg) if not isinstance(values, list) or self.id >= len(values): return False value = values[self.id] - if self.is_own_dummy_entity(value, arg): + if value in getattr(self.base, "predbat_created_entities", ()): return False return is_entity_id(value) @@ -1462,6 +1455,12 @@ def create_entity(self, entity_name, value, uom=None, device_class=None, icon=No attributes["icon"] = icon self.created_attributes[entity_id] = attributes + # Recorded on base, not self: created_attributes is reset per Inverter object, but args + # outlives every rebuild, so this is what lets a later construction tell its own dummy + # apart from real configuration (#4745 review). + if getattr(self.base, "predbat_created_entities", None) is None: + self.base.predbat_created_entities = set() + self.base.predbat_created_entities.add(entity_id) if self.base.get_state_wrapper(entity_id) is None: self.log("Inverter {} **** Creating dummy entity {} with value {} and attributes {}".format(self.id, entity_id, value, attributes)) diff --git a/apps/predbat/tests/test_inverter.py b/apps/predbat/tests/test_inverter.py index d50ab259b6..7e0d3618e8 100644 --- a/apps/predbat/tests/test_inverter.py +++ b/apps/predbat/tests/test_inverter.py @@ -142,14 +142,25 @@ def test_custom_type_respects_configured_time_entity(my_predbat): failed = True # Case 5: the inverter type changes between constructions (discovery or an apps.yaml edit). - # The dummy id embeds the type, so the one left in args names the old type - it must be - # recognised as Predbat's own and replaced, not preserved as though the user had chosen it. + # The dummy id embeds the type, so the one left in args names the old type - it is still + # one Predbat created, so it must be replaced with the current type's dummy rather than + # preserved as though the user had chosen it. my_predbat.args["inverter_type"] = ["TEST_CUSTOM_TIME_ENTITY_REBUILD2"] Inverter(my_predbat, 0, quiet=True) got = my_predbat.args["discharge_start_time"][0] if got != "sensor.{}_TEST_CUSTOM_TIME_ENTITY_REBUILD2_0_discharge_start_time".format(my_predbat.prefix): print(f"ERROR: test_custom_type_respects_configured_time_entity: a stale dummy from a previous inverter type should be replaced, got {got}") failed = True + + # Case 6: a real entity a component's automatic_config() discovered (fox.py/gecloud.py + # set_arg() select.* time entities) is deliberate configuration even though it never + # appears in apps.yaml, so it must be preserved exactly like a hand-written one. + my_predbat.args["discharge_start_time"] = ["select.predbat_fox_abc_battery_schedule_discharge_start_time"] + Inverter(my_predbat, 0, quiet=True) + got = my_predbat.args["discharge_start_time"][0] + if got != "select.predbat_fox_abc_battery_schedule_discharge_start_time": + print(f"ERROR: test_custom_type_respects_configured_time_entity: an auto-discovered component entity should be preserved, got {got}") + failed = True finally: my_predbat.args = saved_args if saved_def is None: From 10525e5bbd00b8d0f1f7b1413e2b8a41f8f84dea Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Sun, 20 Sep 2026 17:54:29 +0100 Subject: [PATCH 6/6] fix(tests): restore predbat_created_entities in test_custom_type_respects_configured_time_entity The test's finally block restored my_predbat.args but not my_predbat.predbat_created_entities, the registry create_entity() now writes dummy ids into (904253cd). Since tests share one fixture, the dummy ids created here leaked into every later test in the run. Co-Authored-By: Claude Sonnet 5 --- apps/predbat/tests/test_inverter.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/predbat/tests/test_inverter.py b/apps/predbat/tests/test_inverter.py index 7e0d3618e8..a2509690a0 100644 --- a/apps/predbat/tests/test_inverter.py +++ b/apps/predbat/tests/test_inverter.py @@ -63,6 +63,8 @@ def test_custom_type_respects_configured_time_entity(my_predbat): saved_args = copy.deepcopy(my_predbat.args) saved_def = copy.deepcopy(INVERTER_DEF.get("TEST_CUSTOM_TIME_ENTITY")) + had_created_entities = hasattr(my_predbat, "predbat_created_entities") + saved_created_entities = copy.deepcopy(my_predbat.predbat_created_entities) if had_created_entities else None try: # Case 1: real entity configured - must be kept, not overwritten @@ -163,6 +165,11 @@ def test_custom_type_respects_configured_time_entity(my_predbat): failed = True finally: my_predbat.args = saved_args + if had_created_entities: + my_predbat.predbat_created_entities = saved_created_entities + else: + if hasattr(my_predbat, "predbat_created_entities"): + del my_predbat.predbat_created_entities if saved_def is None: INVERTER_DEF.pop("TEST_CUSTOM_TIME_ENTITY", None) else: