Files
homeassistant/custom_components/open_meteo_solar_forecast/sensor.py
2024-12-18 13:26:06 +01:00

340 lines
13 KiB
Python

"""Support for the Open-Meteo Solar Forecast sensor service."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any
from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
)
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfEnergy, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_utc_time_change
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from open_meteo_solar_forecast.models import Estimate
from .const import ATTR_WATTS, ATTR_WH_PERIOD, DOMAIN
from .coordinator import OpenMeteoSolarForecastDataUpdateCoordinator
@dataclass(frozen=True)
class OpenMeteoSolarForecastSensorEntityDescription(SensorEntityDescription):
"""Describes a Forecast.Solar Sensor."""
state: Callable[[Estimate], Any] | None = None
SENSORS: tuple[OpenMeteoSolarForecastSensorEntityDescription, ...] = (
OpenMeteoSolarForecastSensorEntityDescription(
key="energy_production_today",
translation_key="energy_production_today",
state=lambda estimate: estimate.energy_production_today,
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=1,
),
OpenMeteoSolarForecastSensorEntityDescription(
key="energy_production_today_remaining",
translation_key="energy_production_today_remaining",
state=lambda estimate: estimate.energy_production_today_remaining,
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=1,
),
OpenMeteoSolarForecastSensorEntityDescription(
key="energy_production_tomorrow",
translation_key="energy_production_tomorrow",
state=lambda estimate: estimate.energy_production_tomorrow,
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=1,
),
OpenMeteoSolarForecastSensorEntityDescription(
key="energy_production_d2",
translation_key="energy_production_d2",
state=lambda estimate: estimate.day_production(
estimate.now().date() + timedelta(days=2)
),
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=1,
),
OpenMeteoSolarForecastSensorEntityDescription(
key="energy_production_d3",
translation_key="energy_production_d3",
state=lambda estimate: estimate.day_production(
estimate.now().date() + timedelta(days=3)
),
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=1,
),
OpenMeteoSolarForecastSensorEntityDescription(
key="energy_production_d4",
translation_key="energy_production_d4",
state=lambda estimate: estimate.day_production(
estimate.now().date() + timedelta(days=4)
),
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=1,
),
OpenMeteoSolarForecastSensorEntityDescription(
key="energy_production_d5",
translation_key="energy_production_d5",
state=lambda estimate: estimate.day_production(
estimate.now().date() + timedelta(days=5)
),
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=1,
),
OpenMeteoSolarForecastSensorEntityDescription(
key="energy_production_d6",
translation_key="energy_production_d6",
state=lambda estimate: estimate.day_production(
estimate.now().date() + timedelta(days=6)
),
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=1,
),
OpenMeteoSolarForecastSensorEntityDescription(
key="energy_production_d7",
translation_key="energy_production_d7",
state=lambda estimate: estimate.day_production(
estimate.now().date() + timedelta(days=7)
),
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=1,
),
OpenMeteoSolarForecastSensorEntityDescription(
key="power_highest_peak_time_today",
translation_key="power_highest_peak_time_today",
device_class=SensorDeviceClass.TIMESTAMP,
),
OpenMeteoSolarForecastSensorEntityDescription(
key="power_highest_peak_time_tomorrow",
translation_key="power_highest_peak_time_tomorrow",
device_class=SensorDeviceClass.TIMESTAMP,
),
OpenMeteoSolarForecastSensorEntityDescription(
key="power_production_now",
translation_key="power_production_now",
device_class=SensorDeviceClass.POWER,
state=lambda estimate: estimate.power_production_now,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
),
OpenMeteoSolarForecastSensorEntityDescription(
key="power_production_next_15minutes",
translation_key="power_production_next_15minutes",
state=lambda estimate: estimate.power_production_at_time(
estimate.now() + timedelta(minutes=15)
),
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfPower.WATT,
),
OpenMeteoSolarForecastSensorEntityDescription(
key="power_production_next_30minutes",
translation_key="power_production_next_30minutes",
state=lambda estimate: estimate.power_production_at_time(
estimate.now() + timedelta(minutes=30)
),
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfPower.WATT,
),
OpenMeteoSolarForecastSensorEntityDescription(
key="power_production_next_hour",
translation_key="power_production_next_hour",
state=lambda estimate: estimate.power_production_at_time(
estimate.now() + timedelta(hours=1)
),
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfPower.WATT,
),
OpenMeteoSolarForecastSensorEntityDescription(
key="power_production_next_12hours",
translation_key="power_production_next_12hours",
state=lambda estimate: estimate.power_production_at_time(
estimate.now() + timedelta(hours=12)
),
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfPower.WATT,
),
OpenMeteoSolarForecastSensorEntityDescription(
key="power_production_next_24hours",
translation_key="power_production_next_24hours",
state=lambda estimate: estimate.power_production_at_time(
estimate.now() + timedelta(hours=24)
),
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfPower.WATT,
),
OpenMeteoSolarForecastSensorEntityDescription(
key="energy_current_hour",
translation_key="energy_current_hour",
state=lambda estimate: estimate.energy_current_hour,
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=1,
),
OpenMeteoSolarForecastSensorEntityDescription(
key="energy_next_hour",
translation_key="energy_next_hour",
state=lambda estimate: estimate.sum_energy_production(1),
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=1,
),
)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Defer sensor setup to the shared sensor module."""
coordinator: OpenMeteoSolarForecastDataUpdateCoordinator = hass.data[DOMAIN][
entry.entry_id
]
async_add_entities(
OpenMeteoSolarForecastSensorEntity(
entry_id=entry.entry_id,
coordinator=coordinator,
entity_description=entity_description,
)
for entity_description in SENSORS
)
class OpenMeteoSolarForecastSensorEntity(
CoordinatorEntity[OpenMeteoSolarForecastDataUpdateCoordinator], SensorEntity
):
"""Defines a Open-Meteo sensor."""
entity_description: OpenMeteoSolarForecastSensorEntityDescription
_attr_has_entity_name = True
def __init__(
self,
*,
entry_id: str,
coordinator: OpenMeteoSolarForecastDataUpdateCoordinator,
entity_description: OpenMeteoSolarForecastSensorEntityDescription,
) -> None:
"""Initialize Open-Meteo Solar sensor."""
super().__init__(coordinator=coordinator)
self.entity_description = entity_description
self.entity_id = f"{SENSOR_DOMAIN}.{entity_description.key}"
self._attr_unique_id = f"{entry_id}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, entry_id)},
manufacturer="Open-Meteo",
name="Solar production forecast",
configuration_url="https://open-meteo.com",
)
async def _update_callback(self, now: datetime) -> None:
"""Update the entity without fetching data from server.
This is required for the power_production_* sensors to update
as they take data in 15-minute intervals and the update interval
is 30 minutes."""
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
await super().async_added_to_hass()
# Update the state of the sensor every minute without
# fetching new data from the server.
async_track_utc_time_change(
self.hass,
self._update_callback,
second=0,
)
@property
def native_value(self) -> datetime | StateType:
"""Return the state of the sensor."""
if self.entity_description.state is None:
state: StateType | datetime = getattr(
self.coordinator.data, self.entity_description.key
)
else:
state = self.entity_description.state(self.coordinator.data)
return state
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the state attributes."""
if self.entity_description.key.startswith(
"energy_production_d"
) or self.entity_description.key in (
"energy_production_today",
"energy_production_tomorrow",
):
target_date = self.coordinator.data.now().date()
if self.entity_description.key == "energy_production_tomorrow":
target_date += timedelta(days=1)
elif self.entity_description.key.startswith("energy_production_d"):
target_date += timedelta(
days=int(self.entity_description.key[len("energy_production_d") :])
)
elif self.entity_description.key == "energy_production_today":
pass # target_date is already set to today
else:
raise ValueError(
f"Unexpected key {self.entity_description.key} for extra_state_attributes"
)
return {
ATTR_WATTS: {
watt_datetime.isoformat(): watt_value
for watt_datetime, watt_value in self.coordinator.data.watts.items()
if watt_datetime.date() == target_date
},
ATTR_WH_PERIOD: {
wh_datetime.isoformat(): wh_value
for wh_datetime, wh_value in self.coordinator.data.wh_period.items()
if wh_datetime.date() == target_date
},
}
return None