from __future__ import annotations from collections.abc import Callable from decimal import Decimal import logging from typing import TYPE_CHECKING from homeassistant.components.sensor import ATTR_LAST_RESET, SensorDeviceClass, SensorEntity, SensorStateClass from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_UNIQUE_ID, UnitOfEnergy, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, State, callback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util from custom_components.powercalc.common import SourceEntity from custom_components.powercalc.const import ( CONF_COST, CONF_COST_SENSOR_PRECISION, CONF_ENERGY_PRICE, CONF_ENERGY_PRICE_MULTIPLIER, CONF_ENERGY_PRICE_SENSOR, CONF_ENERGY_PRICE_SURCHARGE, CONF_ENERGY_SENSOR_ID, DEFAULT_COST_SENSOR_PRECISION, DOMAIN, DOMAIN_CONFIG, ) from custom_components.powercalc.unit import convert_to_decimal, parse_decimal from .abstract import ( BaseEntity, generate_cost_sensor_entity_id, generate_cost_sensor_name, ) from .energy import EnergySensor, resolve_existing_energy_sensor if TYPE_CHECKING: from datetime import datetime from .utility_meter import VirtualUtilityMeter COST_ICON = "mdi:cash" ATTR_LAST_ENERGY = "last_energy" _LOGGER = logging.getLogger(__name__) def _parse_scaled(state: State | None, unit_to_factor: Callable[[str | None], Decimal]) -> Decimal | None: """Parse a numeric state into a Decimal, scaled by a factor derived from its unit. Both energy amounts (converted to kWh) and prices (converted to per kWh) are parsed this way; ``unit_to_factor`` maps the state's unit of measurement to the multiplier to apply. """ value = parse_decimal(state) if value is None: return None assert state is not None # a parsed value implies the state is present and numeric return value * unit_to_factor(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) def _to_kwh_factor(unit: str | None) -> Decimal: """Return the multiplier to convert an energy value expressed in `unit` to kWh. Falls back to 1 (assume kWh) when the unit is missing or not a recognizable energy unit. """ if not unit or unit == UnitOfEnergy.KILO_WATT_HOUR: return Decimal(1) if (factor := convert_to_decimal(1, unit, UnitOfEnergy.KILO_WATT_HOUR)) is not None: return factor _LOGGER.warning("Cannot convert energy unit '%s' to kWh, assuming kWh", unit) return Decimal(1) def _price_per_kwh_factor(unit: str | None) -> Decimal: """Return the multiplier that converts a price value to a price per kWh. Price sensors express their unit as ``/`` (for example ``EUR/MWh``). The energy denominator determines how the raw value maps to a per-kWh price, e.g. ``EUR/MWh`` -> value * 0.001 and ``EUR/Wh`` -> value * 1000. Falls back to 1 (assume the value is already per kWh) when there is no denominator or it is not a recognizable unit. """ if not unit or "/" not in unit: return Decimal(1) denominator = unit.rsplit("/", 1)[-1].strip() if not denominator or denominator == UnitOfEnergy.KILO_WATT_HOUR: return Decimal(1) if (factor := convert_to_decimal(1, UnitOfEnergy.KILO_WATT_HOUR, denominator)) is not None: return factor _LOGGER.warning("Cannot convert energy price unit '%s' to a per-kWh price, assuming per kWh", unit) return Decimal(1) def _currency_from_price_unit(unit: str | None) -> str | None: """Derive the monetary unit from an energy price sensor's unit of measurement. Price sensors typically express their unit as ``/kWh`` (for example ``EUR/kWh`` or ``€/kWh``). The part before the slash is the currency the cost is denominated in. Returns None when no currency can be determined. """ if not unit: return None currency = unit.split("/", 1)[0].strip() return currency or None def create_cost_sensor( hass: HomeAssistant, sensor_config: ConfigType, energy_sensor: EnergySensor | VirtualUtilityMeter, source_entity: SourceEntity | None = None, name: str | None = None, reset_on_source_reset: bool = False, unique_id: str | None = None, ) -> CostSensor | None: """Create a cost sensor tracking the cost of the given energy sensor. The energy sensor can be a regular energy sensor or a utility meter. The energy price is defined globally, either as a fixed price or a price sensor. When no price is configured, no cost sensor is created. A ``unique_id`` can be provided to override the id derived from the energy sensor (used for standalone cost sensor config entries). """ global_config: ConfigType = hass.data[DOMAIN].get(DOMAIN_CONFIG, {}) fixed_price = global_config.get(CONF_ENERGY_PRICE) price_entity_id = global_config.get(CONF_ENERGY_PRICE_SENSOR) price_surcharge = Decimal(str(global_config.get(CONF_ENERGY_PRICE_SURCHARGE, 0) or 0)) price_multiplier = Decimal(str(global_config.get(CONF_ENERGY_PRICE_MULTIPLIER, 1) or 1)) if fixed_price is None and not price_entity_id: _LOGGER.warning( "Cost sensor creation is enabled but no energy price is configured. " "Define `energy_price` or `energy_price_sensor` in the global powercalc configuration", ) return None name_base = name if name is not None else sensor_config.get(CONF_NAME) cost_name = generate_cost_sensor_name(sensor_config, name_base, source_entity) if unique_id is None and energy_sensor.unique_id is not None: unique_id = f"{energy_sensor.unique_id}_cost" entity_id = generate_cost_sensor_entity_id( hass, sensor_config, source_entity, name=name_base, unique_id=unique_id, ) _LOGGER.debug( ( "Creating cost sensor (entity_id=%s, source_entity=%s, fixed_price=%s, " "price_entity=%s, price_surcharge=%s, price_multiplier=%s)" ), entity_id, energy_sensor.entity_id, fixed_price, price_entity_id, price_surcharge, price_multiplier, ) return CostSensor( hass=hass, source_energy_entity=energy_sensor.entity_id, entity_id=entity_id, unique_id=unique_id, name=cost_name, sensor_config=sensor_config, fixed_price=Decimal(str(fixed_price)) if fixed_price is not None else None, price_entity_id=price_entity_id, price_surcharge=price_surcharge, price_multiplier=price_multiplier, reset_on_source_reset=reset_on_source_reset, ) def create_cost_sensor_for_energy_entity(hass: HomeAssistant, sensor_config: ConfigType) -> CostSensor | None: """Create a standalone cost sensor tracking an existing (non-powercalc) energy sensor. The tracked energy sensor is read from the ``cost`` block (YAML) or the flat ``energy_sensor_id`` key (GUI config entry). """ cost_config = sensor_config.get(CONF_COST, {}) energy_sensor_id = cost_config.get(CONF_ENERGY_SENSOR_ID) or sensor_config[CONF_ENERGY_SENSOR_ID] energy_sensor = resolve_existing_energy_sensor(hass, energy_sensor_id) # In YAML the name is optional, fall back to the name of the tracked energy sensor. name = sensor_config.get(CONF_NAME) or energy_sensor.name return create_cost_sensor( hass, sensor_config, energy_sensor, name=name, unique_id=sensor_config.get(CONF_UNIQUE_ID), ) class CostSensor(BaseEntity, RestoreEntity, SensorEntity): """Cost sensor, accumulating the cost of the energy consumed at price-at-consumption.""" _attr_device_class = SensorDeviceClass.MONETARY _attr_state_class = SensorStateClass.TOTAL _attr_should_poll = False _attr_icon = COST_ICON _unrecorded_attributes = frozenset({ATTR_LAST_ENERGY}) def __init__( self, hass: HomeAssistant, source_energy_entity: str, entity_id: str, sensor_config: ConfigType, name: str | None = None, unique_id: str | None = None, fixed_price: Decimal | None = None, price_entity_id: str | None = None, price_surcharge: Decimal = Decimal(0), price_multiplier: Decimal = Decimal(1), reset_on_source_reset: bool = False, ) -> None: self._source_energy_entity = source_energy_entity self._sensor_config = sensor_config self._reset_on_source_reset = reset_on_source_reset self._attr_name = name self._attr_unique_id = unique_id self._attr_native_unit_of_measurement = hass.config.currency self._fixed_price = fixed_price self._price_entity_id = price_entity_id self._price_surcharge = price_surcharge self._price_multiplier = price_multiplier self._rounding_digits = int(sensor_config.get(CONF_COST_SENSOR_PRECISION, DEFAULT_COST_SENSOR_PRECISION)) self._attr_suggested_display_precision = self._rounding_digits self._state: Decimal = Decimal(0) self._last_energy: Decimal | None = None self._current_price: Decimal | None = self._effective_price(fixed_price) # Only a resetting (per utility meter cycle) sensor uses last_reset; a lifetime cost # sensor accumulates monotonically and leaves it None. self._attr_last_reset: datetime | None = None self.entity_id = entity_id async def async_added_to_hass(self) -> None: """Restore state and start tracking the source energy and price sensors.""" await super().async_added_to_hass() if (state := await self.async_get_last_state()) is not None: self._state = parse_decimal(state) or Decimal(0) self._last_energy = parse_decimal(state.attributes.get(ATTR_LAST_ENERGY)) # A resetting (per utility meter cycle) cost sensor exposes last_reset so long-term # statistics treat each cycle reset as a new cycle rather than negative consumption. # Restore it across restarts, falling back to now for a freshly created sensor. if self._reset_on_source_reset: restored = dt_util.parse_datetime(state.attributes.get(ATTR_LAST_RESET, "")) if state else None self._attr_last_reset = restored or dt_util.utcnow() _LOGGER.debug("%s: Restoring cost sensor state: %s", self.entity_id, self._state) # Seed the current price and, for a price sensor, track its changes so consumption # is always settled at the price that was in effect when it was consumed. if self._price_entity_id is not None: price_state = self.hass.states.get(self._price_entity_id) # Prefer the currency of the price sensor (e.g. `€/kWh` -> `€`) over the HA currency. price_unit = price_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if price_state else None if (currency := _currency_from_price_unit(price_unit)) is not None: self._attr_native_unit_of_measurement = currency self._current_price = self._effective_price(_parse_scaled(price_state, _price_per_kwh_factor)) self.async_on_remove( async_track_state_change_event( self.hass, [self._price_entity_id], self._handle_price_state_change, ), ) self.async_on_remove( async_track_state_change_event( self.hass, [self._source_energy_entity], self._handle_energy_state_change, ), ) @callback def _handle_energy_state_change(self, event: Event[EventStateChangedData]) -> None: """Accumulate cost based on the delta of the energy sensor and the current price.""" new_energy = _parse_scaled(event.data["new_state"], _to_kwh_factor) if new_energy is None: return # First reading only establishes a baseline to measure deltas from. if self._last_energy is None: self._last_energy = new_energy return self._accumulate(new_energy, self._current_price) @callback def _handle_price_state_change(self, event: Event[EventStateChangedData]) -> None: """Settle the energy consumed so far at the previous price, then adopt the new price.""" # Recalculate the outstanding energy delta with the previously known price before switching. if self._last_energy is not None and self._current_price is not None: current_energy = _parse_scaled(self.hass.states.get(self._source_energy_entity), _to_kwh_factor) if current_energy is not None: self._accumulate(current_energy, self._current_price) self._current_price = self._effective_price(_parse_scaled(event.data["new_state"], _price_per_kwh_factor)) def _effective_price(self, price: Decimal | None) -> Decimal | None: if price is None: return None return (price + self._price_surcharge) * self._price_multiplier def _accumulate(self, new_energy: Decimal, price: Decimal | None) -> None: """Add the cost of the consumed energy at the given price and advance the baseline.""" # Leave _last_energy untouched when no price is known, so the consumption is # priced once a price becomes available again. if price is None or self._last_energy is None: return delta = new_energy - self._last_energy if delta < 0: if self._reset_on_source_reset: # The source (utility meter) reset for a new cycle, start the cost cycle over. self._state = Decimal(0) self._last_energy = new_energy self._attr_last_reset = dt_util.utcnow() self.async_write_ha_state() return # The energy sensor got reset (e.g. restart or calibrate), treat the new value as the delta. delta = new_energy if delta == 0: return self._state += delta * price self._last_energy = new_energy self.async_write_ha_state() @callback def async_reset(self) -> None: """Reset the cost sensor to zero from the current source energy reading.""" _LOGGER.debug("%s: Reset cost sensor", self.entity_id) self._state = Decimal(0) self._attr_last_reset = dt_util.utcnow() self._set_current_energy_baseline() self.async_write_ha_state() async def async_calibrate(self, value: str) -> None: """Set the cost sensor to the given value from the current source energy reading.""" _LOGGER.debug("%s: Calibrate cost sensor to: %s", self.entity_id, value) self._state = Decimal(value) self._set_current_energy_baseline() self.async_write_ha_state() def _set_current_energy_baseline(self) -> None: current_energy = _parse_scaled(self.hass.states.get(self._source_energy_entity), _to_kwh_factor) if current_energy is not None: self._last_energy = current_energy @property def native_value(self) -> Decimal: """Return the accumulated cost.""" return Decimal(round(self._state, self._rounding_digits)) @property def extra_state_attributes(self) -> dict[str, str] | None: """Return the state attributes of the cost sensor.""" if self._last_energy is None: return None return {ATTR_LAST_ENERGY: str(self._last_energy)}