diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index d53706b315c302..6d542663cdd2e8 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -563,8 +563,17 @@ def _update_with_finer_cost_reads( account, AggregateType.DAY, start, end ) except ApiException as err: - _LOGGER.error("Error getting daily cost reads: %s", err) - raise + _LOGGER.warning( + "Error getting daily cost reads, falling back to usage-only: %s", + err, + ) + try: + daily_cost_reads = await self.api.async_get_cost_reads( + account, AggregateType.DAY, start, end, usage_only=True + ) + except ApiException: + _LOGGER.warning("Usage-only daily reads also failed, using monthly") + return cost_reads _LOGGER.debug("Got %s daily cost reads", len(daily_cost_reads)) _update_with_finer_cost_reads(cost_reads, daily_cost_reads) if account.read_resolution == ReadResolution.DAY: @@ -581,8 +590,19 @@ def _update_with_finer_cost_reads( account, AggregateType.HOUR, start, end ) except ApiException as err: - _LOGGER.error("Error getting hourly cost reads: %s", err) - raise + _LOGGER.warning( + "Error getting hourly cost reads, falling back to usage-only: %s", + err, + ) + try: + hourly_cost_reads = await self.api.async_get_cost_reads( + account, AggregateType.HOUR, start, end, usage_only=True + ) + except ApiException: + _LOGGER.warning( + "Usage-only hourly reads also failed, using coarser reads" + ) + return cost_reads _LOGGER.debug("Got %s hourly cost reads", len(hourly_cost_reads)) _update_with_finer_cost_reads(cost_reads, hourly_cost_reads) _LOGGER.debug("Got %s cost reads", len(cost_reads)) diff --git a/tests/components/opower/test_coordinator.py b/tests/components/opower/test_coordinator.py index 6c6624c35f7638..2012fcd4815f16 100644 --- a/tests/components/opower/test_coordinator.py +++ b/tests/components/opower/test_coordinator.py @@ -250,8 +250,6 @@ async def test_coordinator_migration( ("async_get_accounts", None), ("async_get_forecast", None), ("async_get_cost_reads", AggregateType.BILL), - ("async_get_cost_reads", AggregateType.DAY), - ("async_get_cost_reads", AggregateType.HOUR), ], ) async def test_coordinator_api_exceptions( @@ -290,6 +288,54 @@ async def side_effect(account, agg_type, start, end): await coordinator._async_update_data() +@pytest.mark.parametrize( + "failing_aggregate_type", + [AggregateType.DAY, AggregateType.HOUR], +) +async def test_coordinator_cost_reads_fallback_on_api_error( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opower_api: AsyncMock, + failing_aggregate_type: AggregateType, +) -> None: + """Test that daily/hourly cost read failures fall back to usage-only reads.""" + coordinator = OpowerCoordinator(hass, mock_config_entry) + + # Use a single ELEC account with HOUR resolution so all read levels are attempted + account = mock_opower_api.async_get_accounts.return_value[0] + mock_opower_api.async_get_accounts.return_value = [account] + + t1 = dt_util.as_utc(datetime(2023, 1, 1, 0)) + t2 = dt_util.as_utc(datetime(2023, 1, 2, 0)) + bill_read = CostRead( + start_time=t1, end_time=t2, consumption=10.0, provided_cost=1.0 + ) + usage_read = CostRead( + start_time=t1, end_time=t2, consumption=10.0, provided_cost=0.0 + ) + + async def side_effect( + acc: object, + agg_type: AggregateType, + start: object, + end: object, + usage_only: bool = False, + ) -> list[CostRead]: + if agg_type == failing_aggregate_type and not usage_only: + raise ApiException(message="HTTP Error: 500", url="http://example.com") + if usage_only: + return [usage_read] + return [bill_read] + + mock_opower_api.async_get_cost_reads.side_effect = side_effect + + # Should NOT raise — the coordinator should fall back to usage-only reads + result = await coordinator._async_update_data() + await async_wait_recording_done(hass) + assert result is not None + + async def test_coordinator_updates_with_finer_grained_data( recorder_mock: Recorder, hass: HomeAssistant,