Skip to content
Merged
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
7 changes: 6 additions & 1 deletion apps/predbat/component_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,12 @@ async def start(self):
if not self.api_started:
self.api_started = True
self.log(f"{self.__class__.__name__}: Started")
first = False # Clear first flag once started
# Clear first flag once started. This must happen even when a
# component sets api_started itself from a background task (e.g.
# the gateway's MQTT loop): otherwise first stays True forever and
# start() keeps re-running the first=True startup path on backoff,
# never reaching the steady-state housekeeping run().
first = False
else:
self.count_errors += 1
self.non_fatal_error_occurred()
Expand Down
2 changes: 1 addition & 1 deletion apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
import pytz
import asyncio

THIS_VERSION = "v8.41.2"
THIS_VERSION = "v8.41.3"

from download import predbat_update_move, predbat_update_download, check_install, DEFAULT_PREDBAT_REPOSITORY
from const import MINUTE_WATT
Expand Down
53 changes: 53 additions & 0 deletions apps/predbat/tests/test_component_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,58 @@ async def run_test():
return asyncio.run(run_test())


def test_component_base_first_cleared_when_run_presets_api_started(my_predbat):
"""Regression: a component that sets api_started itself must still leave the startup path.

The gateway's MQTT background loop sets self.api_started = True before run(first=True)
returns. If start() only clears the `first` flag inside `if not self.api_started`, the
flag stays True forever and start() keeps re-running the first=True startup path on
backoff, never reaching the steady-state (first=False) housekeeping that publishes the
plan. This verifies start() transitions to first=False regardless of who set api_started.
"""
print("\n*** Test: ComponentBase clears first when run() pre-sets api_started ***")

class PresetComponent(ComponentBase):
def __init__(self, base):
self.first_flags = []
super().__init__(base)

def initialize(self, **kwargs):
pass

async def run(self, seconds, first):
self.first_flags.append(first)
# Mimic a background task marking the component started before run() returns.
self.api_started = True
return True

async def run_test():
with patch("asyncio.sleep", side_effect=fast_sleep):
base = MockBase()
component = PresetComponent(base)

task = asyncio.create_task(component.start())

# Wait long enough (sped up 100x by fast_sleep → ~2s real) for the component
# loop to advance past simulated seconds=60 so a steady-state run can occur.
await asyncio.sleep(200)
Comment on lines +336 to +338

assert component.api_started, "Component should be started"

await component.stop()
await task

assert component.first_flags, "run() should have been called"
assert component.first_flags[0] is True, "First run should be first=True"
assert any(f is False for f in component.first_flags), "Component must reach steady-state housekeeping (first=False); got first flags: {}".format(component.first_flags)
assert component.first_flags.count(True) == 1, "Startup run() should happen exactly once, got {}".format(component.first_flags)

print(f"PASS: first cleared despite self-set api_started (flags={component.first_flags})")
return False # False = test passed

return asyncio.run(run_test())


def test_component_base_all(my_predbat):
"""Run all component_base tests"""
tests = [
Expand All @@ -310,6 +362,7 @@ def test_component_base_all(my_predbat):
("normal_operation", test_component_base_normal_operation_after_start, "Component runs every 60s after start"),
("exception_handling", test_component_base_exception_handling, "Component handles exceptions with backoff"),
("run_timeout", test_component_base_run_timeout, "Hung run() triggers timeout, stack trace, and error count"),
("first_cleared_preset", test_component_base_first_cleared_when_run_presets_api_started, "first flag clears even when run() pre-sets api_started"),
]

failed = []
Expand Down
Loading