Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/predbat/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
53 changes: 50 additions & 3 deletions apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1501,6 +1503,50 @@ 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 = int(self.get_arg("validate_config_retries", 2))
if retries > 0:
self.validate_config_retries_remaining = retries
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

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
Expand Down Expand Up @@ -1605,7 +1651,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()
Expand Down Expand Up @@ -1686,13 +1732,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:
Expand Down Expand Up @@ -1769,7 +1816,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
Expand Down
99 changes: 99 additions & 0 deletions apps/predbat/tests/test_validate_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,3 +364,102 @@ 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 (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
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
3 changes: 2 additions & 1 deletion apps/predbat/unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
17 changes: 17 additions & 0 deletions docs/apps-yaml.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading