from __future__ import annotations from collections.abc import Callable from datetime import datetime, time, timedelta from decimal import Decimal import logging from typing import Any from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorStateClass, ) from homeassistant.const import ( CONF_NAME, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, UnitOfEnergy, UnitOfPower, ) from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util from homeassistant.util.unit_conversion import EnergyConverter import voluptuous as vol from custom_components.powercalc.common import SourceEntity from custom_components.powercalc.const import ( CONF_DAILY_FIXED_ENERGY, CONF_ENERGY_SENSOR_CATEGORY, CONF_ENERGY_SENSOR_PRECISION, CONF_ENERGY_SENSOR_UNIT_PREFIX, CONF_FIXED, CONF_ON_TIME, CONF_POWER, CONF_START_TIME, CONF_UPDATE_FREQUENCY, CONF_VALUE, DEFAULT_ENERGY_SENSOR_PRECISION, UnitPrefix, ) from custom_components.powercalc.unit import ENERGY_UNIT_PREFIX_MAPPING, evaluate_to_decimal, parse_decimal from .abstract import generate_energy_sensor_entity_id, generate_energy_sensor_name from .energy import EnergySensor from .power import VirtualPowerSensor, create_virtual_power_sensor ENERGY_ICON = "mdi:lightning-bolt" ENTITY_ID_FORMAT = SENSOR_DOMAIN + ".{}" DEFAULT_DAILY_UPDATE_FREQUENCY = 1800 DAILY_FIXED_ENERGY_SCHEMA = vol.Schema( { vol.Required(CONF_VALUE): vol.Any(vol.Coerce(float), cv.template), vol.Optional( CONF_UNIT_OF_MEASUREMENT, default=UnitOfEnergy.KILO_WATT_HOUR, ): vol.In( [UnitOfEnergy.KILO_WATT_HOUR, UnitOfPower.WATT], ), vol.Optional(CONF_ON_TIME, default=timedelta(days=1)): cv.time_period, vol.Optional(CONF_START_TIME): cv.time, vol.Optional( CONF_UPDATE_FREQUENCY, default=DEFAULT_DAILY_UPDATE_FREQUENCY, ): vol.Coerce(int), }, ) _LOGGER = logging.getLogger(__name__) def create_daily_fixed_energy_sensor( hass: HomeAssistant, sensor_config: ConfigType, source_entity: SourceEntity | None = None, ) -> DailyEnergySensor: mode_config: ConfigType = sensor_config.get(CONF_DAILY_FIXED_ENERGY) # type: ignore name = generate_energy_sensor_name( sensor_config, sensor_config.get(CONF_NAME), source_entity, ) unique_id = sensor_config.get(CONF_UNIQUE_ID) or None entity_id = generate_energy_sensor_entity_id( hass, sensor_config, unique_id=unique_id, source_entity=source_entity, ) _LOGGER.debug( "Creating daily_fixed_energy energy sensor (name=%s, entity_id=%s, unique_id=%s)", name, entity_id, unique_id, ) if CONF_ON_TIME in mode_config: on_time = mode_config.get(CONF_ON_TIME) if not isinstance(on_time, timedelta): on_time = timedelta(seconds=on_time) # type: ignore else: on_time = timedelta(days=1) return DailyEnergySensor( hass, name, entity_id, mode_config.get(CONF_VALUE), # type: ignore str(mode_config.get(CONF_UNIT_OF_MEASUREMENT)), int(mode_config.get(CONF_UPDATE_FREQUENCY, DEFAULT_DAILY_UPDATE_FREQUENCY)), sensor_config, on_time=on_time, start_time=mode_config.get(CONF_START_TIME), rounding_digits=int(sensor_config.get(CONF_ENERGY_SENSOR_PRECISION, DEFAULT_ENERGY_SENSOR_PRECISION)), ) async def create_daily_fixed_energy_power_sensor( hass: HomeAssistant, sensor_config: ConfigType, source_entity: SourceEntity, ) -> VirtualPowerSensor | None: mode_config: ConfigType = sensor_config.get(CONF_DAILY_FIXED_ENERGY) # type: ignore if mode_config.get(CONF_ON_TIME) != timedelta(days=1): return None power_value: float = mode_config.get(CONF_VALUE) # type: ignore if mode_config.get(CONF_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR and not isinstance( power_value, Template, ): power_value = power_value * 1000 / 24 power_sensor_config = sensor_config.copy() power_sensor_config[CONF_FIXED] = {CONF_POWER: power_value} unique_id = sensor_config.get(CONF_UNIQUE_ID) if unique_id: power_sensor_config[CONF_UNIQUE_ID] = f"{unique_id}_power" _LOGGER.debug( "Creating daily_fixed_energy power sensor (base_name=%s unique_id=%s)", sensor_config.get(CONF_NAME), unique_id, ) return await create_virtual_power_sensor( hass, power_sensor_config, source_entity, None, ) class DailyEnergySensor(EnergySensor, RestoreEntity, SensorEntity): _attr_device_class = SensorDeviceClass.ENERGY _attr_state_class = SensorStateClass.TOTAL _attr_should_poll = False _attr_icon = ENERGY_ICON def __init__( self, hass: HomeAssistant, name: str, entity_id: str, value: float | Template, user_unit_of_measurement: str, update_frequency: int, sensor_config: dict[str, Any], on_time: timedelta | None = None, start_time: time | None = None, rounding_digits: int = 4, ) -> None: self._hass = hass self._attr_name = name self._state: Decimal = Decimal(0) self._attr_entity_category = sensor_config.get(CONF_ENERGY_SENSOR_CATEGORY) self._value = value self._user_unit_of_measurement = user_unit_of_measurement self._update_frequency = update_frequency self._sensor_config = sensor_config self._on_time = on_time or timedelta(days=1) self._start_time = start_time self._rounding_digits = rounding_digits self._attr_suggested_display_precision = self._rounding_digits self._attr_unique_id = sensor_config.get(CONF_UNIQUE_ID) self.entity_id = entity_id self._last_updated: float = dt_util.utcnow().timestamp() self._last_delta_calculate: float | None = None self.set_native_unit_of_measurement() self._update_timer_removal: Callable[[], None] | None = None def set_native_unit_of_measurement(self) -> None: """Set the native unit of measurement.""" unit_prefix = self._sensor_config.get(CONF_ENERGY_SENSOR_UNIT_PREFIX) or UnitPrefix.KILO self._attr_native_unit_of_measurement = ENERGY_UNIT_PREFIX_MAPPING.get( unit_prefix, UnitOfEnergy.KILO_WATT_HOUR, ) async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() if state := await self.async_get_last_state(): if (restored := parse_decimal(state)) is None: _LOGGER.warning( "%s: Cannot restore state: %s", self.entity_id, state.state, ) self._state = Decimal(0) else: self._state = restored self._last_updated = state.last_changed.timestamp() self._state += self.calculate_delta() self.async_schedule_update_ha_state() else: self._state = Decimal(0) _LOGGER.debug("%s: Restoring state: %s", self.entity_id, self._state) @callback def refresh(__: datetime) -> None: """Update the energy sensor state.""" delta = self.calculate_delta(self._update_frequency) if delta > 0: self._state = self._state + delta _LOGGER.debug( "%s: Updating daily_fixed_energy sensor: %.4f", self.entity_id, self._state, ) self.async_schedule_update_ha_state() self._last_updated = dt_util.now().timestamp() self._update_timer_removal = async_track_time_interval( self.hass, refresh, timedelta(seconds=self._update_frequency), cancel_on_shutdown=True, ) self.async_on_remove(self._update_timer_removal) def calculate_delta(self, elapsed_seconds: int = 0) -> Decimal: if self._last_delta_calculate is None: self._last_delta_calculate = self._last_updated elapsed_seconds = (int(self._last_delta_calculate) - int(self._last_updated)) + elapsed_seconds self._last_delta_calculate = dt_util.utcnow().timestamp() rendered = evaluate_to_decimal(self._value) if rendered is None: return Decimal(0) value = float(rendered) wh_per_day = ( value * (self._on_time.total_seconds() / 3600) if self._user_unit_of_measurement == UnitOfPower.WATT else EnergyConverter.convert(value, UnitOfEnergy.KILO_WATT_HOUR, UnitOfEnergy.WATT_HOUR) ) # Convert Wh/day to the sensor's native energy unit energy_per_day = EnergyConverter.convert( wh_per_day, UnitOfEnergy.WATT_HOUR, self._attr_native_unit_of_measurement, ) return Decimal((energy_per_day / 86400) * elapsed_seconds) @property def native_value(self) -> Decimal: """Return the state of the sensor.""" return Decimal(round(self._state, self._rounding_digits)) @callback def async_reset(self) -> None: _LOGGER.debug("%s: Reset energy sensor", self.entity_id) self._state = Decimal(0) self._attr_last_reset = dt_util.utcnow() self.async_write_ha_state() async def async_increase(self, value: str) -> None: _LOGGER.debug("%s: Increasing energy sensor with %s", self.entity_id, value) self._state += Decimal(value) self.async_write_ha_state() async def async_calibrate(self, value: str) -> None: _LOGGER.debug("%s: Calibrate energy sensor with %s", self.entity_id, value) self._state = Decimal(value) self.async_write_ha_state()