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
13 changes: 8 additions & 5 deletions apps/predbat/axle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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)")

Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand Down
70 changes: 70 additions & 0 deletions apps/predbat/tests/test_axle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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:")]
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)}"
Expand Down
Loading