Compare commits

...

1 Commits

Author SHA1 Message Date
Daniel Hjelseth Høyer
c199391cde Refactor Tibber
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-02-13 13:32:23 +01:00
5 changed files with 416 additions and 270 deletions

View File

@@ -23,7 +23,7 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util, ssl as ssl_util
from .const import AUTH_IMPLEMENTATION, DATA_HASS_CONFIG, DOMAIN, TibberConfigEntry
from .coordinator import TibberDataAPICoordinator
from .coordinator import TibberDataAPICoordinator, TibberDataCoordinator
from .services import async_setup_services
PLATFORMS = [Platform.BINARY_SENSOR, Platform.NOTIFY, Platform.SENSOR]
@@ -39,6 +39,7 @@ class TibberRuntimeData:
session: OAuth2Session
data_api_coordinator: TibberDataAPICoordinator | None = field(default=None)
data_coordinator: TibberDataCoordinator | None = field(default=None)
_client: tibber.Tibber | None = None
async def async_get_client(self, hass: HomeAssistant) -> tibber.Tibber:
@@ -124,9 +125,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: TibberConfigEntry) -> bo
except tibber.FatalHttpExceptionError as err:
raise ConfigEntryNotReady("Fatal HTTP error from Tibber API") from err
coordinator = TibberDataAPICoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data.data_api_coordinator = coordinator
data_api_coordinator = TibberDataAPICoordinator(hass, entry)
await data_api_coordinator.async_config_entry_first_refresh()
entry.runtime_data.data_api_coordinator = data_api_coordinator
data_coordinator = TibberDataCoordinator(hass, entry, entry.runtime_data)
await data_coordinator.async_config_entry_first_refresh()
entry.runtime_data.data_coordinator = data_coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True

View File

