From 7f4c03a06a592b53f7067637a239cfb02edcbdc0 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Thu, 30 Jul 2026 21:11:02 +0100 Subject: [PATCH 1/2] feat(config): retry validate_config() after a failure, self-healing (#4379) validate_config() only ran at startup and on config change, never periodically - so a transient race (a slow-starting integration's sensor not populated yet at that exact moment) left a stale "config invalid" status sitting until the next manual restart, even though apps.yaml was actually fine moments later. Adds validate_config_schedule_retry()/validate_config_check_retry(): after any validate_config() call that finds errors, arms a retry sequence (default 2 retries, 1 minute apart, both configurable via apps.yaml). Retries only ever start following an actual failure - a clean validation never triggers one. A retry that comes back clean clears the sequence immediately; one that's still failing decrements the counter and reschedules, giving up quietly once retries are exhausted. Checked once per tick from the existing 15-second update_time_loop() rather than registering a new scheduler primitive - this AppDaemon-shim codebase only has run_every(), no one-shot delayed-callback API to build on. New apps.yaml-only settings (APPS_SCHEMA, not HA-exposed): validate_config_retries (default 2, 0 disables), validate_config_retry_minutes (default 1). --- apps/predbat/config.py | 2 + apps/predbat/predbat.py | 50 ++++++++++- apps/predbat/tests/test_validate_config.py | 97 ++++++++++++++++++++++ apps/predbat/unit_test.py | 3 +- docs/apps-yaml.md | 17 ++++ 5 files changed, 165 insertions(+), 4 deletions(-) diff --git a/apps/predbat/config.py b/apps/predbat/config.py index ef248246c..d60019c42 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -2195,6 +2195,8 @@ "ge_cloud_automatic_split_pv": {"type": "boolean"}, "num_inverters": {"type": "integer", "zero": False}, "balance_inverters_seconds": {"type": "integer", "zero": True}, + "validate_config_retries": {"type": "integer", "zero": True}, + "validate_config_retry_minutes": {"type": "integer", "zero": True}, "givtcp_rest": {"type": "string_list", "entries": "num_inverters"}, "charge_rate": {"type": "sensor_list", "sensor_type": "float", "modify": True, "entries": "num_inverters"}, "discharge_rate": {"type": "sensor_list", "sensor_type": "float", "modify": True, "entries": "num_inverters"}, diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index f523b9812..23758d240 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -299,6 +299,8 @@ def reset(self): self.db_manager = None self.plan_debug = False self.arg_errors = {} + self.validate_config_retries_remaining = 0 + self.validate_config_next_retry_time = None self.ha_interface = None self.num_cars = 0 self.fatal_error = False @@ -1501,6 +1503,47 @@ def validate_config(self): return errors + def validate_config_schedule_retry(self, errors): + """ + Called immediately after validate_config() with its error count. If validation failed, + (re-)arms a retry sequence so a self-healed condition (e.g. a slow-starting integration's + sensor not populated yet) clears its own error status without needing a manual restart - + see #4379. A clean validation cancels any retry sequence already in progress. + """ + if errors: + retries = self.get_arg("validate_config_retries", 2) + if retries > 0: + self.validate_config_retries_remaining = retries + retry_minutes = self.get_arg("validate_config_retry_minutes", 1) + self.validate_config_next_retry_time = self.now_utc + timedelta(minutes=retry_minutes) + else: + self.validate_config_retries_remaining = 0 + self.validate_config_next_retry_time = None + + def validate_config_check_retry(self): + """ + Called every 15 seconds from update_time_loop(). Re-runs validate_config() if a retry is + due, only while a retry sequence is armed (i.e. only following an actual validation + failure - see #4379) - a no-op the rest of the time. + """ + if self.validate_config_retries_remaining <= 0: + return + if self.validate_config_next_retry_time is None or self.now_utc < self.validate_config_next_retry_time: + return + + self.validate_config_retries_remaining -= 1 + errors = self.validate_config() + if errors == 0: + self.log("Info: Config validation retry succeeded, previous errors have now cleared") + self.validate_config_retries_remaining = 0 + self.validate_config_next_retry_time = None + elif self.validate_config_retries_remaining <= 0: + self.log("Warn: Config validation still failing after all retries, giving up until the next restart or config change") + self.validate_config_next_retry_time = None + else: + retry_minutes = self.get_arg("validate_config_retry_minutes", 1) + self.validate_config_next_retry_time = self.now_utc + timedelta(minutes=retry_minutes) + def is_running(self): """ Check if the app is running @@ -1605,7 +1648,7 @@ def initialize(self): self.load_user_config(quiet=False, register=True) self.auto_config(final=True) - self.validate_config() + self.validate_config_schedule_retry(self.validate_config()) # Restore the last saved plan so it is immediately active before the first calculation self.load_plan() @@ -1686,13 +1729,14 @@ def update_time_loop(self, cb_args): raise Exception("HA interface not active") self.check_entity_refresh() + self.validate_config_check_retry() if self.update_pending and not self.prediction_started: # Full update required self.update_pending = False self.prediction_started = True try: self.load_user_config() - self.validate_config() + self.validate_config_schedule_retry(self.validate_config()) self.update_pred(scheduled=False) self.create_entity_list() except Exception as e: @@ -1769,7 +1813,7 @@ def run_time_loop(self, cb_args): self.update_pending = False self.ha_interface.update_states() self.load_user_config() - self.validate_config() + self.validate_config_schedule_retry(self.validate_config()) config_changed = True # Run the prediction diff --git a/apps/predbat/tests/test_validate_config.py b/apps/predbat/tests/test_validate_config.py index fa715d7ab..3d1a64e9c 100644 --- a/apps/predbat/tests/test_validate_config.py +++ b/apps/predbat/tests/test_validate_config.py @@ -364,3 +364,100 @@ def test_validate_config(my_predbat): print("**** test_validate_config PASSED ****") return False + + +def test_validate_config_retry(my_predbat): + """ + Tests validate_config_schedule_retry()/validate_config_check_retry() - the retry mechanism + added for #4379 so a validation failure that self-heals on its own (e.g. a slow-starting + integration's sensor not populated yet) clears its own error status without needing a + manual restart, instead of sitting stale until the next restart/config change. + + Stubs validate_config() itself with a controlled sequence of results rather than relying on + real apps.yaml validation reaching a clean state - the test fixture's own baseline args + already carry pre-existing validation warnings unrelated to this feature, so "clean" can't + be reached just by fixing one deliberately-broken field. This isolates the retry-scheduling + logic under test from that ambient noise. + """ + from datetime import timedelta + + print("**** test_validate_config_retry ****") + + saved_args = my_predbat.args.copy() + saved_retries_remaining = my_predbat.validate_config_retries_remaining + saved_next_retry_time = my_predbat.validate_config_next_retry_time + saved_now_utc = my_predbat.now_utc + saved_validate_config = my_predbat.validate_config + + def _should_not_be_called(): + raise AssertionError("validate_config() should not have been called here") + + try: + # A clean validation should never arm a retry sequence + my_predbat.validate_config_retries_remaining = 0 + my_predbat.validate_config_next_retry_time = None + my_predbat.validate_config_schedule_retry(0) + assert my_predbat.validate_config_retries_remaining == 0, "Clean validation should not arm a retry" + assert my_predbat.validate_config_next_retry_time is None + + # A failing validation arms the default (2 retries, 1 minute) + my_predbat.args.pop("validate_config_retries", None) + my_predbat.args.pop("validate_config_retry_minutes", None) + my_predbat.validate_config_schedule_retry(1) + assert my_predbat.validate_config_retries_remaining == 2, f"Expected 2 retries armed by default, got {my_predbat.validate_config_retries_remaining}" + assert my_predbat.validate_config_next_retry_time == my_predbat.now_utc + timedelta(minutes=1) + + # check_retry() is a no-op before the retry time is due - must not even call validate_config() + my_predbat.validate_config = _should_not_be_called + my_predbat.validate_config_check_retry() + assert my_predbat.validate_config_retries_remaining == 2, "Should not have retried before the due time" + + # Once due, a still-failing re-validation decrements the counter and reschedules + my_predbat.now_utc = saved_now_utc + timedelta(minutes=1) + my_predbat.validate_config = lambda: 1 # simulate validation still failing + my_predbat.validate_config_check_retry() + assert my_predbat.validate_config_retries_remaining == 1, f"Expected 1 retry remaining, got {my_predbat.validate_config_retries_remaining}" + assert my_predbat.validate_config_next_retry_time == my_predbat.now_utc + timedelta(minutes=1) + + # Exhausting the final retry while still failing stops the sequence cleanly + my_predbat.now_utc = my_predbat.now_utc + timedelta(minutes=1) + my_predbat.validate_config_check_retry() + assert my_predbat.validate_config_retries_remaining == 0, "Should give up after the last retry" + assert my_predbat.validate_config_next_retry_time is None + + # No further retries happen once the sequence has stopped, however much time passes + my_predbat.now_utc = my_predbat.now_utc + timedelta(minutes=10) + my_predbat.validate_config = _should_not_be_called + my_predbat.validate_config_check_retry() + assert my_predbat.validate_config_retries_remaining == 0 + + # A retry that succeeds clears the sequence immediately, not just decrements it + my_predbat.validate_config = lambda: 1 + my_predbat.validate_config_schedule_retry(1) + assert my_predbat.validate_config_retries_remaining == 2 + my_predbat.now_utc = my_predbat.now_utc + timedelta(minutes=1) + my_predbat.validate_config = lambda: 0 # simulate the underlying issue having self-healed + my_predbat.validate_config_check_retry() + assert my_predbat.validate_config_retries_remaining == 0, "A successful retry should clear the sequence, not just decrement it" + assert my_predbat.validate_config_next_retry_time is None + + # validate_config_retries: 0 disables the feature entirely + my_predbat.args["validate_config_retries"] = 0 + my_predbat.validate_config_schedule_retry(1) + assert my_predbat.validate_config_retries_remaining == 0, "validate_config_retries=0 should disable retries" + + # A custom retry count/interval is respected + my_predbat.args["validate_config_retries"] = 5 + my_predbat.args["validate_config_retry_minutes"] = 3 + my_predbat.validate_config_schedule_retry(1) + assert my_predbat.validate_config_retries_remaining == 5 + assert my_predbat.validate_config_next_retry_time == my_predbat.now_utc + timedelta(minutes=3) + + print("**** test_validate_config_retry PASSED ****") + return False + finally: + my_predbat.args = saved_args + my_predbat.validate_config = saved_validate_config + my_predbat.validate_config_retries_remaining = saved_retries_remaining + my_predbat.validate_config_next_retry_time = saved_next_retry_time + my_predbat.now_utc = saved_now_utc diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index 2ae54518b..653b61853 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -155,7 +155,7 @@ from tests.test_octopus_download_rates import test_octopus_download_rates_wrapper from tests.test_integer_config import test_integer_config_entities, test_expose_config_preserves_integer, test_config_item_range_clamp, test_config_item_step_min_max_types_consistent from tests.test_predbat_metrics_data_age import test_data_age_metrics_round_trip -from tests.test_validate_config import test_validate_config +from tests.test_validate_config import test_validate_config, test_validate_config_retry from tests.test_plan_json_rate_adjust import run_test_plan_json_rate_adjust from tests.test_plan_why_reason import run_test_plan_why_reason from tests.test_rate_replicate_missing_slots import test_rate_replicate @@ -392,6 +392,7 @@ def main(): ("teslemetry", test_teslemetry, "Teslemetry Tesla Powerwall component tests (data path, control, tariff)", False), ("integer_config", test_integer_config_entities, "Integer config entities tests", False), ("validate_config", test_validate_config, "APPS_SCHEMA validator tests (string types, sensor boolean states)", False), + ("validate_config_retry", test_validate_config_retry, "Config validation retry-after-failure tests (#4379)", False), ("expose_config_integer", test_expose_config_preserves_integer, "Expose config preserves integer tests", False), ("config_item_range_clamp", test_config_item_range_clamp, "Config item min/max range clamp tests", False), ("config_item_step_min_max_types", test_config_item_step_min_max_types_consistent, "Config item step/min/max type consistency tests", False), diff --git a/docs/apps-yaml.md b/docs/apps-yaml.md index aab5c24c2..8223400c0 100644 --- a/docs/apps-yaml.md +++ b/docs/apps-yaml.md @@ -1842,6 +1842,23 @@ but there is one configuration item in `apps.yaml`: Defines how often to run the inverter balancing, 30 seconds is recommended if your machine is fast enough, but the default is 60 seconds. +## Config validation retries + +`apps.yaml` is validated at startup and whenever its configuration changes. If a sensor you've mapped isn't populated yet at that exact moment +(e.g. a slower-starting integration during a Home Assistant restart), Predbat reports a configuration error - correctly, at the time. If that +sensor comes good on its own a few seconds later, Predbat automatically retries validation a few times, so a self-healed condition clears its +own error status rather than needing a manual restart. + +```yaml + validate_config_retries: 2 + validate_config_retry_minutes: 1 +``` + +**validate_config_retries** sets how many times to retry after an initial validation failure - the default is 2. **validate_config_retry_minutes** +sets how long to wait between each retry - the default is 1 minute. Retries only happen after a validation failure; a clean `apps.yaml` is never +re-checked early. Set **validate_config_retries** to 0 to disable retries entirely and revert to the previous behaviour (a failed validation +persists until the next restart or config change). + ## Workarounds There are a number of different configuration items in `apps.yaml` that can be used to tweak the way Predbat operates and workaround From 94dda07085588a6e8da06542eab06311feb3c81d Mon Sep 17 00:00:00 2001 From: Rik Allen <48563392+chalfontchubby@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:21:15 +0100 Subject: [PATCH 2/2] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/predbat/predbat.py | 7 +++++-- apps/predbat/tests/test_validate_config.py | 6 ++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 23758d240..792d393b1 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -1511,11 +1511,14 @@ def validate_config_schedule_retry(self, errors): see #4379. A clean validation cancels any retry sequence already in progress. """ if errors: - retries = self.get_arg("validate_config_retries", 2) + retries = int(self.get_arg("validate_config_retries", 2)) if retries > 0: self.validate_config_retries_remaining = retries - retry_minutes = self.get_arg("validate_config_retry_minutes", 1) + retry_minutes = max(0, int(self.get_arg("validate_config_retry_minutes", 1))) self.validate_config_next_retry_time = self.now_utc + timedelta(minutes=retry_minutes) + else: + self.validate_config_retries_remaining = 0 + self.validate_config_next_retry_time = None else: self.validate_config_retries_remaining = 0 self.validate_config_next_retry_time = None diff --git a/apps/predbat/tests/test_validate_config.py b/apps/predbat/tests/test_validate_config.py index 3d1a64e9c..18aae04c6 100644 --- a/apps/predbat/tests/test_validate_config.py +++ b/apps/predbat/tests/test_validate_config.py @@ -441,11 +441,13 @@ def _should_not_be_called(): assert my_predbat.validate_config_retries_remaining == 0, "A successful retry should clear the sequence, not just decrement it" assert my_predbat.validate_config_next_retry_time is None - # validate_config_retries: 0 disables the feature entirely + # validate_config_retries: 0 disables the feature entirely (and cancels any armed retry sequence) + my_predbat.validate_config_retries_remaining = 2 + my_predbat.validate_config_next_retry_time = my_predbat.now_utc + timedelta(minutes=1) my_predbat.args["validate_config_retries"] = 0 my_predbat.validate_config_schedule_retry(1) assert my_predbat.validate_config_retries_remaining == 0, "validate_config_retries=0 should disable retries" - + assert my_predbat.validate_config_next_retry_time is None # A custom retry count/interval is respected my_predbat.args["validate_config_retries"] = 5 my_predbat.args["validate_config_retry_minutes"] = 3