1
mirror of https://github.com/home-assistant/core synced 2024-09-03 08:14:07 +02:00
ha-core/homeassistant/components/tibber/sensor.py

643 lines
23 KiB
Python

"""Support for Tibber sensors."""
from __future__ import annotations
import asyncio
from datetime import timedelta
import logging
from random import randrange
import aiohttp
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import StatisticData, StatisticMetaData
from homeassistant.components.recorder.statistics import (
async_add_external_statistics,
get_last_statistics,
statistics_during_period,
)
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ELECTRIC_CURRENT_AMPERE,
ELECTRIC_POTENTIAL_VOLT,
ENERGY_KILO_WATT_HOUR,
EVENT_HOMEASSISTANT_STOP,
PERCENTAGE,
POWER_WATT,
SIGNAL_STRENGTH_DECIBELS,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.device_registry import async_get as async_get_dev_reg
from homeassistant.helpers.entity import DeviceInfo, EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity_registry import async_get as async_get_entity_reg
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from homeassistant.util import Throttle, dt as dt_util
from .const import DOMAIN as TIBBER_DOMAIN, MANUFACTURER
_LOGGER = logging.getLogger(__name__)
ICON = "mdi:currency-usd"
SCAN_INTERVAL = timedelta(minutes=1)
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5)
PARALLEL_UPDATES = 0
RT_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="averagePower",
name="average power",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=POWER_WATT,
),
SensorEntityDescription(
key="power",
name="power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=POWER_WATT,
),
SensorEntityDescription(
key="powerProduction",
name="power production",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=POWER_WATT,
),
SensorEntityDescription(
key="minPower",
name="min power",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=POWER_WATT,
),
SensorEntityDescription(
key="maxPower",
name="max power",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=POWER_WATT,
),
SensorEntityDescription(
key="accumulatedConsumption",
name="accumulated consumption",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=ENERGY_KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL,
),
SensorEntityDescription(
key="accumulatedConsumptionLastHour",
name="accumulated consumption current hour",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=ENERGY_KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
),
SensorEntityDescription(
key="estimatedHourConsumption",
name="Estimated consumption current hour",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=ENERGY_KILO_WATT_HOUR,
),
SensorEntityDescription(
key="accumulatedProduction",
name="accumulated production",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=ENERGY_KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL,
),
SensorEntityDescription(
key="accumulatedProductionLastHour",
name="accumulated production current hour",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=ENERGY_KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
),
SensorEntityDescription(
key="lastMeterConsumption",
name="last meter consumption",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=ENERGY_KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
),
SensorEntityDescription(
key="lastMeterProduction",
name="last meter production",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=ENERGY_KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
),
SensorEntityDescription(
key="voltagePhase1",
name="voltage phase1",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="voltagePhase2",
name="voltage phase2",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="voltagePhase3",
name="voltage phase3",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="currentL1",
name="current L1",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="currentL2",
name="current L2",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="currentL3",
name="current L3",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="signalStrength",
name="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",
name="accumulated reward",
device_class=SensorDeviceClass.MONETARY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="accumulatedCost",
name="accumulated cost",
device_class=SensorDeviceClass.MONETARY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="powerFactor",
name="power factor",
device_class=SensorDeviceClass.POWER_FACTOR,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
)
SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="month_cost",
name="Monthly cost",
device_class=SensorDeviceClass.MONETARY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="peak_hour",
name="Monthly peak hour consumption",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=ENERGY_KILO_WATT_HOUR,
),
SensorEntityDescription(
key="peak_hour_time",
name="Time of max hour consumption",
device_class=SensorDeviceClass.TIMESTAMP,
),
SensorEntityDescription(
key="month_cons",
name="Monthly net consumption",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=ENERGY_KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
),
)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the Tibber sensor."""
tibber_connection = hass.data[TIBBER_DOMAIN]
entity_registry = async_get_entity_reg(hass)
device_registry = async_get_dev_reg(hass)
coordinator: TibberDataCoordinator | None = None
entities: list[TibberSensor] = []
for home in tibber_connection.get_homes(only_active=False):
try:
await home.update_info()
except asyncio.TimeoutError as err:
_LOGGER.error("Timeout connecting to Tibber home: %s ", err)
raise PlatformNotReady() from err
except 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, tibber_connection)
for entity_description in SENSORS:
entities.append(TibberDataSensor(home, coordinator, entity_description))
if home.has_real_time_consumption:
await home.rt_subscribe(
TibberRtDataCoordinator(
async_add_entities, home, hass
).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", TIBBER_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({(TIBBER_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={(TIBBER_DOMAIN, home.home_id)}
)
async_add_entities(entities, True)
class TibberSensor(SensorEntity):
"""Representation of a generic Tibber sensor."""
def __init__(self, *args, tibber_home, **kwargs):
"""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 = None
self._model = None
@property
def device_info(self):
"""Return the device_info of the device."""
device_info = DeviceInfo(
identifiers={(TIBBER_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."""
def __init__(self, tibber_home):
"""Initialize the sensor."""
super().__init__(tibber_home=tibber_home)
self._last_updated = None
self._spread_load_constant = randrange(5000)
self._attr_available = False
self._attr_extra_state_attributes = {
"app_nickname": None,
"grid_company": None,
"estimated_annual_consumption": None,
"price_level": None,
"max_price": None,
"avg_price": None,
"min_price": None,
"off_peak_1": None,
"peak": None,
"off_peak_2": None,
}
self._attr_icon = ICON
self._attr_name = f"Electricity price {self._home_name}"
self._attr_unique_id = self._tibber_home.home_id
self._model = "Price Sensor"
self._device_name = self._home_name
async def async_update(self):
"""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()
< 5 * 3600 + self._spread_load_constant
or not self.available
):
_LOGGER.debug("Asking for new data")
await self._fetch_data()
elif (
self._tibber_home.current_price_total
and self._last_updated
and self._last_updated.hour == now.hour
and self._tibber_home.last_data_timestamp
):
return
res = self._tibber_home.current_price_data()
self._attr_native_value, price_level, self._last_updated = res
self._attr_extra_state_attributes["price_level"] = price_level
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):
_LOGGER.debug("Fetching data")
try:
await self._tibber_home.update_info_and_price_info()
except (asyncio.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,
coordinator: TibberDataCoordinator,
entity_description: SensorEntityDescription,
):
"""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}"
)
self._attr_name = f"{entity_description.name} {self._home_name}"
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):
"""Return the value of the sensor."""
return getattr(self._tibber_home, self.entity_description.key)
class TibberSensorRT(TibberSensor, CoordinatorEntity["TibberRtDataCoordinator"]):
"""Representation of a Tibber sensor for real time consumption."""
def __init__(
self,
tibber_home,
description: SensorEntityDescription,
initial_state,
coordinator: TibberRtDataCoordinator,
):
"""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_name = f"{description.name} {self._home_name}"
self._attr_native_value = initial_state
self._attr_unique_id = f"{self._tibber_home.home_id}_rt_{description.name}"
if description.key in ("accumulatedCost", "accumulatedReward"):
self._attr_native_unit_of_measurement = tibber_home.currency
@property
def available(self):
"""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 (
state < 0.5 * self.native_value # type: ignore[operator] # native_value is float
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 TibberRtDataCoordinator(DataUpdateCoordinator):
"""Handle Tibber realtime data."""
def __init__(self, async_add_entities, tibber_home, hass):
"""Initialize the data handler."""
self._async_add_entities = async_add_entities
self._tibber_home = tibber_home
self.hass = hass
self._added_sensors = set()
super().__init__(
hass,
_LOGGER,
name=tibber_home.info["viewer"]["home"]["address"].get(
"address1", "Tibber"
),
)
self._async_remove_device_updates_handler = self.async_add_listener(
self._add_sensors
)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop)
@callback
def _handle_ha_stop(self, _event) -> None:
"""Handle Home Assistant stopping."""
self._async_remove_device_updates_handler()
@callback
def _add_sensors(self):
"""Add sensor."""
if not (live_measurement := self.get_live_measurement()):
return
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
entity = TibberSensorRT(
self._tibber_home,
sensor_description,
state,
self,
)
new_entities.append(entity)
self._added_sensors.add(sensor_description.key)
if new_entities:
self._async_add_entities(new_entities)
def get_live_measurement(self):
"""Get live measurement data."""
if errors := self.data.get("errors"):
_LOGGER.error(errors[0])
return None
return self.data.get("data", {}).get("liveMeasurement")
class TibberDataCoordinator(DataUpdateCoordinator):
"""Handle Tibber data and insert statistics."""
def __init__(self, hass, tibber_connection):
"""Initialize the data handler."""
super().__init__(
hass,
_LOGGER,
name=f"Tibber {tibber_connection.name}",
update_interval=timedelta(minutes=20),
)
self._tibber_connection = tibber_connection
async def _async_update_data(self):
"""Update data via API."""
await self._tibber_connection.fetch_consumption_data_active_homes()
await self._insert_statistics()
async def _insert_statistics(self):
"""Insert Tibber statistics."""
for home in self._tibber_connection.get_homes():
if not home.hourly_consumption_data:
continue
for sensor_type in (
"consumption",
"totalCost",
):
statistic_id = (
f"{TIBBER_DOMAIN}:energy_"
f"{sensor_type.lower()}_"
f"{home.home_id.replace('-', '')}"
)
last_stats = await get_instance(self.hass).async_add_executor_job(
get_last_statistics, self.hass, 1, statistic_id, True
)
if not last_stats:
# First time we insert 5 years of data (if available)
hourly_consumption_data = await home.get_historic_data(5 * 365 * 24)
_sum = 0
last_stats_time = None
else:
# hourly_consumption_data contains the last 30 days
# of consumption data.
# We update the statistics with the last 30 days
# of data to handle corrections in the data.
hourly_consumption_data = home.hourly_consumption_data
start = dt_util.parse_datetime(
hourly_consumption_data[0]["from"]
) - timedelta(hours=1)
stat = await get_instance(self.hass).async_add_executor_job(
statistics_during_period,
self.hass,
start,
None,
[statistic_id],
"hour",
True,
)
_sum = stat[statistic_id][0]["sum"]
last_stats_time = stat[statistic_id][0]["start"]
statistics = []
for data in hourly_consumption_data:
if data.get(sensor_type) is None:
continue
start = dt_util.parse_datetime(data["from"])
if last_stats_time is not None and start <= last_stats_time:
continue
_sum += data[sensor_type]
statistics.append(
StatisticData(
start=start,
state=data[sensor_type],
sum=_sum,
)
)
if sensor_type == "consumption":
unit = ENERGY_KILO_WATT_HOUR
else:
unit = home.currency
metadata = StatisticMetaData(
has_mean=False,
has_sum=True,
name=f"{home.name} {sensor_type}",
source=TIBBER_DOMAIN,
statistic_id=statistic_id,
unit_of_measurement=unit,
)
async_add_external_statistics(self.hass, metadata, statistics)