@@ -2,9 +2,11 @@
from __future__ import annotations
from datetime import timedelta
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
from typing import TYPE_CHECKING, cast
from typing import TYPE_CHECKING, Any, cast
from aiohttp.client_exceptions import ClientError
import tibber
@@ -21,8 +23,9 @@ from homeassistant.components.recorder.statistics import (
get_last_statistics,
statistics_during_period,
)
from homeassistant.const import UnitOfEnergy
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, UnitOfEnergy
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
@@ -31,6 +34,9 @@ from homeassistant.util.unit_conversion import EnergyConverter
from .const import DOMAIN
if TYPE_CHECKING:
from tibber import TibberHome
from . import TibberRuntimeData
from .const import TibberConfigEntry
FIVE_YEARS = 5 * 365 * 24
@@ -38,8 +44,52 @@ FIVE_YEARS = 5 * 365 * 24
_LOGGER = logging.getLogger(__name__)
class TibberDataCoordinator(DataUpdateCoordinator[None]):
"""Handle Tibber data and insert statistics."""
@dataclass
class TibberHomeData:
"""Structured data per Tibber home from GraphQL and price API."""
currency: str
price_unit: str
current_price: float | None
current_price_time: datetime | None
intraday_price_ranking: float | None
max_price: float
avg_price: float
min_price: float
off_peak_1: float
peak: float
off_peak_2: float
month_cost: float | None
peak_hour: float | None
peak_hour_time: datetime | None
month_cons: float | None
def _build_home_data(home: TibberHome) -> TibberHomeData:
"""Build TibberHomeData from a TibberHome after price info has been fetched."""
price_value, price_time, price_rank = home.current_price_data()
attrs = home.current_attributes()
return TibberHomeData(
currency=home.currency,
price_unit=home.price_unit,
current_price=price_value,
current_price_time=price_time,
intraday_price_ranking=price_rank,
max_price=attrs.get("max_price", 0.0),
avg_price=attrs.get("avg_price", 0.0),
min_price=attrs.get("min_price", 0.0),
off_peak_1=attrs.get("off_peak_1", 0.0),
peak=attrs.get("peak", 0.0),
off_peak_2=attrs.get("off_peak_2", 0.0),
month_cost=getattr(home, "month_cost", None),
peak_hour=getattr(home, "peak_hour", None),
peak_hour_time=getattr(home, "peak_hour_time", None),
month_cons=getattr(home, "month_cons", None),
)
class TibberDataCoordinator(DataUpdateCoordinator[dict[str, TibberHomeData]]):
"""Handle Tibber data, insert statistics, and expose per-home data for sensors."""
config_entry: TibberConfigEntry
@@ -47,24 +97,39 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]):
self,
hass: HomeAssistant,
config_entry: TibberConfigEntry,
tibber_connection: tibber.Tibber,
runtime_data: TibberRuntimeData,
) -> None:
"""Initialize the data handler."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"Tibber {tibber_connection.name}",
name="Tibber",
update_interval=timedelta(minutes=20),
)
self._tibber_connection = tibber_connection
self._runtime_data = runtime_data
async def _async_update_data(self) -> None:
"""Update data via API."""
async def _async_update_data(self) -> dict[str, TibberHomeData]:
"""Update data via API and return per-home data for sensors."""
tibber_connection = await self._runtime_data.async_get_client(self.hass)
try:
await self._tibber_connection.fetch_consumption_data_active_homes()
await self._tibber_connection.fetch_production_data_active_homes()
await self._insert_statistics()
await tibber_connection.fetch_consumption_data_active_homes()
await tibber_connection.fetch_production_data_active_homes()
now = dt_util.now()
for home in tibber_connection.get_homes(only_active=True):
update_needed = False
last_data_timestamp = home.last_data_timestamp
if last_data_timestamp is None:
update_needed = True
else:
remaining_seconds = (last_data_timestamp - now).total_seconds()
if remaining_seconds < 11 * 3600:
update_needed = True
if update_needed:
await home.update_info_and_price_info()
await self._insert_statistics(tibber_connection)
except tibber.RetryableHttpExceptionError as err:
raise UpdateFailed(f"Error communicating with API ({err.status})") from err
except tibber.FatalHttpExceptionError:
@@ -72,10 +137,16 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]):
self.hass.async_create_task(
self.hass.config_entries.async_reload(self.config_entry.entry_id)
)
return self.data if self.data is not None else {}
async def _insert_statistics(self) -> None:
result: dict[str, TibberHomeData] = {}
for home in tibber_connection.get_homes(only_active=True):
result[home.home_id] = _build_home_data(home)
return result
async def _insert_statistics(self, tibber_connection: tibber.Tibber) -> None:
"""Insert Tibber statistics."""
for home in self._tibber_connection.get_homes():
for home in tibber_connection.get_homes():
sensors: list[tuple[str, bool, str | None, str]] = []
if home.hourly_consumption_data:
sensors.append(
@@ -257,3 +328,48 @@ class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]):
) from err
self._build_sensor_lookup(devices)
return devices
class TibberRtDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Handle Tibber realtime data."""
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
add_sensor_callback: Callable[[TibberRtDataCoordinator, Any], None],
tibber_home: TibberHome,
) -> None:
"""Initialize the data handler."""
self._add_sensor_callback = add_sensor_callback
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=tibber_home.info["viewer"]["home"]["address"].get(
"address1", "Tibber"
),
)
self._async_remove_device_updates_handler = self.async_add_listener(
self._data_updated
)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop)
@callback
def _handle_ha_stop(self, _event: Event) -> None:
"""Handle Home Assistant stopping."""
self._async_remove_device_updates_handler()
@callback
def _data_updated(self) -> None:
"""Triggered when data is updated."""
if live_measurement := self.get_live_measurement():
self._add_sensor_callback(self, live_measurement)
def get_live_measurement(self) -> Any:
"""Get live measurement data."""
if errors := self.data.get("errors"):
_LOGGER.error(errors[0])
return None
return self.data.get("data", {}).get("liveMeasurement")

View File

