Improve data updating for Tibber (#168065)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Daniel Hjelseth Høyer
2026-04-29 08:42:47 +02:00
committed by GitHub
co-authored by Copilot Martin Hjelmare
parent aa7474839b
commit dc8abff6b9
6 changed files with 401 additions and 73 deletions
+7 -1
View File
@@ -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
+79 -20
View File
@@ -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]]):
+9 -3
View File
@@ -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]):
+52
View File
@@ -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."""
+252
View File
@@ -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]
+2 -49
View File
@@ -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)