"""Support for Tibber sensors.""" 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 import aiohttp from tibber import FatalHttpExceptionError, RetryableHttpExceptionError, TibberHome from tibber.data_api import TibberDevice from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS, EntityCategory, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, UnitOfLength, UnitOfPower, ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import device_registry as dr, 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 .const import ( API_TYPE_DATA_API, API_TYPE_GRAPHQL, CONF_API_TYPE, DOMAIN, MANUFACTURER, ) from .coordinator import TibberDataAPICoordinator, TibberDataCoordinator _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", "accumulated_production_last_hour": "accumulated production current hour", "current_l1": "current L1", "current_l2": "current L2", "current_l3": "current L3", "estimated_hour_consumption": "Estimated consumption current hour", } RT_SENSORS_UNIQUE_ID_MIGRATION_SIMPLE = { # simple migration can be done by replacing " " with "_" "accumulated_consumption", "accumulated_cost", "accumulated_production", "accumulated_reward", "average_power", "last_meter_consumption", "last_meter_production", "max_power", "min_power", "power_factor", "power_production", "signal_strength", "voltage_phase1", "voltage_phase2", "voltage_phase3", } RT_SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="averagePower", translation_key="average_power", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, ), SensorEntityDescription( key="power", translation_key="power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, ), SensorEntityDescription( key="powerProduction", translation_key="power_production", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, ), SensorEntityDescription( key="minPower", translation_key="min_power", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, ), SensorEntityDescription( key="maxPower", translation_key="max_power", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, ), SensorEntityDescription( key="accumulatedConsumption", translation_key="accumulated_consumption", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="accumulatedConsumptionLastHour", translation_key="accumulated_consumption_last_hour", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="estimatedHourConsumption", translation_key="estimated_hour_consumption", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), SensorEntityDescription( key="accumulatedProduction", translation_key="accumulated_production", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="accumulatedProductionLastHour", translation_key="accumulated_production_last_hour", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="lastMeterConsumption", translation_key="last_meter_consumption", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="lastMeterProduction", translation_key="last_meter_production", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="voltagePhase1", translation_key="voltage_phase1", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="voltagePhase2", translation_key="voltage_phase2", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="voltagePhase3", translation_key="voltage_phase3", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="currentL1", translation_key="current_l1", device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="currentL2", translation_key="current_l2", device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="currentL3", translation_key="current_l3", device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="signalStrength", translation_key="signal_strength", device_class=SensorDeviceClass.SIGNAL_STRENGTH, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( key="accumulatedReward", translation_key="accumulated_reward", device_class=SensorDeviceClass.MONETARY, state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="accumulatedCost", translation_key="accumulated_cost", device_class=SensorDeviceClass.MONETARY, state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="powerFactor", translation_key="power_factor", device_class=SensorDeviceClass.POWER_FACTOR, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), ) SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="month_cost", translation_key="month_cost", device_class=SensorDeviceClass.MONETARY, ), SensorEntityDescription( key="peak_hour", translation_key="peak_hour", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), SensorEntityDescription( key="peak_hour_time", translation_key="peak_hour_time", device_class=SensorDeviceClass.TIMESTAMP, ), SensorEntityDescription( key="month_cons", translation_key="month_cons", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), ) DATA_API_SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="storage.stateOfCharge", translation_key="storage_state_of_charge", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="storage.targetStateOfCharge", translation_key="storage_target_state_of_charge", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="connector.status", translation_key="connector_status", device_class=SensorDeviceClass.ENUM, options=["connected", "disconnected", "unknown"], ), SensorEntityDescription( key="charging.status", translation_key="charging_status", device_class=SensorDeviceClass.ENUM, options=["charging", "idle", "unknown"], ), SensorEntityDescription( key="range.remaining", translation_key="range_remaining", device_class=SensorDeviceClass.DISTANCE, native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, ), SensorEntityDescription( key="charging.current.max", translation_key="charging_current_max", device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="charging.current.offlineFallback", translation_key="charging_current_offline_fallback", device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, ), ) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tibber sensor.""" if entry.data.get(CONF_API_TYPE, API_TYPE_GRAPHQL) == API_TYPE_DATA_API: await _async_setup_data_api_sensors(hass, entry, async_add_entities) return tibber_connection = hass.data[DOMAIN][API_TYPE_GRAPHQL].tibber entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) coordinator: TibberDataCoordinator | None = None entities: list[TibberSensor] = [] for home in tibber_connection.get_homes(only_active=False): try: await home.update_info() except TimeoutError as err: _LOGGER.error("Timeout connecting to Tibber home: %s ", err) raise PlatformNotReady from err except ( RetryableHttpExceptionError, FatalHttpExceptionError, aiohttp.ClientError, ) as err: _LOGGER.error("Error connecting to Tibber home: %s ", err) 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 ) if home.has_real_time_consumption: entity_creator = TibberRtEntityCreator( async_add_entities, home, entity_registry ) await home.rt_subscribe( TibberRtDataCoordinator( hass, entry, entity_creator.add_sensors, home, ).async_set_updated_data ) # migrate old_id = home.info["viewer"]["home"]["meteringPointData"]["consumptionEan"] if old_id is None: continue # migrate to new device ids old_entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, old_id) if old_entity_id is not None: entity_registry.async_update_entity( old_entity_id, new_unique_id=home.home_id ) # migrate to new device ids device_entry = device_registry.async_get_device(identifiers={(DOMAIN, old_id)}) if device_entry and entry.entry_id in device_entry.config_entries: device_registry.async_update_device( device_entry.id, new_identifiers={(DOMAIN, home.home_id)} ) async_add_entities(entities, True) async def _async_setup_data_api_sensors( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors backed by the Tibber Data API.""" domain_data = hass.data.get(DOMAIN, {}) runtime = domain_data[API_TYPE_DATA_API] coordinator = TibberDataAPICoordinator(hass, entry, runtime) await coordinator.async_config_entry_first_refresh() entities: list[TibberDataAPISensor] = [] api_sensors = {sensor.key: sensor for sensor in DATA_API_SENSORS} for device in coordinator.data.values(): for sensor in device.sensors: description: SensorEntityDescription | None = api_sensors.get(sensor.id) if description is None: _LOGGER.error("Sensor %s not found", sensor.id) continue entities.append( TibberDataAPISensor( coordinator, device, description, sensor.description ) ) async_add_entities(entities) class TibberDataAPISensor(CoordinatorEntity[TibberDataAPICoordinator], SensorEntity): """Representation of a Tibber Data API capability sensor.""" _attr_has_entity_name = True def __init__( self, coordinator: TibberDataAPICoordinator, device: TibberDevice, entity_description: SensorEntityDescription, name: str, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self._device: TibberDevice = device self.entity_description = entity_description self._attr_name = name self._attr_unique_id = f"{device.external_id}_{self.entity_description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device.external_id)}, name=device.name, manufacturer=device.brand, model=device.model, ) @property def native_value( self, ) -> StateType: """Return the value reported by the device.""" device = self.coordinator.data.get(self._device.id) if device is None: _LOGGER.error("Device %s not found", self._device.id) return None for sensor in self._device.sensors: if sensor.id == self.entity_description.key: return sensor.value return None @property def available(self) -> bool: """Return whether the sensor is available.""" device = self.coordinator.data.get(self._device.id) if device is None: return False return self.native_value is not 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.""" def __init__( self, tibber_home: TibberHome, coordinator: TibberDataCoordinator, entity_description: SensorEntityDescription, ) -> 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 self._device_name = self._home_name @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 @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.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() class TibberRtEntityCreator: """Create realtime Tibber entities.""" def __init__( self, async_add_entities: AddConfigEntryEntitiesCallback, tibber_home: TibberHome, entity_registry: er.EntityRegistry, ) -> None: """Initialize the data handler.""" self._async_add_entities = async_add_entities self._tibber_home = tibber_home self._added_sensors: set[str] = set() self._entity_registry = entity_registry @callback def _migrate_unique_id(self, sensor_description: SensorEntityDescription) -> None: """Migrate unique id if needed.""" home_id = self._tibber_home.home_id translation_key = sensor_description.translation_key description_key = sensor_description.key entity_id: str | None = None if translation_key in RT_SENSORS_UNIQUE_ID_MIGRATION_SIMPLE: entity_id = self._entity_registry.async_get_entity_id( "sensor", DOMAIN, f"{home_id}_rt_{translation_key.replace('_', ' ')}", ) elif translation_key in RT_SENSORS_UNIQUE_ID_MIGRATION: entity_id = self._entity_registry.async_get_entity_id( "sensor", DOMAIN, f"{home_id}_rt_{RT_SENSORS_UNIQUE_ID_MIGRATION[translation_key]}", ) elif translation_key != description_key: entity_id = self._entity_registry.async_get_entity_id( "sensor", DOMAIN, f"{home_id}_rt_{translation_key}", ) if entity_id is None: return new_unique_id = f"{home_id}_rt_{description_key}" _LOGGER.debug( "Migrating unique id for %s to %s", entity_id, new_unique_id, ) try: self._entity_registry.async_update_entity( entity_id, new_unique_id=new_unique_id ) except ValueError as err: _LOGGER.error(err) @callback def add_sensors( self, coordinator: TibberRtDataCoordinator, live_measurement: Any ) -> None: """Add sensor.""" new_entities = [] for sensor_description in RT_SENSORS: if sensor_description.key in self._added_sensors: continue state = live_measurement.get(sensor_description.key) if state is None: continue self._migrate_unique_id(sensor_description) entity = TibberSensorRT( self._tibber_home, sensor_description, state, coordinator, ) new_entities.append(entity) 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")