Files
HomeAssistantVS/custom_components/powercalc/sensors/cost.py
T

406 lines
17 KiB
Python

from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
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 ``<currency>/<energy>`` (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 ``<currency>/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
@dataclass(frozen=True, slots=True)
class PriceConfig:
"""The resolved energy price configuration for a single cost sensor."""
fixed_price: Decimal | None = None
price_entity_id: str | None = None
surcharge: Decimal = Decimal(0)
multiplier: Decimal = Decimal(1)
@property
def has_price_source(self) -> bool:
"""Return whether a price source (fixed price or price sensor) is configured."""
return self.fixed_price is not None or bool(self.price_entity_id)
def effective_price(self, price: Decimal | None) -> Decimal | None:
"""Apply the surcharge and multiplier to a raw price per kWh."""
if price is None:
return None
return (price + self.surcharge) * self.multiplier
def resolve_price_config(hass: HomeAssistant, sensor_config: ConfigType) -> PriceConfig:
"""Resolve the energy price for a sensor, preferring its own config over the global one.
The price source is resolved as a whole: a sensor which defines a fixed price or a price
sensor of its own fully replaces the globally configured price source, rather than mixing
the two. The surcharge and multiplier are independent and fall back to the global value
individually.
"""
global_config: ConfigType = hass.data[DOMAIN].get(DOMAIN_CONFIG, {})
if CONF_ENERGY_PRICE in sensor_config or CONF_ENERGY_PRICE_SENSOR in sensor_config:
fixed_price = sensor_config.get(CONF_ENERGY_PRICE)
price_entity_id = sensor_config.get(CONF_ENERGY_PRICE_SENSOR)
else:
fixed_price = global_config.get(CONF_ENERGY_PRICE)
price_entity_id = global_config.get(CONF_ENERGY_PRICE_SENSOR)
def resolve(key: str, default: float) -> Decimal:
value = sensor_config.get(key, global_config.get(key))
return Decimal(str(default if value is None else value))
return PriceConfig(
fixed_price=Decimal(str(fixed_price)) if fixed_price is not None else None,
price_entity_id=price_entity_id,
surcharge=resolve(CONF_ENERGY_PRICE_SURCHARGE, 0),
multiplier=resolve(CONF_ENERGY_PRICE_MULTIPLIER, 1),
)
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
taken from the sensor configuration when it defines one, otherwise from the global
configuration, 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).
"""
price_config = resolve_price_config(hass, sensor_config)
if not price_config.has_price_source:
_LOGGER.warning(
"Cost sensor creation is enabled but no energy price is configured. "
"Define `energy_price` or `energy_price_sensor` on the sensor itself "
"or 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, %s)",
entity_id,
energy_sensor.entity_id,
price_config,
)
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,
price_config=price_config,
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,
price_config: PriceConfig | None = None,
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._price_config = price_config or PriceConfig()
self._price_entity_id = self._price_config.price_entity_id
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._price_config.effective_price(self._price_config.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._price_config.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._price_config.effective_price(
_parse_scaled(event.data["new_state"], _price_per_kwh_factor),
)
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)}