Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion homeassistant/components/energy/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
"documentation": "https://www.home-assistant.io/integrations/energy",
"codeowners": ["@home-assistant/core"],
"iot_class": "calculated",
"dependencies": ["websocket_api", "history"],
"dependencies": ["websocket_api", "history", "recorder"],
"quality_scale": "internal"
}
277 changes: 277 additions & 0 deletions homeassistant/components/energy/validate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
"""Validate the energy preferences provide valid data."""
from __future__ import annotations

import dataclasses
from typing import Any

from homeassistant.components import recorder, sensor
from homeassistant.const import (
ENERGY_KILO_WATT_HOUR,
ENERGY_WATT_HOUR,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, callback, valid_entity_id

from . import data
from .const import DOMAIN


@dataclasses.dataclass
class ValidationIssue:
"""Error or warning message."""

type: str
identifier: str
value: Any | None = None


@dataclasses.dataclass
class EnergyPreferencesValidation:
"""Dictionary holding validation information."""

energy_sources: list[list[ValidationIssue]] = dataclasses.field(
default_factory=list
)
device_consumption: list[list[ValidationIssue]] = dataclasses.field(
default_factory=list
)

def as_dict(self) -> dict:
"""Return dictionary version."""
return dataclasses.asdict(self)


@callback
def _async_validate_energy_stat(
hass: HomeAssistant, stat_value: str, result: list[ValidationIssue]
) -> None:
"""Validate a statistic."""
has_entity_source = valid_entity_id(stat_value)

if not has_entity_source:
return

if not recorder.is_entity_recorded(hass, stat_value):
result.append(
ValidationIssue(
"recorder_untracked",
stat_value,
)
)
return

state = hass.states.get(stat_value)

if state is None:
result.append(
ValidationIssue(
"entity_not_defined",
stat_value,
)
)
return

if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
result.append(ValidationIssue("entity_unavailable", stat_value, state.state))
return

try:
current_value: float | None = float(state.state)
except ValueError:
result.append(
ValidationIssue("entity_state_non_numeric", stat_value, state.state)
)
return

if current_value is not None and current_value < 0:
result.append(
ValidationIssue("entity_negative_state", stat_value, current_value)
)

unit = state.attributes.get("unit_of_measurement")

if unit not in (ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR):
result.append(
ValidationIssue("entity_unexpected_unit_energy", stat_value, unit)
)

state_class = state.attributes.get("state_class")

if state_class != sensor.STATE_CLASS_TOTAL_INCREASING:
result.append(
ValidationIssue(
"entity_unexpected_state_class_total_increasing",
stat_value,
state_class,
)
)


@callback
def _async_validate_price_entity(
hass: HomeAssistant, entity_id: str, result: list[ValidationIssue]
) -> None:
"""Validate that the price entity is correct."""
state = hass.states.get(entity_id)

if state is None:
result.append(
ValidationIssue(
"entity_not_defined",
entity_id,
)
)
return

try:
value: float | None = float(state.state)
except ValueError:
result.append(
ValidationIssue("entity_state_non_numeric", entity_id, state.state)
)
return

if value is not None and value < 0:
result.append(ValidationIssue("entity_negative_state", entity_id, value))

unit = state.attributes.get("unit_of_measurement")

if unit is None or not unit.endswith(
(f"/{ENERGY_KILO_WATT_HOUR}", f"/{ENERGY_WATT_HOUR}")
):
result.append(ValidationIssue("entity_unexpected_unit_price", entity_id, unit))


@callback
def _async_validate_cost_stat(
hass: HomeAssistant, stat_id: str, result: list[ValidationIssue]
) -> None:
"""Validate that the cost stat is correct."""
has_entity = valid_entity_id(stat_id)

if not has_entity:
return

if not recorder.is_entity_recorded(hass, stat_id):
result.append(
ValidationIssue(
"recorder_untracked",
stat_id,
)
)


@callback
def _async_validate_cost_entity(
hass: HomeAssistant, entity_id: str, result: list[ValidationIssue]
) -> None:
"""Validate that the cost entity is correct."""
if not recorder.is_entity_recorded(hass, entity_id):
result.append(
ValidationIssue(
"recorder_untracked",
entity_id,
)
)

state = hass.states.get(entity_id)

if state is None:
result.append(
ValidationIssue(
"entity_not_defined",
entity_id,
)
)
return

state_class = state.attributes.get("state_class")

if state_class != sensor.STATE_CLASS_TOTAL_INCREASING:
result.append(
ValidationIssue(
"entity_unexpected_state_class_total_increasing", entity_id, state_class
)
)


async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
"""Validate the energy configuration."""
manager = await data.async_get_manager(hass)

result = EnergyPreferencesValidation()

if manager.data is None:
return result

for source in manager.data["energy_sources"]:
source_result: list[ValidationIssue] = []
result.energy_sources.append(source_result)

if source["type"] == "grid":
for flow in source["flow_from"]:
_async_validate_energy_stat(
hass, flow["stat_energy_from"], source_result
)

if flow.get("stat_cost") is not None:
_async_validate_cost_stat(hass, flow["stat_cost"], source_result)

elif flow.get("entity_energy_price") is not None:
_async_validate_price_entity(
hass, flow["entity_energy_price"], source_result
)
_async_validate_cost_entity(
hass,
hass.data[DOMAIN]["cost_sensors"][flow["stat_energy_from"]],
source_result,
)

for flow in source["flow_to"]:
_async_validate_energy_stat(hass, flow["stat_energy_to"], source_result)

if flow.get("stat_compensation") is not None:
_async_validate_cost_stat(
hass, flow["stat_compensation"], source_result
)

elif flow.get("entity_energy_price") is not None:
_async_validate_price_entity(
hass, flow["entity_energy_price"], source_result
)
_async_validate_cost_entity(
hass,
hass.data[DOMAIN]["cost_sensors"][flow["stat_energy_to"]],
source_result,
)

elif source["type"] == "gas":
_async_validate_energy_stat(hass, source["stat_energy_from"], source_result)

if source.get("stat_cost") is not None:
_async_validate_cost_stat(hass, source["stat_cost"], source_result)

elif source.get("entity_energy_price") is not None:
_async_validate_price_entity(
hass, source["entity_energy_price"], source_result
)
_async_validate_cost_entity(
hass,
hass.data[DOMAIN]["cost_sensors"][source["stat_energy_from"]],
source_result,
)

elif source["type"] == "solar":
_async_validate_energy_stat(hass, source["stat_energy_from"], source_result)

elif source["type"] == "battery":
_async_validate_energy_stat(hass, source["stat_energy_from"], source_result)
_async_validate_energy_stat(hass, source["stat_energy_to"], source_result)

for device in manager.data["device_consumption"]:
device_result: list[ValidationIssue] = []
result.device_consumption.append(device_result)
_async_validate_energy_stat(hass, device["stat_consumption"], device_result)

return result
17 changes: 17 additions & 0 deletions homeassistant/components/energy/websocket_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
EnergyPreferencesUpdate,
async_get_manager,
)
from .validate import async_validate

