mirror of https://github.com/home-assistant/core
348 lines
10 KiB
Python
348 lines
10 KiB
Python
"""Energy data."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from collections import Counter
|
|
from collections.abc import Awaitable, Callable
|
|
from typing import Literal, TypedDict
|
|
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.core import HomeAssistant, callback
|
|
from homeassistant.helpers import config_validation as cv, singleton, storage
|
|
|
|
from .const import DOMAIN
|
|
|
|
STORAGE_VERSION = 1
|
|
STORAGE_KEY = DOMAIN
|
|
|
|
|
|
@singleton.singleton(f"{DOMAIN}_manager")
|
|
async def async_get_manager(hass: HomeAssistant) -> EnergyManager:
|
|
"""Return an initialized data manager."""
|
|
manager = EnergyManager(hass)
|
|
await manager.async_initialize()
|
|
return manager
|
|
|
|
|
|
class FlowFromGridSourceType(TypedDict):
|
|
"""Dictionary describing the 'from' stat for the grid source."""
|
|
|
|
# statistic_id of a an energy meter (kWh)
|
|
stat_energy_from: str
|
|
|
|
# statistic_id of costs ($) incurred from the energy meter
|
|
# If set to None and entity_energy_price or number_energy_price are configured,
|
|
# an EnergyCostSensor will be automatically created
|
|
stat_cost: str | None
|
|
|
|
# Used to generate costs if stat_cost is set to None
|
|
entity_energy_price: str | None # entity_id of an entity providing price ($/kWh)
|
|
number_energy_price: float | None # Price for energy ($/kWh)
|
|
|
|
|
|
class FlowToGridSourceType(TypedDict):
|
|
"""Dictionary describing the 'to' stat for the grid source."""
|
|
|
|
# kWh meter
|
|
stat_energy_to: str
|
|
|
|
# statistic_id of compensation ($) received for contributing back
|
|
# If set to None and entity_energy_price or number_energy_price are configured,
|
|
# an EnergyCostSensor will be automatically created
|
|
stat_compensation: str | None
|
|
|
|
# Used to generate costs if stat_compensation is set to None
|
|
entity_energy_price: str | None # entity_id of an entity providing price ($/kWh)
|
|
number_energy_price: float | None # Price for energy ($/kWh)
|
|
|
|
|
|
class GridSourceType(TypedDict):
|
|
"""Dictionary holding the source of grid energy consumption."""
|
|
|
|
type: Literal["grid"]
|
|
|
|
flow_from: list[FlowFromGridSourceType]
|
|
flow_to: list[FlowToGridSourceType]
|
|
|
|
cost_adjustment_day: float
|
|
|
|
|
|
class SolarSourceType(TypedDict):
|
|
"""Dictionary holding the source of energy production."""
|
|
|
|
type: Literal["solar"]
|
|
|
|
stat_energy_from: str
|
|
config_entry_solar_forecast: list[str] | None
|
|
|
|
|
|
class BatterySourceType(TypedDict):
|
|
"""Dictionary holding the source of battery storage."""
|
|
|
|
type: Literal["battery"]
|
|
|
|
stat_energy_from: str
|
|
stat_energy_to: str
|
|
|
|
|
|
class GasSourceType(TypedDict):
|
|
"""Dictionary holding the source of gas consumption."""
|
|
|
|
type: Literal["gas"]
|
|
|
|
stat_energy_from: str
|
|
|
|
# statistic_id of costs ($) incurred from the gas meter
|
|
# If set to None and entity_energy_price or number_energy_price are configured,
|
|
# an EnergyCostSensor will be automatically created
|
|
stat_cost: str | None
|
|
|
|
# Used to generate costs if stat_cost is set to None
|
|
entity_energy_price: str | None # entity_id of an entity providing price ($/m³)
|
|
number_energy_price: float | None # Price for energy ($/m³)
|
|
|
|
|
|
class WaterSourceType(TypedDict):
|
|
"""Dictionary holding the source of water consumption."""
|
|
|
|
type: Literal["water"]
|
|
|
|
stat_energy_from: str
|
|
|
|
# statistic_id of costs ($) incurred from the water meter
|
|
# If set to None and entity_energy_price or number_energy_price are configured,
|
|
# an EnergyCostSensor will be automatically created
|
|
stat_cost: str | None
|
|
|
|
# Used to generate costs if stat_cost is set to None
|
|
entity_energy_price: str | None # entity_id of an entity providing price ($/m³)
|
|
number_energy_price: float | None # Price for energy ($/m³)
|
|
|
|
|
|
type SourceType = (
|
|
GridSourceType
|
|
| SolarSourceType
|
|
| BatterySourceType
|
|
| GasSourceType
|
|
| WaterSourceType
|
|
)
|
|
|
|
|
|
class DeviceConsumption(TypedDict):
|
|
"""Dictionary holding the source of individual device consumption."""
|
|
|
|
# This is an ever increasing value
|
|
stat_consumption: str
|
|
|
|
# An optional custom name for display in energy graphs
|
|
name: str | None
|
|
|
|
|
|
class EnergyPreferences(TypedDict):
|
|
"""Dictionary holding the energy data."""
|
|
|
|
energy_sources: list[SourceType]
|
|
device_consumption: list[DeviceConsumption]
|
|
|
|
|
|
class EnergyPreferencesUpdate(EnergyPreferences, total=False):
|
|
"""all types optional."""
|
|
|
|
|
|
def _flow_from_ensure_single_price(
|
|
val: FlowFromGridSourceType,
|
|
) -> FlowFromGridSourceType:
|
|
"""Ensure we use a single price source."""
|
|
if (
|
|
val["entity_energy_price"] is not None
|
|
and val["number_energy_price"] is not None
|
|
):
|
|
raise vol.Invalid("Define either an entity or a fixed number for the price")
|
|
|
|
return val
|
|
|
|
|
|
FLOW_FROM_GRID_SOURCE_SCHEMA = vol.All(
|
|
vol.Schema(
|
|
{
|
|
vol.Required("stat_energy_from"): str,
|
|
vol.Optional("stat_cost"): vol.Any(str, None),
|
|
# entity_energy_from was removed in HA Core 2022.10
|
|
vol.Remove("entity_energy_from"): vol.Any(str, None),
|
|
vol.Optional("entity_energy_price"): vol.Any(str, None),
|
|
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
|
|
}
|
|
),
|
|
_flow_from_ensure_single_price,
|
|
)
|
|
|
|
|
|
FLOW_TO_GRID_SOURCE_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required("stat_energy_to"): str,
|
|
vol.Optional("stat_compensation"): vol.Any(str, None),
|
|
# entity_energy_to was removed in HA Core 2022.10
|
|
vol.Remove("entity_energy_to"): vol.Any(str, None),
|
|
vol.Optional("entity_energy_price"): vol.Any(str, None),
|
|
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
|
|
}
|
|
)
|
|
|
|
|
|
def _generate_unique_value_validator(key: str) -> Callable[[list[dict]], list[dict]]:
|
|
"""Generate a validator that ensures a value is only used once."""
|
|
|
|
def validate_uniqueness(
|
|
val: list[dict],
|
|
) -> list[dict]:
|
|
"""Ensure that the user doesn't add duplicate values."""
|
|
counts = Counter(flow_from[key] for flow_from in val)
|
|
|
|
for value, count in counts.items():
|
|
if count > 1:
|
|
raise vol.Invalid(f"Cannot specify {value} more than once")
|
|
|
|
return val
|
|
|
|
return validate_uniqueness
|
|
|
|
|
|
GRID_SOURCE_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required("type"): "grid",
|
|
vol.Required("flow_from"): vol.All(
|
|
[FLOW_FROM_GRID_SOURCE_SCHEMA],
|
|
_generate_unique_value_validator("stat_energy_from"),
|
|
),
|
|
vol.Required("flow_to"): vol.All(
|
|
[FLOW_TO_GRID_SOURCE_SCHEMA],
|
|
_generate_unique_value_validator("stat_energy_to"),
|
|
),
|
|
vol.Required("cost_adjustment_day"): vol.Coerce(float),
|
|
}
|
|
)
|
|
SOLAR_SOURCE_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required("type"): "solar",
|
|
vol.Required("stat_energy_from"): str,
|
|
vol.Optional("config_entry_solar_forecast"): vol.Any([str], None),
|
|
}
|
|
)
|
|
BATTERY_SOURCE_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required("type"): "battery",
|
|
vol.Required("stat_energy_from"): str,
|
|
vol.Required("stat_energy_to"): str,
|
|
}
|
|
)
|
|
GAS_SOURCE_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required("type"): "gas",
|
|
vol.Required("stat_energy_from"): str,
|
|
vol.Optional("stat_cost"): vol.Any(str, None),
|
|
# entity_energy_from was removed in HA Core 2022.10
|
|
vol.Remove("entity_energy_from"): vol.Any(str, None),
|
|
vol.Optional("entity_energy_price"): vol.Any(str, None),
|
|
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
|
|
}
|
|
)
|
|
WATER_SOURCE_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required("type"): "water",
|
|
vol.Required("stat_energy_from"): str,
|
|
vol.Optional("stat_cost"): vol.Any(str, None),
|
|
vol.Optional("entity_energy_price"): vol.Any(str, None),
|
|
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
|
|
}
|
|
)
|
|
|
|
|
|
def check_type_limits(value: list[SourceType]) -> list[SourceType]:
|
|
"""Validate that we don't have too many of certain types."""
|
|
types = Counter([val["type"] for val in value])
|
|
|
|
if types.get("grid", 0) > 1:
|
|
raise vol.Invalid("You cannot have more than 1 grid source")
|
|
|
|
return value
|
|
|
|
|
|
ENERGY_SOURCE_SCHEMA = vol.All(
|
|
vol.Schema(
|
|
[
|
|
cv.key_value_schemas(
|
|
"type",
|
|
{
|
|
"grid": GRID_SOURCE_SCHEMA,
|
|
"solar": SOLAR_SOURCE_SCHEMA,
|
|
"battery": BATTERY_SOURCE_SCHEMA,
|
|
"gas": GAS_SOURCE_SCHEMA,
|
|
"water": WATER_SOURCE_SCHEMA,
|
|
},
|
|
)
|
|
]
|
|
),
|
|
check_type_limits,
|
|
)
|
|
|
|
DEVICE_CONSUMPTION_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required("stat_consumption"): str,
|
|
vol.Optional("name"): str,
|
|
}
|
|
)
|
|
|
|
|
|
class EnergyManager:
|
|
"""Manage the instance energy prefs."""
|
|
|
|
def __init__(self, hass: HomeAssistant) -> None:
|
|
"""Initialize energy manager."""
|
|
self._hass = hass
|
|
self._store = storage.Store[EnergyPreferences](
|
|
hass, STORAGE_VERSION, STORAGE_KEY
|
|
)
|
|
self.data: EnergyPreferences | None = None
|
|
self._update_listeners: list[Callable[[], Awaitable]] = []
|
|
|
|
async def async_initialize(self) -> None:
|
|
"""Initialize the energy integration."""
|
|
self.data = await self._store.async_load()
|
|
|
|
@staticmethod
|
|
def default_preferences() -> EnergyPreferences:
|
|
"""Return default preferences."""
|
|
return {
|
|
"energy_sources": [],
|
|
"device_consumption": [],
|
|
}
|
|
|
|
async def async_update(self, update: EnergyPreferencesUpdate) -> None:
|
|
"""Update the preferences."""
|
|
if self.data is None:
|
|
data = EnergyManager.default_preferences()
|
|
else:
|
|
data = self.data.copy()
|
|
|
|
for key in (
|
|
"energy_sources",
|
|
"device_consumption",
|
|
):
|
|
if key in update:
|
|
data[key] = update[key]
|
|
|
|
self.data = data
|
|
self._store.async_delay_save(lambda: data, 60)
|
|
|
|
if not self._update_listeners:
|
|
return
|
|
|
|
await asyncio.gather(*(listener() for listener in self._update_listeners))
|
|
|
|
@callback
|
|
def async_listen_updates(self, update_listener: Callable[[], Awaitable]) -> None:
|
|
"""Listen for data updates."""
|
|
self._update_listeners.append(update_listener)
|