diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 2fa987782a9a..2b4335ea9146 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -26,6 +26,7 @@ from .const import AUTH_IMPLEMENTATION, DATA_HASS_CONFIG, DOMAIN, TibberConfigEn from .coordinator import ( TibberDataAPICoordinator, TibberDataCoordinator, + TibberFetchPriceCoordinator, TibberPriceCoordinator, ) from .services import async_setup_services @@ -44,6 +45,7 @@ class TibberRuntimeData: session: OAuth2Session data_api_coordinator: TibberDataAPICoordinator | None = field(default=None) data_coordinator: TibberDataCoordinator | None = field(default=None) + fetch_price_coordinator: TibberFetchPriceCoordinator | None = field(default=None) price_coordinator: TibberPriceCoordinator | None = field(default=None) _client: tibber.Tibber | None = None @@ -131,7 +133,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TibberConfigEntry) -> bo raise ConfigEntryNotReady("Fatal HTTP error from Tibber API") from err if tibber_connection.get_homes(only_active=True): - price_coordinator = TibberPriceCoordinator(hass, entry) + fetch_price_coordinator = TibberFetchPriceCoordinator(hass, entry) + await fetch_price_coordinator.async_config_entry_first_refresh() + entry.runtime_data.fetch_price_coordinator = fetch_price_coordinator + + price_coordinator = TibberPriceCoordinator(hass, entry, fetch_price_coordinator) await price_coordinator.async_config_entry_first_refresh() entry.runtime_data.price_coordinator = price_coordinator diff --git a/homeassistant/components/tibber/coordinator.py b/homeassistant/components/tibber/coordinator.py index 1110f81f621d..bfe3cdb145af 100644 --- a/homeassistant/components/tibber/coordinator.py +++ b/homeassistant/components/tibber/coordinator.py @@ -24,7 +24,7 @@ from homeassistant.components.recorder.statistics import ( statistics_during_period, ) from homeassistant.const import UnitOfEnergy -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import EnergyConverter @@ -102,7 +102,7 @@ class TibberCoordinator[_DataT](DataUpdateCoordinator[_DataT]): config_entry: TibberConfigEntry, *, name: str, - update_interval: timedelta, + update_interval: timedelta | None = None, ) -> None: """Initialize the coordinator.""" super().__init__( @@ -278,21 +278,54 @@ class TibberDataCoordinator(TibberCoordinator[None]): class TibberPriceCoordinator(TibberCoordinator[dict[str, TibberHomeData]]): - """Handle Tibber price data and insert statistics.""" + """Handle Tibber price data.""" def __init__( self, hass: HomeAssistant, config_entry: TibberConfigEntry, + price_fetch_coordinator: TibberFetchPriceCoordinator, ) -> None: """Initialize the price coordinator.""" super().__init__( hass, config_entry, name=f"{DOMAIN} price", - update_interval=timedelta(minutes=1), ) - self._tomorrow_price_poll_threshold_seconds = random.uniform(0, 3600 * 10) + self._price_fetch_coordinator = price_fetch_coordinator + self._unsub_price_fetch_listener: CALLBACK_TYPE | None = None + + @callback + def _build_price_data(self) -> dict[str, TibberHomeData]: + """Build derived price data from the fetched Tibber homes.""" + return { + home_id: _build_home_data(home) + for home_id, home in (self._price_fetch_coordinator.data or {}).items() + } + + @callback + def _async_handle_price_fetch_update(self) -> None: + """Update derived price data when fetched prices change.""" + self.update_interval = self._time_until_next_15_minute() + self.async_set_updated_data(self._build_price_data()) + + @callback + def _schedule_refresh(self) -> None: + """Start listening to fetched price data when entities subscribe.""" + super()._schedule_refresh() + if self._unsub_price_fetch_listener is None: + self._unsub_price_fetch_listener = ( + self._price_fetch_coordinator.async_add_listener( + self._async_handle_price_fetch_update + ) + ) + + def _unschedule_refresh(self) -> None: + """Stop listening to fetched price data when unused.""" + super()._unschedule_refresh() + if self._unsub_price_fetch_listener is not None: + self._unsub_price_fetch_listener() + self._unsub_price_fetch_listener = None def _time_until_next_15_minute(self) -> timedelta: """Return time until the next 15-minute boundary (0, 15, 30, 45) in UTC.""" @@ -309,7 +342,30 @@ class TibberPriceCoordinator(TibberCoordinator[dict[str, TibberHomeData]]): return next_run - now async def _async_update_data(self) -> dict[str, TibberHomeData]: - """Update data via API and return per-home data for sensors.""" + self.update_interval = self._time_until_next_15_minute() + return self._build_price_data() + + +class TibberFetchPriceCoordinator(TibberCoordinator[dict[str, tibber.TibberHome]]): + """Fetch Tibber price data from the API.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: TibberConfigEntry, + ) -> None: + """Initialize the price coordinator.""" + super().__init__( + hass, + config_entry, + name=f"{DOMAIN} price fetch", + ) + self._tomorrow_price_poll_threshold_seconds = random.uniform( + 3600 * 14, 3600 * 22 + ) + + async def _async_update_data(self) -> dict[str, tibber.TibberHome]: + """Fetch latest price data via API and return per-home data.""" tibber_connection = await self._async_get_client() active_homes = tibber_connection.get_homes(only_active=True) @@ -341,28 +397,31 @@ class TibberPriceCoordinator(TibberCoordinator[dict[str, TibberHomeData]]): return True if _has_prices_tomorrow(home): return False - if (today_end - now).total_seconds() < ( - self._tomorrow_price_poll_threshold_seconds + if now >= today_start + timedelta( + seconds=self._tomorrow_price_poll_threshold_seconds ): return True return False - homes_to_update = [home for home in active_homes if _needs_update(home)] + self.update_interval = timedelta(seconds=random.uniform(60, 60 * 10)) try: - if homes_to_update: - await asyncio.gather( - *(home.update_info_and_price_info() for home in homes_to_update) + await asyncio.gather( + *( + home.update_info_and_price_info() + for home in active_homes + if _needs_update(home) ) - except tibber.RetryableHttpExceptionError as err: - raise UpdateFailed(f"Error communicating with API ({err.status})") from err - except tibber.FatalHttpExceptionError as err: - raise UpdateFailed(f"Error communicating with API ({err.status})") from err + ) + except tibber.exceptions.RateLimitExceededError as err: + raise UpdateFailed( + f"Rate limit exceeded, retry after {err.retry_after} seconds", + retry_after=err.retry_after, + ) from err + except tibber.exceptions.HttpExceptionError as err: + raise UpdateFailed(f"Error communicating with API ({err})") from err - result = {home.home_id: _build_home_data(home) for home in active_homes} - - self.update_interval = self._time_until_next_15_minute() - return result + return {home.home_id: home for home in active_homes} class TibberDataAPICoordinator(TibberCoordinator[dict[str, TibberDevice]]): diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 0ac0114a1c88..cd96d8000b0c 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -750,7 +750,7 @@ class TibberSensorElPrice(TibberSensor, CoordinatorEntity[TibberPriceCoordinator ) -> None: """Initialize the sensor.""" super().__init__(coordinator=coordinator, tibber_home=tibber_home) - self._attr_available = False + self._price_data_available = False self._attr_native_unit_of_measurement = tibber_home.price_unit self._attr_extra_state_attributes = { "app_nickname": None, @@ -771,6 +771,11 @@ class TibberSensorElPrice(TibberSensor, CoordinatorEntity[TibberPriceCoordinator self._device_name = self._home_name self._update_attributes() + @property + def available(self) -> bool: + """Return if the sensor is available.""" + return super().available and self._price_data_available + @callback def _handle_coordinator_update(self) -> None: self._update_attributes() @@ -784,7 +789,8 @@ class TibberSensorElPrice(TibberSensor, CoordinatorEntity[TibberPriceCoordinator (home_data := data.get(self._tibber_home.home_id)) is None or (current_price := home_data.get("current_price")) is None ): - self._attr_available = False + self._price_data_available = False + self._attr_native_value = None return self._attr_native_unit_of_measurement = home_data.get( @@ -805,7 +811,7 @@ class TibberSensorElPrice(TibberSensor, CoordinatorEntity[TibberPriceCoordinator self._attr_extra_state_attributes["estimated_annual_consumption"] = home_data[ "estimated_annual_consumption" ] - self._attr_available = True + self._price_data_available = True class TibberDataSensor(TibberSensor, CoordinatorEntity[TibberDataCoordinator]): diff --git a/tests/components/tibber/conftest.py b/tests/components/tibber/conftest.py index b6943067d0cb..ae19505e1078 100644 --- a/tests/components/tibber/conftest.py +++ b/tests/components/tibber/conftest.py @@ -18,6 +18,7 @@ from homeassistant.components.tibber.const import AUTH_IMPLEMENTATION, DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry from tests.typing import RecorderInstanceContextManager @@ -145,6 +146,57 @@ def create_tibber_device( return tibber.data_api.TibberDevice(device_data, home_id=home_id) +def create_tibber_home( + *, + current_price: float | None = 1.25, + price_total: dict[str, float] | None = None, +) -> MagicMock: + """Create a mocked Tibber home with an active subscription.""" + home = MagicMock() + home.home_id = "home-id" + home.name = "Home" + home.currency = "NOK" + home.price_unit = "NOK/kWh" + home.price_total = price_total or {} + home.has_active_subscription = True + home.has_real_time_consumption = False + home.last_data_timestamp = None + home.update_info = AsyncMock(return_value=None) + home.update_info_and_price_info = AsyncMock(return_value=None) + home.current_price_data = MagicMock( + return_value=(current_price, dt_util.utcnow(), 0.4) + ) + home.current_attributes = MagicMock( + return_value={ + "max_price": 1.8, + "avg_price": 1.2, + "min_price": 0.8, + "off_peak_1": 0.9, + "peak": 1.7, + "off_peak_2": 1.0, + } + ) + home.month_cost = 111.1 + home.peak_hour = 2.5 + home.peak_hour_time = dt_util.utcnow() + home.month_cons = 222.2 + home.hourly_consumption_data = [] + home.hourly_production_data = [] + home.info = { + "viewer": { + "home": { + "appNickname": "Home", + "address": {"address1": "Street 1"}, + "meteringPointData": { + "gridCompany": "GridCo", + "estimatedAnnualConsumption": 12000, + }, + } + } + } + return home + + @pytest.fixture def config_entry(hass: HomeAssistant) -> MockConfigEntry: """Tibber config entry.""" diff --git a/tests/components/tibber/test_coordinator.py b/tests/components/tibber/test_coordinator.py new file mode 100644 index 000000000000..96e84cf32bc1 --- /dev/null +++ b/tests/components/tibber/test_coordinator.py @@ -0,0 +1,252 @@ +"""Tests for the Tibber coordinators.""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +import tibber + +from homeassistant.components.recorder import Recorder +from homeassistant.components.tibber.const import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from .conftest import create_tibber_home + +from tests.common import MockConfigEntry, async_fire_time_changed + + +def _prices_for_days(*days: str) -> dict[str, float]: + """Return price data keyed by ISO timestamps for the given days.""" + return {f"{day}T12:00:00+00:00": 1.0 for day in days} + + +async def _async_setup_price_sensor( + hass: HomeAssistant, + config_entry: MockConfigEntry, + tibber_mock: MagicMock, + entity_registry: er.EntityRegistry, + home: MagicMock, +) -> str: + """Set up the Tibber config entry and return the price sensor entity id.""" + tibber_mock.get_homes.return_value = [home] + config_entry.data["token"]["expires_at"] = dt_util.utcnow().timestamp() + 86400 + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, home.home_id) + assert entity_id is not None + assert hass.states.get(entity_id) is not None + return entity_id + + +async def _async_fire_coordinator_update( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + delta: timedelta, +) -> None: + """Move time forward and fire scheduled coordinator updates.""" + freezer.tick(delta) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + +async def test_price_fetch_refreshes_when_today_prices_are_missing( + recorder_mock: Recorder, + hass: HomeAssistant, + config_entry: MockConfigEntry, + tibber_mock: MagicMock, + setup_credentials: None, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test price fetching when cached prices do not include today.""" + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2026-04-26 00:10:00+00:00") + home = create_tibber_home(price_total=_prices_for_days("2026-04-25")) + + async def update_info_and_price_info() -> None: + home.price_total = _prices_for_days("2026-04-26") + + home.update_info_and_price_info.side_effect = update_info_and_price_info + + await _async_setup_price_sensor( + hass, config_entry, tibber_mock, entity_registry, home + ) + + # Update immediately when no prices are available for today + assert home.update_info_and_price_info.await_count == 1 + + await _async_fire_coordinator_update(hass, freezer, timedelta(hours=12)) + + # No update after 12 hours + assert home.update_info_and_price_info.await_count == 1 + + +async def test_price_fetch_waits_until_tomorrow_price_polling_window( + recorder_mock: Recorder, + hass: HomeAssistant, + config_entry: MockConfigEntry, + tibber_mock: MagicMock, + setup_credentials: None, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test price fetching waits until the tomorrow-price polling window.""" + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2026-04-26 13:00:00+00:00") + home = create_tibber_home(price_total=_prices_for_days("2026-04-26")) + + async def update_info_and_price_info() -> None: + home.price_total = _prices_for_days("2026-04-26", "2026-04-27") + + home.update_info_and_price_info.side_effect = update_info_and_price_info + + await _async_setup_price_sensor( + hass, config_entry, tibber_mock, entity_registry, home + ) + + assert home.update_info_and_price_info.await_count == 0 + + await _async_fire_coordinator_update(hass, freezer, timedelta(minutes=10)) + + # No update before the price polling window has passed + assert home.update_info_and_price_info.await_count == 0 + + await _async_fire_coordinator_update(hass, freezer, timedelta(hours=9, minutes=50)) + + # Update after the price polling window has passed + assert home.update_info_and_price_info.await_count == 1 + + await _async_fire_coordinator_update(hass, freezer, timedelta(hours=10)) + + # Ensure we only update once + assert home.update_info_and_price_info.await_count == 1 + + +async def test_price_fetch_skips_update_when_tomorrow_prices_exist( + recorder_mock: Recorder, + hass: HomeAssistant, + config_entry: MockConfigEntry, + tibber_mock: MagicMock, + setup_credentials: None, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test price fetching skips homes that already have tomorrow prices.""" + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2026-04-26 23:00:00+00:00") + home = create_tibber_home(price_total=_prices_for_days("2026-04-26", "2026-04-27")) + + await _async_setup_price_sensor( + hass, config_entry, tibber_mock, entity_registry, home + ) + + await _async_fire_coordinator_update(hass, freezer, timedelta(hours=10)) + + # No update when tomorrow prices are already available + assert home.update_info_and_price_info.await_count == 0 + + +@pytest.mark.parametrize( + ("exception", "expected_message"), + [ + pytest.param( + tibber.exceptions.RateLimitExceededError( + 429, "Too many requests", "RATE_LIMIT", 123 + ), + "Rate limit exceeded, retry after 123 seconds", + id="rate_limit", + ), + pytest.param( + tibber.exceptions.HttpExceptionError(503, "Service unavailable"), + "Error communicating with API (Service unavailable)", + id="http_error", + ), + ], +) +async def test_price_fetch_refresh_handles_update_exceptions( + recorder_mock: Recorder, + hass: HomeAssistant, + config_entry: MockConfigEntry, + tibber_mock: MagicMock, + setup_credentials: None, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, + exception: Exception, + expected_message: str, +) -> None: + """Test handled exceptions during price fetching coordinator refresh.""" + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2026-04-26 23:00:00+00:00") + home = create_tibber_home(price_total=_prices_for_days("2026-04-26")) + + entity_id = await _async_setup_price_sensor( + hass, config_entry, tibber_mock, entity_registry, home + ) + state = hass.states.get(entity_id) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + initial_update_count = home.update_info_and_price_info.await_count + home.update_info_and_price_info.side_effect = exception + + await _async_fire_coordinator_update(hass, freezer, timedelta(hours=1)) + + assert home.update_info_and_price_info.await_count == initial_update_count + 1 + assert ( + f"Error fetching {DOMAIN} price fetch data: {expected_message}" in caplog.text + ) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + +async def test_price_sensor_unavailable_when_cached_prices_run_out( + recorder_mock: Recorder, + hass: HomeAssistant, + config_entry: MockConfigEntry, + tibber_mock: MagicMock, + setup_credentials: None, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test price sensor becomes unavailable when cached prices no longer apply.""" + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2026-04-26 23:50:00+00:00") + home = create_tibber_home(price_total=_prices_for_days("2026-04-26")) + + def current_price_data() -> tuple[float | None, datetime | None, float | None]: + if dt_util.now().date() == date(2026, 4, 26): + return (1.25, dt_util.utcnow(), 0.4) + return (None, None, None) + + home.current_price_data.side_effect = current_price_data + + entity_id = await _async_setup_price_sensor( + hass, config_entry, tibber_mock, entity_registry, home + ) + + state = hass.states.get(entity_id) + assert state is not None + assert float(state.state) == 1.25 + + await _async_fire_coordinator_update(hass, freezer, timedelta(minutes=10)) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.SENSOR] diff --git a/tests/components/tibber/test_sensor.py b/tests/components/tibber/test_sensor.py index e29464287512..64db3e647d92 100644 --- a/tests/components/tibber/test_sensor.py +++ b/tests/components/tibber/test_sensor.py @@ -11,59 +11,12 @@ from homeassistant.components.tibber.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -from homeassistant.util import dt as dt_util -from .conftest import create_tibber_device +from .conftest import create_tibber_device, create_tibber_home from tests.common import MockConfigEntry -def _create_home(*, current_price: float | None = 1.25) -> MagicMock: - """Create a mocked Tibber home with an active subscription.""" - home = MagicMock() - home.home_id = "home-id" - home.name = "Home" - home.currency = "NOK" - home.price_unit = "NOK/kWh" - home.has_active_subscription = True - home.has_real_time_consumption = False - home.last_data_timestamp = None - home.update_info = AsyncMock(return_value=None) - home.update_info_and_price_info = AsyncMock(return_value=None) - home.current_price_data = MagicMock( - return_value=(current_price, dt_util.utcnow(), 0.4) - ) - home.current_attributes = MagicMock( - return_value={ - "max_price": 1.8, - "avg_price": 1.2, - "min_price": 0.8, - "off_peak_1": 0.9, - "peak": 1.7, - "off_peak_2": 1.0, - } - ) - home.month_cost = 111.1 - home.peak_hour = 2.5 - home.peak_hour_time = dt_util.utcnow() - home.month_cons = 222.2 - home.hourly_consumption_data = [] - home.hourly_production_data = [] - home.info = { - "viewer": { - "home": { - "appNickname": "Home", - "address": {"address1": "Street 1"}, - "meteringPointData": { - "gridCompany": "GridCo", - "estimatedAnnualConsumption": 12000, - }, - } - } - } - return home - - async def test_price_sensor_state_unit_and_attributes( recorder_mock: Recorder, hass: HomeAssistant, @@ -73,7 +26,7 @@ async def test_price_sensor_state_unit_and_attributes( entity_registry: er.EntityRegistry, ) -> None: """Test price sensor state and attributes.""" - home = _create_home(current_price=1.25) + home = create_tibber_home(current_price=1.25) tibber_mock.get_homes.return_value = [home] await hass.config_entries.async_setup(config_entry.entry_id)