EnergyWebSocketCommandHandler = Callable[
[HomeAssistant, websocket_api.ActiveConnection, Dict[str, Any], "EnergyManager"],
Expand All @@ -35,6 +36,7 @@ def async_setup(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_get_prefs)
websocket_api.async_register_command(hass, ws_save_prefs)
websocket_api.async_register_command(hass, ws_info)
websocket_api.async_register_command(hass, ws_validate)


def _ws_with_manager(
Expand Down Expand Up @@ -113,3 +115,18 @@ def ws_info(
) -> None:
"""Handle get info command."""
connection.send_result(msg["id"], hass.data[DOMAIN])


@websocket_api.websocket_command(
{
vol.Required("type"): "energy/validate",
}
)
@websocket_api.async_response
async def ws_validate(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Handle validate command."""
connection.send_result(msg["id"], (await async_validate(hass)).as_dict())
11 changes: 11 additions & 0 deletions homeassistant/components/recorder/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,17 @@ async def async_migration_in_progress(hass: HomeAssistant) -> bool:
return hass.data[DATA_INSTANCE].migration_in_progress


@bind_hass
def is_entity_recorded(hass: HomeAssistant, entity_id: str) -> bool:
"""Check if an entity is being recorded.

Async friendly.
"""
if DATA_INSTANCE not in hass.data:
return False
return hass.data[DATA_INSTANCE].entity_filter(entity_id)


def run_information(hass, point_in_time: datetime | None = None):
"""Return information about current run.

Expand Down
Loading