@@ -0,0 +1,140 @@
"""Shared entity base for Tibber sensors."""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, cast
from homeassistant.components.sensor import SensorEntityDescription
from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import TibberDataCoordinator, TibberHomeData, TibberRtDataCoordinator
if TYPE_CHECKING:
from tibber import TibberHome
class TibberDataCoordinatorEntity(CoordinatorEntity[TibberDataCoordinator]):
"""Base entity for Tibber sensors using TibberDataCoordinator."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: TibberDataCoordinator,
tibber_home: TibberHome,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._tibber_home = tibber_home
self._home_name: str = tibber_home.name or tibber_home.home_id
self._model: str | None = None
self._device_name: str = self._home_name
def _get_home_data(self) -> TibberHomeData | None:
"""Return cached home data from the coordinator."""
data = cast(dict[str, TibberHomeData] | None, self.coordinator.data)
if data is None:
return None
return data.get(self._tibber_home.home_id)
@property
def device_info(self) -> DeviceInfo:
"""Return device information."""
return DeviceInfo(
identifiers={(DOMAIN, self._tibber_home.home_id)},
name=self._device_name,
model=self._model,
)
class TibberSensor:
"""Mixin for Tibber sensors that have a Tibber home and device info.
Used as the first base for real-time sensors (TibberSensorRT with
CoordinatorEntity["TibberRtDataCoordinator"]). Provides _tibber_home,
_home_name, _model, _device_name and device_info; does not inherit
CoordinatorEntity so the second base can be the coordinator entity.
"""
def __init__(self, coordinator: object, tibber_home: TibberHome) -> None:
"""Initialize the mixin."""
super().__init__(coordinator) # type: ignore[call-arg]
self._tibber_home = tibber_home
self._home_name: str = tibber_home.name or tibber_home.home_id
self._model: str | None = None
self._device_name: str = self._home_name
@property
def device_info(self) -> DeviceInfo:
"""Return device information."""
return DeviceInfo(
identifiers={(DOMAIN, self._tibber_home.home_id)},
name=self._device_name,
model=self._model,
)
class TibberSensorRT(TibberSensor, CoordinatorEntity[TibberRtDataCoordinator]):
"""Representation of a Tibber sensor for real time consumption."""
def __init__(
self,
tibber_home: TibberHome,
description: SensorEntityDescription,
initial_state: float,
coordinator: TibberRtDataCoordinator,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator=coordinator, tibber_home=tibber_home)
self.entity_description = description
self._model = "Tibber Pulse"
self._device_name = f"{self._model} {self._home_name}"
self._attr_native_value = initial_state
self._attr_last_reset: datetime | None = None
self._attr_unique_id = f"{self._tibber_home.home_id}_rt_{description.key}"
if description.key in ("accumulatedCost", "accumulatedReward"):
self._attr_native_unit_of_measurement = tibber_home.currency
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self._tibber_home.rt_subscription_running
@callback
def _handle_coordinator_update(self) -> None:
if not (live_measurement := self.coordinator.get_live_measurement()):
return
state = live_measurement.get(self.entity_description.key)
if state is None:
return
if self.entity_description.key in (
"accumulatedConsumption",
"accumulatedProduction",
):
# Value is reset to 0 at midnight, but not always strictly increasing
# due to hourly corrections.
# If device is offline, last_reset should be updated when it comes
# back online if the value has decreased
ts_local = dt_util.parse_datetime(live_measurement["timestamp"])
if ts_local is not None:
if self._attr_last_reset is None or (
state < 0.5 * self._attr_native_value
and (
ts_local.hour == 0
or (ts_local - self._attr_last_reset) > timedelta(hours=24)
)
):
self._attr_last_reset = dt_util.as_utc(
ts_local.replace(hour=0, minute=0, second=0, microsecond=0)
)
if self.entity_description.key == "powerFactor":
state *= 100.0
self._attr_native_value = state
self.async_write_ha_state()

View File

@@ -2,12 +2,8 @@
from __future__ import annotations
from collections.abc import Callable
import datetime
from datetime import timedelta
import logging
from random import randrange
from typing import Any
from typing import Any, cast
import aiohttp
from tibber import FatalHttpExceptionError, RetryableHttpExceptionError, TibberHome
@@ -19,9 +15,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS,
EntityCategory,
@@ -32,28 +26,26 @@ from homeassistant.const import (
UnitOfPower,
UnitOfTemperature,
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from homeassistant.util import Throttle, dt as dt_util
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER, TibberConfigEntry
from .coordinator import TibberDataAPICoordinator, TibberDataCoordinator
from .const import DOMAIN, TibberConfigEntry
from .coordinator import (
TibberDataAPICoordinator,
TibberDataCoordinator,
TibberRtDataCoordinator,
)
from .entity import TibberDataCoordinatorEntity, TibberSensorRT
_LOGGER = logging.getLogger(__name__)
ICON = "mdi:currency-usd"
SCAN_INTERVAL = timedelta(minutes=1)
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5)
PARALLEL_UPDATES = 0
TWENTY_MINUTES = 20 * 60
RT_SENSORS_UNIQUE_ID_MIGRATION = {
"accumulated_consumption_last_hour": "accumulated consumption current hour",
@@ -262,6 +254,48 @@ SENSORS: tuple[SensorEntityDescription, ...] = (
),
)
PRICE_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="current_price",
translation_key="electricity_price",
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="max_price",
translation_key="max_price",
device_class=SensorDeviceClass.MONETARY,
),
SensorEntityDescription(
key="avg_price",
translation_key="avg_price",
device_class=SensorDeviceClass.MONETARY,
),
SensorEntityDescription(
key="min_price",
translation_key="min_price",
device_class=SensorDeviceClass.MONETARY,
),
SensorEntityDescription(
key="off_peak_1",
translation_key="off_peak_1",
device_class=SensorDeviceClass.MONETARY,
),
SensorEntityDescription(
key="peak",
translation_key="peak",
device_class=SensorDeviceClass.MONETARY,
),
SensorEntityDescription(
key="off_peak_2",
translation_key="off_peak_2",
device_class=SensorDeviceClass.MONETARY,
),
SensorEntityDescription(
key="intraday_price_ranking",
translation_key="intraday_price_ranking",
state_class=SensorStateClass.MEASUREMENT,
),
)
DATA_API_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
@@ -603,14 +637,13 @@ async def _async_setup_graphql_sensors(
entry: TibberConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tibber sensor."""
"""Set up the Tibber GraphQL-based sensors."""
tibber_connection = await entry.runtime_data.async_get_client(hass)
entity_registry = er.async_get(hass)
coordinator: TibberDataCoordinator | None = None
entities: list[TibberSensor] = []
active_homes: list[TibberHome] = []
for home in tibber_connection.get_homes(only_active=False):
try:
await home.update_info()
@@ -626,13 +659,7 @@ async def _async_setup_graphql_sensors(
raise PlatformNotReady from err
if home.has_active_subscription:
entities.append(TibberSensorElPrice(home))
if coordinator is None:
coordinator = TibberDataCoordinator(hass, entry, tibber_connection)
entities.extend(
TibberDataSensor(home, coordinator, entity_description)
for entity_description in SENSORS
)
active_homes.append(home)
if home.has_real_time_consumption:
entity_creator = TibberRtEntityCreator(
@@ -647,6 +674,18 @@ async def _async_setup_graphql_sensors(
).async_set_updated_data
)
entities: list[TibberDataSensor] = []
coordinator = entry.runtime_data.data_coordinator
if coordinator is not None and active_homes:
for home in active_homes:
entities.extend(
TibberDataSensor(home, coordinator, desc, model="Price Sensor")
for desc in PRICE_SENSORS
)
entities.extend(
TibberDataSensor(home, coordinator, desc) for desc in SENSORS
)
async_add_entities(entities)
@@ -707,200 +746,70 @@ class TibberDataAPISensor(CoordinatorEntity[TibberDataAPICoordinator], SensorEnt
return sensor.value if sensor else None
class TibberSensor(SensorEntity):
"""Representation of a generic Tibber sensor."""
_attr_has_entity_name = True
def __init__(self, *args: Any, tibber_home: TibberHome, **kwargs: Any) -> None:
"""Initialize the sensor."""
super().__init__(*args, **kwargs)
self._tibber_home = tibber_home
self._home_name = tibber_home.info["viewer"]["home"]["appNickname"]
if self._home_name is None:
self._home_name = tibber_home.info["viewer"]["home"]["address"].get(
"address1", ""
)
self._device_name: str | None = None
self._model: str | None = None
@property
def device_info(self) -> DeviceInfo:
"""Return the device_info of the device."""
device_info = DeviceInfo(
identifiers={(DOMAIN, self._tibber_home.home_id)},
name=self._device_name,
manufacturer=MANUFACTURER,
)
if self._model is not None:
device_info["model"] = self._model
return device_info
class TibberSensorElPrice(TibberSensor):
"""Representation of a Tibber sensor for el price."""
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_translation_key = "electricity_price"
def __init__(self, tibber_home: TibberHome) -> None:
"""Initialize the sensor."""
super().__init__(tibber_home=tibber_home)
self._last_updated: datetime.datetime | None = None
self._spread_load_constant = randrange(TWENTY_MINUTES)
self._attr_available = False
self._attr_extra_state_attributes = {
"app_nickname": None,
"grid_company": None,
"estimated_annual_consumption": None,
"max_price": None,
"avg_price": None,
"min_price": None,
"off_peak_1": None,
"peak": None,
"off_peak_2": None,
"intraday_price_ranking": None,
}
self._attr_icon = ICON
self._attr_unique_id = self._tibber_home.home_id
self._model = "Price Sensor"
self._device_name = self._home_name
async def async_update(self) -> None:
"""Get the latest data and updates the states."""
now = dt_util.now()
if (
not self._tibber_home.last_data_timestamp
or (self._tibber_home.last_data_timestamp - now).total_seconds()
< 10 * 3600 - self._spread_load_constant
or not self.available
):
_LOGGER.debug("Asking for new data")
await self._fetch_data()
elif (
self._tibber_home.price_total
and self._last_updated
and self._last_updated.hour == now.hour
and now - self._last_updated < timedelta(minutes=15)
and self._tibber_home.last_data_timestamp
):
return
res = self._tibber_home.current_price_data()
self._attr_native_value, self._last_updated, price_rank = res
self._attr_extra_state_attributes["intraday_price_ranking"] = price_rank
attrs = self._tibber_home.current_attributes()
self._attr_extra_state_attributes.update(attrs)
self._attr_available = self._attr_native_value is not None
self._attr_native_unit_of_measurement = self._tibber_home.price_unit
@Throttle(MIN_TIME_BETWEEN_UPDATES)
async def _fetch_data(self) -> None:
_LOGGER.debug("Fetching data")
try:
await self._tibber_home.update_info_and_price_info()
except TimeoutError, aiohttp.ClientError:
return
data = self._tibber_home.info["viewer"]["home"]
self._attr_extra_state_attributes["app_nickname"] = data["appNickname"]
self._attr_extra_state_attributes["grid_company"] = data["meteringPointData"][
"gridCompany"
]
self._attr_extra_state_attributes["estimated_annual_consumption"] = data[
"meteringPointData"
]["estimatedAnnualConsumption"]
class TibberDataSensor(TibberSensor, CoordinatorEntity[TibberDataCoordinator]):
"""Representation of a Tibber sensor."""
class TibberDataSensor(TibberDataCoordinatorEntity):
"""Representation of a Tibber sensor reading from coordinator data."""
def __init__(
self,
tibber_home: TibberHome,
coordinator: TibberDataCoordinator,
entity_description: SensorEntityDescription,
*,
model: str | None = None,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator=coordinator, tibber_home=tibber_home)
self.entity_description = entity_description
self._attr_unique_id = (
f"{self._tibber_home.home_id}_{self.entity_description.key}"
)
if entity_description.key == "month_cost":
self._attr_native_unit_of_measurement = self._tibber_home.currency
if self.entity_description.key == "current_price":
# Preserve the existing unique ID for the electricity price
# entity to avoid breaking user setups.
self._attr_unique_id = self._tibber_home.home_id
else:
self._attr_unique_id = (
f"{self._tibber_home.home_id}_{self.entity_description.key}"
)
self._device_name = self._home_name
if model is not None:
self._model = model
@property
def native_value(self) -> StateType:
"""Return the value of the sensor."""
return getattr(self._tibber_home, self.entity_description.key) # type: ignore[no-any-return]
class TibberSensorRT(TibberSensor, CoordinatorEntity["TibberRtDataCoordinator"]):
"""Representation of a Tibber sensor for real time consumption."""
def __init__(
self,
tibber_home: TibberHome,
description: SensorEntityDescription,
initial_state: float,
coordinator: TibberRtDataCoordinator,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator=coordinator, tibber_home=tibber_home)
self.entity_description = description
self._model = "Tibber Pulse"
self._device_name = f"{self._model} {self._home_name}"
self._attr_native_value = initial_state
self._attr_unique_id = f"{self._tibber_home.home_id}_rt_{description.key}"
if description.key in ("accumulatedCost", "accumulatedReward"):
self._attr_native_unit_of_measurement = tibber_home.currency
"""Return the value of the sensor from coordinator data."""
home_data = self._get_home_data()
if home_data is None:
return None
return cast(
StateType,
getattr(home_data, self.entity_description.key, None),
)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self._tibber_home.rt_subscription_running
def native_unit_of_measurement(self) -> str | None:
"""Return the unit from coordinator data for monetary sensors."""
if self.entity_description.key == "current_price":
home_data = self._get_home_data()
if home_data is None:
return None
return home_data.price_unit
@callback
def _handle_coordinator_update(self) -> None:
if not (live_measurement := self.coordinator.get_live_measurement()):
return
state = live_measurement.get(self.entity_description.key)
if state is None:
return
if self.entity_description.key in (
"accumulatedConsumption",
"accumulatedProduction",
):
# Value is reset to 0 at midnight, but not always strictly increasing
# due to hourly corrections.
# If device is offline, last_reset should be updated when it comes
# back online if the value has decreased
ts_local = dt_util.parse_datetime(live_measurement["timestamp"])
if ts_local is not None:
if self.last_reset is None or (
# native_value is float
state < 0.5 * self.native_value # type: ignore[operator]
and (
ts_local.hour == 0
or (ts_local - self.last_reset) > timedelta(hours=24)
)
):
self._attr_last_reset = dt_util.as_utc(
ts_local.replace(hour=0, minute=0, second=0, microsecond=0)
)
if self.entity_description.key == "powerFactor":
state *= 100.0
self._attr_native_value = state
self.async_write_ha_state()
if self.entity_description.device_class == SensorDeviceClass.MONETARY:
home_data = self._get_home_data()
if home_data is None:
return None
if self.entity_description.key in {
"max_price",
"avg_price",
"min_price",
"off_peak_1",
"peak",
"off_peak_2",
}:
return home_data.price_unit
return home_data.currency
desc = cast(SensorEntityDescription, self.entity_description)
return desc.native_unit_of_measurement
class TibberRtEntityCreator:
@@ -985,48 +894,3 @@ class TibberRtEntityCreator:
self._added_sensors.add(sensor_description.key)
if new_entities:
self._async_add_entities(new_entities)
class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-class-module
"""Handle Tibber realtime data."""
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
add_sensor_callback: Callable[[TibberRtDataCoordinator, Any], None],
tibber_home: TibberHome,
) -> None:
"""Initialize the data handler."""
self._add_sensor_callback = add_sensor_callback
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=tibber_home.info["viewer"]["home"]["address"].get(
"address1", "Tibber"
),
)
self._async_remove_device_updates_handler = self.async_add_listener(
self._data_updated
)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop)
@callback
def _handle_ha_stop(self, _event: Event) -> None:
"""Handle Home Assistant stopping."""
self._async_remove_device_updates_handler()
@callback
def _data_updated(self) -> None:
"""Triggered when data is updated."""
if live_measurement := self.get_live_measurement():
self._add_sensor_callback(self, live_measurement)
def get_live_measurement(self) -> Any:
"""Get live measurement data."""
if errors := self.data.get("errors"):
_LOGGER.error(errors[0])
return None
return self.data.get("data", {}).get("liveMeasurement")

View File

@@ -43,6 +43,9 @@
"average_power": {
"name": "Average power"
},
"avg_price": {
"name": "Average price today"
},
"cellular_rssi": {
"name": "Cellular signal strength"
},
@@ -136,6 +139,9 @@
"grid_phase_count": {
"name": "Number of grid phases"
},
"intraday_price_ranking": {
"name": "Intraday price ranking"
},
"last_meter_consumption": {
"name": "Last meter consumption"
},
@@ -145,15 +151,30 @@
"max_power": {
"name": "Max power"
},
"max_price": {
"name": "Max price today"
},
"min_power": {
"name": "Min power"
},
"min_price": {
"name": "Min price today"
},
"month_cons": {
"name": "Monthly net consumption"
},
"month_cost": {
"name": "Monthly cost"
},
"off_peak_1": {
"name": "Off-peak 1 average"
},
"off_peak_2": {
"name": "Off-peak 2 average"
},
"peak": {
"name": "Peak average"
},
"peak_hour": {
"name": "Monthly peak hour consumption"
},