"""Sensors for SolarEdge Optimizers.""" from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from typing import Any from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant, callback 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.update_coordinator import CoordinatorEntity from homeassistant.util import slugify from . import SolarEdgeOptimizerConfigEntry from .api import OptimizerData from .areas import configured_area_mapping from .const import DOMAIN from .coordinator import SolarEdgeOptimizerCoordinator type OptimizerValue = float | None @dataclass(frozen=True, kw_only=True) class SolarEdgeOptimizerSensorDescription(SensorEntityDescription): """Describe an optimizer sensor.""" value_fn: Callable[[OptimizerData], OptimizerValue] SENSOR_DESCRIPTIONS = ( SolarEdgeOptimizerSensorDescription( key="daily_energy", translation_key="daily_energy", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=1, value_fn=lambda optimizer: optimizer.daily_energy_wh, ), SolarEdgeOptimizerSensorDescription( key="current_power", translation_key="current_power", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, value_fn=lambda optimizer: optimizer.current_power_w, ), ) AREA_SENSOR_DESCRIPTIONS = ( SolarEdgeOptimizerSensorDescription( key="daily_energy", translation_key="area_daily_energy", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=1, value_fn=lambda optimizer: optimizer.daily_energy_wh, ), SolarEdgeOptimizerSensorDescription( key="current_power", translation_key="area_current_power", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, value_fn=lambda optimizer: optimizer.current_power_w, ), ) STRING_SENSOR_DESCRIPTIONS = ( SolarEdgeOptimizerSensorDescription( key="daily_energy", translation_key="string_daily_energy", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=1, value_fn=lambda optimizer: optimizer.daily_energy_wh, ), SolarEdgeOptimizerSensorDescription( key="current_power", translation_key="string_current_power", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, value_fn=lambda optimizer: optimizer.current_power_w, ), ) async def async_setup_entry( hass: HomeAssistant, entry: SolarEdgeOptimizerConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up optimizer sensors from a config entry.""" coordinator = entry.runtime_data area_mapping = configured_area_mapping(coordinator.data.site_id, entry.options) area_entities = [ SolarEdgeAreaSensor(coordinator, area, optimizer_ids, description) for area, optimizer_ids in area_mapping.items() for description in AREA_SENSOR_DESCRIPTIONS ] _remove_stale_area_entities(hass, entry, area_entities) async_add_entities(area_entities) known_serials: set[str] = set() known_string_ids: set[str] = set() @callback def async_add_new_entities() -> None: entities: list[SensorEntity] = [] new_optimizers = [ optimizer for optimizer in coordinator.data.optimizers if optimizer.serial not in known_serials ] known_serials.update(optimizer.serial for optimizer in new_optimizers) entities.extend( SolarEdgeOptimizerSensor(coordinator, optimizer.serial, description) for optimizer in new_optimizers for description in SENSOR_DESCRIPTIONS ) new_string_ids = sorted( { optimizer.string_id for optimizer in coordinator.data.optimizers if optimizer.string_id is not None } - known_string_ids ) known_string_ids.update(new_string_ids) entities.extend( SolarEdgeStringSensor(coordinator, string_id, description) for string_id in new_string_ids for description in STRING_SENSOR_DESCRIPTIONS ) if entities: async_add_entities(entities) _remove_stale_string_entities(hass, entry) async_add_new_entities() entry.async_on_unload(coordinator.async_add_listener(async_add_new_entities)) def _remove_stale_area_entities( hass: HomeAssistant, entry: SolarEdgeOptimizerConfigEntry, area_entities: list[SolarEdgeAreaSensor], ) -> None: """Remove registry entries for areas no longer configured.""" entity_registry = er.async_get(hass) desired_unique_ids = {entity.unique_id for entity in area_entities} area_prefix = f"{entry.runtime_data.data.site_id}_area_" for entity in er.async_entries_for_config_entry(entity_registry, entry.entry_id): if ( entity.unique_id.startswith(area_prefix) and entity.unique_id not in desired_unique_ids ): entity_registry.async_remove(entity.entity_id) def _remove_stale_string_entities( hass: HomeAssistant, entry: SolarEdgeOptimizerConfigEntry, ) -> None: """Remove registry entries for strings no longer returned by the App.""" entity_registry = er.async_get(hass) site_id = entry.runtime_data.data.site_id desired_unique_ids = { f"{site_id}_string_{slugify(string_id)}_{description.key}" for string_id in { optimizer.string_id for optimizer in entry.runtime_data.data.optimizers if optimizer.string_id is not None } for description in STRING_SENSOR_DESCRIPTIONS } string_prefix = f"{site_id}_string_" for entity in er.async_entries_for_config_entry(entity_registry, entry.entry_id): if ( entity.unique_id.startswith(string_prefix) and entity.unique_id not in desired_unique_ids ): entity_registry.async_remove(entity.entity_id) def _site_device_info(coordinator: SolarEdgeOptimizerCoordinator) -> DeviceInfo: """Return the shared SolarEdge site device information.""" site_id = coordinator.data.site_id return DeviceInfo( identifiers={(DOMAIN, site_id)}, manufacturer="SolarEdge", model="Optimizer Monitoring Site", name=f"SolarEdge Site {site_id}", ) class SolarEdgeOptimizerSensor( CoordinatorEntity[SolarEdgeOptimizerCoordinator], SensorEntity ): """Represent one optimizer reading.""" _attr_has_entity_name = True def __init__( self, coordinator: SolarEdgeOptimizerCoordinator, serial: str, description: SolarEdgeOptimizerSensorDescription, ) -> None: """Initialize a sensor.""" super().__init__(coordinator) self.entity_description = description self._serial = serial self._attr_unique_id = f"{coordinator.data.site_id}_{serial}_{description.key}" optimizer = coordinator.data.optimizers_by_serial[serial] self._attr_translation_placeholders = { "optimizer_id": optimizer.optimizer_id or serial } @property def _optimizer(self) -> OptimizerData | None: return self.coordinator.data.optimizers_by_serial.get(self._serial) @property def native_value(self) -> OptimizerValue: """Return the current sensor value.""" if (optimizer := self._optimizer) is None: return None return self.entity_description.value_fn(optimizer) @property def available(self) -> bool: """Return whether this individual reading is available.""" return super().available and self.native_value is not None @property def device_info(self) -> DeviceInfo: """Return the shared SolarEdge site device information.""" return _site_device_info(self.coordinator) @property def extra_state_attributes(self) -> dict[str, Any]: """Return stable optimizer metadata.""" if (optimizer := self._optimizer) is None: return {} return { "optimizer_id": optimizer.optimizer_id, "string_id": optimizer.string_id, "inverter_id": optimizer.inverter_id, "last_measurement": optimizer.last_measurement, } class SolarEdgeAreaSensor( CoordinatorEntity[SolarEdgeOptimizerCoordinator], SensorEntity ): """Represent the sum of one optimizer area.""" _attr_has_entity_name = True def __init__( self, coordinator: SolarEdgeOptimizerCoordinator, area: str, optimizer_ids: tuple[str, ...], description: SolarEdgeOptimizerSensorDescription, ) -> None: """Initialize an area sum sensor.""" super().__init__(coordinator) self.entity_description = description self._area = area self._optimizer_ids = optimizer_ids self._attr_unique_id = ( f"{coordinator.data.site_id}_area_{slugify(area)}_{description.key}" ) self._attr_translation_placeholders = {"area": area} @property def _values(self) -> tuple[OptimizerValue, ...]: optimizers_by_id = self.coordinator.data.optimizers_by_id return tuple( self.entity_description.value_fn(optimizer) if (optimizer := optimizers_by_id.get(optimizer_id)) is not None else None for optimizer_id in self._optimizer_ids ) @property def native_value(self) -> OptimizerValue: """Return the sum when every configured optimizer value is available.""" values = self._values if not values or any(value is None for value in values): return None return sum(value for value in values if value is not None) @property def available(self) -> bool: """Return whether every optimizer in the area is available.""" return super().available and self.native_value is not None @property def device_info(self) -> DeviceInfo: """Return the shared SolarEdge site device information.""" return _site_device_info(self.coordinator) @property def extra_state_attributes(self) -> dict[str, Any]: """Return area membership and completeness information.""" values = self._values return { "area": self._area, "optimizer_ids": list(self._optimizer_ids), "optimizer_count": len(self._optimizer_ids), "available_optimizer_count": sum(value is not None for value in values), } class SolarEdgeStringSensor( CoordinatorEntity[SolarEdgeOptimizerCoordinator], SensorEntity ): """Represent the sum of one optimizer string.""" _attr_has_entity_name = True def __init__( self, coordinator: SolarEdgeOptimizerCoordinator, string_id: str, description: SolarEdgeOptimizerSensorDescription, ) -> None: """Initialize a string sum sensor.""" super().__init__(coordinator) self.entity_description = description self._string_id = string_id self._attr_unique_id = ( f"{coordinator.data.site_id}_string_{slugify(string_id)}_{description.key}" ) self._attr_translation_placeholders = {"string_id": string_id} @property def _optimizers(self) -> tuple[OptimizerData, ...]: return tuple( optimizer for optimizer in self.coordinator.data.optimizers if optimizer.string_id == self._string_id ) @property def _values(self) -> tuple[OptimizerValue, ...]: return tuple( self.entity_description.value_fn(optimizer) for optimizer in self._optimizers ) @property def native_value(self) -> OptimizerValue: """Return the sum when every optimizer value is available.""" values = self._values if not values or any(value is None for value in values): return None return sum(value for value in values if value is not None) @property def available(self) -> bool: """Return whether every optimizer in the string is available.""" return super().available and self.native_value is not None @property def device_info(self) -> DeviceInfo: """Return the shared SolarEdge site device information.""" return _site_device_info(self.coordinator) @property def extra_state_attributes(self) -> dict[str, Any]: """Return string membership and completeness information.""" optimizers = self._optimizers values = self._values return { "string_id": self._string_id, "optimizer_ids": [ optimizer.optimizer_id for optimizer in optimizers if optimizer.optimizer_id is not None ], "optimizer_count": len(optimizers), "available_optimizer_count": sum(value is not None for value in values), }