403 lines
13 KiB
Python
403 lines
13 KiB
Python
"""Helper sensor for calculating utility costs."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import copy
|
|
from dataclasses import dataclass
|
|
import logging
|
|
from typing import Any, Final, Literal, TypeVar, cast
|
|
|
|
from homeassistant.components.sensor import (
|
|
ATTR_LAST_RESET,
|
|
ATTR_STATE_CLASS,
|
|
DEVICE_CLASS_MONETARY,
|
|
STATE_CLASS_MEASUREMENT,
|
|
STATE_CLASS_TOTAL,
|
|
STATE_CLASS_TOTAL_INCREASING,
|
|
SensorEntity,
|
|
)
|
|
from homeassistant.components.sensor.recorder import reset_detected
|
|
from homeassistant.const import (
|
|
ATTR_UNIT_OF_MEASUREMENT,
|
|
ENERGY_KILO_WATT_HOUR,
|
|
ENERGY_WATT_HOUR,
|
|
VOLUME_CUBIC_METERS,
|
|
)
|
|
from homeassistant.core import HomeAssistant, State, callback, split_entity_id
|
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
from homeassistant.helpers.event import async_track_state_change_event
|
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
|
import homeassistant.util.dt as dt_util
|
|
|
|
from .const import DOMAIN
|
|
from .data import EnergyManager, async_get_manager
|
|
|
|
SUPPORTED_STATE_CLASSES = [
|
|
STATE_CLASS_MEASUREMENT,
|
|
STATE_CLASS_TOTAL,
|
|
STATE_CLASS_TOTAL_INCREASING,
|
|
]
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
async def async_setup_platform(
|
|
hass: HomeAssistant,
|
|
config: ConfigType,
|
|
async_add_entities: AddEntitiesCallback,
|
|
discovery_info: DiscoveryInfoType | None = None,
|
|
) -> None:
|
|
"""Set up the energy sensors."""
|
|
sensor_manager = SensorManager(await async_get_manager(hass), async_add_entities)
|
|
await sensor_manager.async_start()
|
|
|
|
|
|
T = TypeVar("T")
|
|
|
|
|
|
@dataclass
|
|
class SourceAdapter:
|
|
"""Adapter to allow sources and their flows to be used as sensors."""
|
|
|
|
source_type: Literal["grid", "gas"]
|
|
flow_type: Literal["flow_from", "flow_to", None]
|
|
stat_energy_key: Literal["stat_energy_from", "stat_energy_to"]
|
|
entity_energy_key: Literal["entity_energy_from", "entity_energy_to"]
|
|
total_money_key: Literal["stat_cost", "stat_compensation"]
|
|
name_suffix: str
|
|
entity_id_suffix: str
|
|
|
|
|
|
SOURCE_ADAPTERS: Final = (
|
|
SourceAdapter(
|
|
"grid",
|
|
"flow_from",
|
|
"stat_energy_from",
|
|
"entity_energy_from",
|
|
"stat_cost",
|
|
"Cost",
|
|
"cost",
|
|
),
|
|
SourceAdapter(
|
|
"grid",
|
|
"flow_to",
|
|
"stat_energy_to",
|
|
"entity_energy_to",
|
|
"stat_compensation",
|
|
"Compensation",
|
|
"compensation",
|
|
),
|
|
SourceAdapter(
|
|
"gas",
|
|
None,
|
|
"stat_energy_from",
|
|
"entity_energy_from",
|
|
"stat_cost",
|
|
"Cost",
|
|
"cost",
|
|
),
|
|
)
|
|
|
|
|
|
class SensorManager:
|
|
"""Class to handle creation/removal of sensor data."""
|
|
|
|
def __init__(
|
|
self, manager: EnergyManager, async_add_entities: AddEntitiesCallback
|
|
) -> None:
|
|
"""Initialize sensor manager."""
|
|
self.manager = manager
|
|
self.async_add_entities = async_add_entities
|
|
self.current_entities: dict[tuple[str, str | None, str], EnergyCostSensor] = {}
|
|
|
|
async def async_start(self) -> None:
|
|
"""Start."""
|
|
self.manager.async_listen_updates(self._process_manager_data)
|
|
|
|
if self.manager.data:
|
|
await self._process_manager_data()
|
|
|
|
async def _process_manager_data(self) -> None:
|
|
"""Process manager data."""
|
|
to_add: list[EnergyCostSensor] = []
|
|
to_remove = dict(self.current_entities)
|
|
|
|
async def finish() -> None:
|
|
if to_add:
|
|
self.async_add_entities(to_add)
|
|
await asyncio.gather(*(ent.add_finished.wait() for ent in to_add))
|
|
|
|
for key, entity in to_remove.items():
|
|
self.current_entities.pop(key)
|
|
await entity.async_remove()
|
|
|
|
if not self.manager.data:
|
|
await finish()
|
|
return
|
|
|
|
for energy_source in self.manager.data["energy_sources"]:
|
|
for adapter in SOURCE_ADAPTERS:
|
|
if adapter.source_type != energy_source["type"]:
|
|
continue
|
|
|
|
if adapter.flow_type is None:
|
|
self._process_sensor_data(
|
|
adapter,
|
|
# Opting out of the type complexity because can't get it to work
|
|
energy_source, # type: ignore
|
|
to_add,
|
|
to_remove,
|
|
)
|
|
continue
|
|
|
|
for flow in energy_source[adapter.flow_type]: # type: ignore
|
|
self._process_sensor_data(
|
|
adapter,
|
|
# Opting out of the type complexity because can't get it to work
|
|
flow, # type: ignore
|
|
to_add,
|
|
to_remove,
|
|
)
|
|
|
|
await finish()
|
|
|
|
@callback
|
|
def _process_sensor_data(
|
|
self,
|
|
adapter: SourceAdapter,
|
|
config: dict,
|
|
to_add: list[EnergyCostSensor],
|
|
to_remove: dict[tuple[str, str | None, str], EnergyCostSensor],
|
|
) -> None:
|
|
"""Process sensor data."""
|
|
# No need to create an entity if we already have a cost stat
|
|
if config.get(adapter.total_money_key) is not None:
|
|
return
|
|
|
|
key = (adapter.source_type, adapter.flow_type, config[adapter.stat_energy_key])
|
|
|
|
# Make sure the right data is there
|
|
# If the entity existed, we don't pop it from to_remove so it's removed
|
|
if config.get(adapter.entity_energy_key) is None or (
|
|
config.get("entity_energy_price") is None
|
|
and config.get("number_energy_price") is None
|
|
):
|
|
return
|
|
|
|
current_entity = to_remove.pop(key, None)
|
|
if current_entity:
|
|
current_entity.update_config(config)
|
|
return
|
|
|
|
self.current_entities[key] = EnergyCostSensor(
|
|
adapter,
|
|
config,
|
|
)
|
|
to_add.append(self.current_entities[key])
|
|
|
|
|
|
class EnergyCostSensor(SensorEntity):
|
|
"""Calculate costs incurred by consuming energy.
|
|
|
|
This is intended as a fallback for when no specific cost sensor is available for the
|
|
utility.
|
|
"""
|
|
|
|
_wrong_state_class_reported = False
|
|
_wrong_unit_reported = False
|
|
|
|
def __init__(
|
|
self,
|
|
adapter: SourceAdapter,
|
|
config: dict,
|
|
) -> None:
|
|
"""Initialize the sensor."""
|
|
super().__init__()
|
|
|
|
self._adapter = adapter
|
|
self.entity_id = (
|
|
f"{config[adapter.entity_energy_key]}_{adapter.entity_id_suffix}"
|
|
)
|
|
self._attr_device_class = DEVICE_CLASS_MONETARY
|
|
self._attr_state_class = STATE_CLASS_TOTAL
|
|
self._config = config
|
|
self._last_energy_sensor_state: State | None = None
|
|
# add_finished is set when either of async_added_to_hass or add_to_platform_abort
|
|
# is called
|
|
self.add_finished = asyncio.Event()
|
|
|
|
def _reset(self, energy_state: State) -> None:
|
|
"""Reset the cost sensor."""
|
|
self._attr_native_value = 0.0
|
|
self._attr_last_reset = dt_util.utcnow()
|
|
self._last_energy_sensor_state = energy_state
|
|
self.async_write_ha_state()
|
|
|
|
@callback
|
|
def _update_cost(self) -> None:
|
|
"""Update incurred costs."""
|
|
energy_state = self.hass.states.get(
|
|
cast(str, self._config[self._adapter.entity_energy_key])
|
|
)
|
|
|
|
if energy_state is None:
|
|
return
|
|
|
|
state_class = energy_state.attributes.get(ATTR_STATE_CLASS)
|
|
if state_class not in SUPPORTED_STATE_CLASSES:
|
|
if not self._wrong_state_class_reported:
|
|
self._wrong_state_class_reported = True
|
|
_LOGGER.warning(
|
|
"Found unexpected state_class %s for %s",
|
|
state_class,
|
|
energy_state.entity_id,
|
|
)
|
|
return
|
|
|
|
# last_reset must be set if the sensor is STATE_CLASS_MEASUREMENT
|
|
if (
|
|
state_class == STATE_CLASS_MEASUREMENT
|
|
and ATTR_LAST_RESET not in energy_state.attributes
|
|
):
|
|
return
|
|
|
|
try:
|
|
energy = float(energy_state.state)
|
|
except ValueError:
|
|
return
|
|
|
|
# Determine energy price
|
|
if self._config["entity_energy_price"] is not None:
|
|
energy_price_state = self.hass.states.get(
|
|
self._config["entity_energy_price"]
|
|
)
|
|
|
|
if energy_price_state is None:
|
|
return
|
|
|
|
try:
|
|
energy_price = float(energy_price_state.state)
|
|
except ValueError:
|
|
return
|
|
|
|
if (
|
|
self._adapter.source_type == "grid"
|
|
and energy_price_state.attributes.get(
|
|
ATTR_UNIT_OF_MEASUREMENT, ""
|
|
).endswith(f"/{ENERGY_WATT_HOUR}")
|
|
):
|
|
energy_price *= 1000.0
|
|
|
|
else:
|
|
energy_price_state = None
|
|
energy_price = cast(float, self._config["number_energy_price"])
|
|
|
|
if self._last_energy_sensor_state is None:
|
|
# Initialize as it's the first time all required entities are in place.
|
|
self._reset(energy_state)
|
|
return
|
|
|
|
energy_unit = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
|
|
|
if self._adapter.source_type == "grid":
|
|
if energy_unit == ENERGY_WATT_HOUR:
|
|
energy_price /= 1000
|
|
elif energy_unit != ENERGY_KILO_WATT_HOUR:
|
|
energy_unit = None
|
|
|
|
elif self._adapter.source_type == "gas":
|
|
if energy_unit != VOLUME_CUBIC_METERS:
|
|
energy_unit = None
|
|
|
|
if energy_unit is None:
|
|
if not self._wrong_unit_reported:
|
|
self._wrong_unit_reported = True
|
|
_LOGGER.warning(
|
|
"Found unexpected unit %s for %s",
|
|
energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT),
|
|
energy_state.entity_id,
|
|
)
|
|
return
|
|
|
|
if state_class != STATE_CLASS_TOTAL_INCREASING and energy_state.attributes.get(
|
|
ATTR_LAST_RESET
|
|
) != self._last_energy_sensor_state.attributes.get(ATTR_LAST_RESET):
|
|
# Energy meter was reset, reset cost sensor too
|
|
energy_state_copy = copy.copy(energy_state)
|
|
energy_state_copy.state = "0.0"
|
|
self._reset(energy_state_copy)
|
|
elif state_class == STATE_CLASS_TOTAL_INCREASING and reset_detected(
|
|
self.hass,
|
|
cast(str, self._config[self._adapter.entity_energy_key]),
|
|
energy,
|
|
float(self._last_energy_sensor_state.state),
|
|
self._last_energy_sensor_state,
|
|
):
|
|
# Energy meter was reset, reset cost sensor too
|
|
energy_state_copy = copy.copy(energy_state)
|
|
energy_state_copy.state = "0.0"
|
|
self._reset(energy_state_copy)
|
|
# Update with newly incurred cost
|
|
old_energy_value = float(self._last_energy_sensor_state.state)
|
|
cur_value = cast(float, self._attr_native_value)
|
|
self._attr_native_value = cur_value + (energy - old_energy_value) * energy_price
|
|
|
|
self._last_energy_sensor_state = energy_state
|
|
|
|
async def async_added_to_hass(self) -> None:
|
|
"""Register callbacks."""
|
|
energy_state = self.hass.states.get(
|
|
self._config[self._adapter.entity_energy_key]
|
|
)
|
|
if energy_state:
|
|
name = energy_state.name
|
|
else:
|
|
name = split_entity_id(self._config[self._adapter.entity_energy_key])[
|
|
0
|
|
].replace("_", " ")
|
|
|
|
self._attr_name = f"{name} {self._adapter.name_suffix}"
|
|
|
|
self._update_cost()
|
|
|
|
# Store stat ID in hass.data so frontend can look it up
|
|
self.hass.data[DOMAIN]["cost_sensors"][
|
|
self._config[self._adapter.entity_energy_key]
|
|
] = self.entity_id
|
|
|
|
@callback
|
|
def async_state_changed_listener(*_: Any) -> None:
|
|
"""Handle child updates."""
|
|
self._update_cost()
|
|
self.async_write_ha_state()
|
|
|
|
self.async_on_remove(
|
|
async_track_state_change_event(
|
|
self.hass,
|
|
cast(str, self._config[self._adapter.entity_energy_key]),
|
|
async_state_changed_listener,
|
|
)
|
|
)
|
|
self.add_finished.set()
|
|
|
|
@callback
|
|
def add_to_platform_abort(self) -> None:
|
|
"""Abort adding an entity to a platform."""
|
|
self.add_finished.set()
|
|
|
|
async def async_will_remove_from_hass(self) -> None:
|
|
"""Handle removing from hass."""
|
|
self.hass.data[DOMAIN]["cost_sensors"].pop(
|
|
self._config[self._adapter.entity_energy_key]
|
|
)
|
|
await super().async_will_remove_from_hass()
|
|
|
|
@callback
|
|
def update_config(self, config: dict) -> None:
|
|
"""Update the config."""
|
|
self._config = config
|
|
|
|
@property
|
|
def native_unit_of_measurement(self) -> str | None:
|
|
"""Return the units of measurement."""
|
|
return self.hass.config.currency
|