diff --git a/apps/predbat/axle.py b/apps/predbat/axle.py index 242ad41c5..69a1bdcd3 100644 --- a/apps/predbat/axle.py +++ b/apps/predbat/axle.py @@ -414,7 +414,6 @@ async def _fetch_byok_event(self): self.cleanup_event_history() await self.save_event_history() - self.publish_axle_event() self.log(f"AxleAPI: Successfully fetched event data - {import_export} event from {start_time} to {end_time}" if start_time else "AxleAPI: No scheduled event") self.update_success_timestamp() @@ -452,7 +451,6 @@ async def _fetch_managed_price_curve(self): self._process_price_curve(data) self.cleanup_event_history() await self.save_event_history() - self.publish_axle_event() self.update_success_timestamp() self.log("AxleAPI: Price curve processed successfully (managed mode)") @@ -588,7 +586,12 @@ async def automatic_config(self): self.set_arg("axle_session", "binary_sensor." + self.prefix + "_axle_event") async def run(self, seconds, first): - """Main run loop — fetch when data is 10+ minutes old, publish state every minute.""" + """Main run loop — fetch when data is 10+ minutes old, always publish state afterwards. + + Publishing is unconditional so the on/off state keeps reflecting the current time even + when fetching is stuck failing (e.g. an expired API key) - otherwise a cached event that + has since ended would be reported as active forever. + """ if first: await self.load_event_history() if self.automatic: @@ -606,8 +609,8 @@ async def run(self, seconds, first): except Exception as e: self.log(f"Warn: AxleAPI: Exception during fetch: {e}") self.failures_total += 1 - elif (seconds % 60) == 0: # Every minute, update state to reflect if event is active or not - self.publish_axle_event() + + self.publish_axle_event() return True diff --git a/apps/predbat/tests/test_axle.py b/apps/predbat/tests/test_axle.py index c12ffe3c2..a1bd69b9d 100644 --- a/apps/predbat/tests/test_axle.py +++ b/apps/predbat/tests/test_axle.py @@ -139,6 +139,7 @@ def test_axle(my_predbat=None): ("datetime_parsing", _test_axle_datetime_parsing_variations, "Datetime parsing variations"), ("json_parse_error", _test_axle_json_parse_error, "JSON parse error handling"), ("run_method", _test_axle_run_method, "Run method execution"), + ("publish_after_fetch_failure", _test_axle_publishes_state_after_fetch_failure, "Republish state despite fetch failure"), ("history_loading", _test_axle_history_loading, "History loading from state"), ("history_cleanup", _test_axle_history_cleanup, "History cleanup old events"), ("fetch_sessions", _test_axle_fetch_sessions, "Fetch sessions from API"), @@ -243,6 +244,7 @@ def _test_axle_fetch_with_active_event(my_predbat=None): with patch("aiohttp.ClientSession", return_value=mock_session): run_async(axle.fetch_axle_event()) + axle.publish_axle_event() # run() always republishes after a fetch attempt # Verify current event data was parsed correctly (stored as strings) assert axle.current_event["start_time"] == "2025-12-20T14:00:00+0000" @@ -423,6 +425,7 @@ def _test_axle_fetch_with_notify_config(my_predbat=None): axle2.log_messages.clear() with patch("aiohttp.ClientSession", return_value=mock_session2): run_async(axle2.fetch_axle_event()) + axle2.publish_axle_event() # run() always republishes after a fetch attempt # Verify NO notification was sent alert_messages2 = [msg for msg in axle2.log_messages if msg.startswith("Alert:")] @@ -458,6 +461,7 @@ def _test_axle_fetch_with_future_event(my_predbat=None): with patch("aiohttp.ClientSession", return_value=mock_session): run_async(axle.fetch_axle_event()) + axle.publish_axle_event() # run() always republishes after a fetch attempt # Verify event data was parsed (stored as strings) assert axle.current_event["start_time"] == "2025-12-20T14:00:00+0000" @@ -494,6 +498,7 @@ def _test_axle_fetch_with_past_event(my_predbat=None): with patch("aiohttp.ClientSession", return_value=mock_session): run_async(axle.fetch_axle_event()) + axle.publish_axle_event() # run() always republishes after a fetch attempt # Event should be added to history (ended) assert len(axle.event_history) == 1, "Past event should be in history" @@ -522,6 +527,7 @@ def _test_axle_fetch_no_event(my_predbat=None): with patch("aiohttp.ClientSession", return_value=mock_session): run_async(axle.fetch_axle_event()) + axle.publish_axle_event() # run() always republishes after a fetch attempt # Verify all event data is None assert axle.current_event["start_time"] is None @@ -788,6 +794,69 @@ async def mock_fetch(): return False +def _test_axle_publishes_state_after_fetch_failure(my_predbat=None): + """Published state must reflect the current time even when the fetch itself fails. + + Otherwise a component stuck erroring (e.g. an expired API key) never republishes, + so a cached event that has since ended keeps being reported as active forever. + """ + print("Test: Axle API republishes state when fetch fails") + + async def failing_fetch(): + """Simulate a fetch that always raises, as if the Axle API key had expired.""" + raise RuntimeError("simulated persistent API failure (e.g. expired key)") + + now = datetime(2025, 12, 20, 14, 30, 0, tzinfo=timezone.utc) + sensor_id = "binary_sensor.predbat_axle_event" + + # Scenario 1: cached event has already ended - state must flip to "off" even though + # the fetch that would normally refresh it is broken. + axle = MockAxleAPI() + axle.initialize(api_key="test_key", pence_per_kwh=100, automatic=False) + axle._now_utc = now + axle.current_event = { + "start_time": "2025-12-20T12:00:00+0000", + "end_time": "2025-12-20T13:00:00+0000", # ended 90 minutes ago + "import_export": "export", + "pence_per_kwh": 100, + } + axle.updated_at = None # force fetch_due True + axle.fetch_axle_event = failing_fetch + + result = run_async(axle.run(seconds=600, first=False)) + + assert result is True, "Run should still return True even when the fetch fails" + assert axle.failures_total == 1, "Failure should be recorded" + assert sensor_id in axle.dashboard_items, "State must still be published even though the fetch failed" + assert axle.dashboard_items[sensor_id]["state"] == "off", "Expired cached event must not be reported as active forever just because fetching stopped working" + + print(" ✓ Expired event correctly reported off despite fetch failure") + + # Scenario 2: cached event is still within its own window - state should still + # correctly show "on" from the cached data alone. + axle2 = MockAxleAPI() + axle2.initialize(api_key="test_key", pence_per_kwh=100, automatic=False) + axle2._now_utc = now + axle2.current_event = { + "start_time": "2025-12-20T14:00:00+0000", + "end_time": "2025-12-20T16:00:00+0000", # still active + "import_export": "export", + "pence_per_kwh": 100, + } + axle2.updated_at = None + axle2.fetch_axle_event = failing_fetch + + run_async(axle2.run(seconds=600, first=False)) + + assert sensor_id in axle2.dashboard_items, "State must still be published even though the fetch failed" + assert axle2.dashboard_items[sensor_id]["state"] == "on", "Still-active cached event should remain correctly reported on" + + print(" ✓ Still-active event correctly reported on despite fetch failure") + + print(" ✓ State republished from cached data regardless of fetch outcome") + return False + + def _test_axle_history_loading(my_predbat=None): """Test loading event history from sensor on startup""" print("Test: Axle API history loading") @@ -1489,6 +1558,7 @@ def session_factory(*args, **kwargs): with patch("aiohttp.ClientSession", side_effect=session_factory): run_async(axle.fetch_axle_event()) + axle.publish_axle_event() # run() always republishes after a fetch attempt # Should have 4 events: 2 slots × 2 directions assert len(axle.event_history) == 4, f"Expected 4 events, got {len(axle.event_history)}"