Skip to content
Snippets Groups Projects
Unverified Commit 2f77b502 authored by Paulus Schoutsen's avatar Paulus Schoutsen Committed by GitHub
Browse files

Add energy validation (#54567)

parent 6d0ce814
No related branches found
No related tags found
No related merge requests found
...@@ -4,6 +4,6 @@ ...@@ -4,6 +4,6 @@
"documentation": "https://www.home-assistant.io/integrations/energy", "documentation": "https://www.home-assistant.io/integrations/energy",
"codeowners": ["@home-assistant/core"], "codeowners": ["@home-assistant/core"],
"iot_class": "calculated", "iot_class": "calculated",
"dependencies": ["websocket_api", "history"], "dependencies": ["websocket_api", "history", "recorder"],
"quality_scale": "internal" "quality_scale": "internal"
} }
"""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
...@@ -18,6 +18,7 @@ from .data import ( ...@@ -18,6 +18,7 @@ from .data import (
EnergyPreferencesUpdate, EnergyPreferencesUpdate,
async_get_manager, async_get_manager,
) )
from .validate import async_validate
EnergyWebSocketCommandHandler = Callable[ EnergyWebSocketCommandHandler = Callable[
[HomeAssistant, websocket_api.ActiveConnection, Dict[str, Any], "EnergyManager"], [HomeAssistant, websocket_api.ActiveConnection, Dict[str, Any], "EnergyManager"],
...@@ -35,6 +36,7 @@ def async_setup(hass: HomeAssistant) -> None: ...@@ -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_get_prefs)
websocket_api.async_register_command(hass, ws_save_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_info)
websocket_api.async_register_command(hass, ws_validate)
def _ws_with_manager( def _ws_with_manager(
...@@ -113,3 +115,18 @@ def ws_info( ...@@ -113,3 +115,18 @@ def ws_info(
) -> None: ) -> None:
"""Handle get info command.""" """Handle get info command."""
connection.send_result(msg["id"], hass.data[DOMAIN]) 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())
...@@ -174,6 +174,17 @@ async def async_migration_in_progress(hass: HomeAssistant) -> bool: ...@@ -174,6 +174,17 @@ async def async_migration_in_progress(hass: HomeAssistant) -> bool:
return hass.data[DATA_INSTANCE].migration_in_progress 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): def run_information(hass, point_in_time: datetime | None = None):
"""Return information about current run. """Return information about current run.
......
"""Test that validation works."""
from unittest.mock import patch
import pytest
from homeassistant.components.energy import async_get_manager, validate
from homeassistant.setup import async_setup_component
from tests.common import async_init_recorder_component
@pytest.fixture
def mock_is_entity_recorded():
"""Mock recorder.is_entity_recorded."""
mocks = {}
with patch(
"homeassistant.components.recorder.is_entity_recorded",
side_effect=lambda hass, entity_id: mocks.get(entity_id, True),
):
yield mocks
@pytest.fixture(autouse=True)
async def mock_energy_manager(hass):
"""Set up energy."""
await async_init_recorder_component(hass)
assert await async_setup_component(hass, "energy", {"energy": {}})
manager = await async_get_manager(hass)
manager.data = manager.default_preferences()
return manager
async def test_validation_empty_config(hass):
"""Test validating an empty config."""
assert (await validate.async_validate(hass)).as_dict() == {
"energy_sources": [],
"device_consumption": [],
}
async def test_validation(hass, mock_energy_manager):
"""Test validating success."""
for key in ("device_cons", "battery_import", "battery_export", "solar_production"):
hass.states.async_set(
f"sensor.{key}",
"123",
{"unit_of_measurement": "kWh", "state_class": "total_increasing"},
)
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "battery",
"stat_energy_from": "sensor.battery_import",
"stat_energy_to": "sensor.battery_export",
},
{"type": "solar", "stat_energy_from": "sensor.solar_production"},
],
"device_consumption": [{"stat_consumption": "sensor.device_cons"}],
}
)
assert (await validate.async_validate(hass)).as_dict() == {
"energy_sources": [[], []],
"device_consumption": [[]],
}
async def test_validation_device_consumption_entity_missing(hass, mock_energy_manager):
"""Test validating missing stat for device."""
await mock_energy_manager.async_update(
{"device_consumption": [{"stat_consumption": "sensor.not_exist"}]}
)
assert (await validate.async_validate(hass)).as_dict() == {
"energy_sources": [],
"device_consumption": [
[
{
"type": "entity_not_defined",
"identifier": "sensor.not_exist",
"value": None,
}
]
],
}
async def test_validation_device_consumption_entity_unavailable(
hass, mock_energy_manager
):
"""Test validating missing stat for device."""
await mock_energy_manager.async_update(
{"device_consumption": [{"stat_consumption": "sensor.unavailable"}]}
)
hass.states.async_set("sensor.unavailable", "unavailable", {})
assert (await validate.async_validate(hass)).as_dict() == {
"energy_sources": [],
"device_consumption": [
[
{
"type": "entity_unavailable",
"identifier": "sensor.unavailable",
"value": "unavailable",
}
]
],
}
async def test_validation_device_consumption_entity_non_numeric(
hass, mock_energy_manager
):
"""Test validating missing stat for device."""
await mock_energy_manager.async_update(
{"device_consumption": [{"stat_consumption": "sensor.non_numeric"}]}
)
hass.states.async_set("sensor.non_numeric", "123,123.10")
assert (await validate.async_validate(hass)).as_dict() == {
"energy_sources": [],
"device_consumption": [
[
{
"type": "entity_state_non_numeric",
"identifier": "sensor.non_numeric",
"value": "123,123.10",
},
]
],
}
async def test_validation_device_consumption_entity_unexpected_unit(
hass, mock_energy_manager
):
"""Test validating missing stat for device."""
await mock_energy_manager.async_update(
{"device_consumption": [{"stat_consumption": "sensor.unexpected_unit"}]}
)
hass.states.async_set(
"sensor.unexpected_unit",
"10.10",
{"unit_of_measurement": "beers", "state_class": "total_increasing"},
)
assert (await validate.async_validate(hass)).as_dict() == {
"energy_sources": [],
"device_consumption": [
[
{
"type": "entity_unexpected_unit_energy",
"identifier": "sensor.unexpected_unit",
"value": "beers",
}
]
],
}
async def test_validation_device_consumption_recorder_not_tracked(
hass, mock_energy_manager, mock_is_entity_recorded
):
"""Test validating device based on untracked entity."""
mock_is_entity_recorded["sensor.not_recorded"] = False
await mock_energy_manager.async_update(
{"device_consumption": [{"stat_consumption": "sensor.not_recorded"}]}
)
assert (await validate.async_validate(hass)).as_dict() == {
"energy_sources": [],
"device_consumption": [
[
{
"type": "recorder_untracked",
"identifier": "sensor.not_recorded",
"value": None,
}
]
],
}
async def test_validation_solar(hass, mock_energy_manager):
"""Test validating missing stat for device."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{"type": "solar", "stat_energy_from": "sensor.solar_production"}
]
}
)
hass.states.async_set(
"sensor.solar_production",
"10.10",
{"unit_of_measurement": "beers", "state_class": "total_increasing"},
)
assert (await validate.async_validate(hass)).as_dict() == {
"energy_sources": [
[
{
"type": "entity_unexpected_unit_energy",
"identifier": "sensor.solar_production",
"value": "beers",
}
]
],
"device_consumption": [],
}
async def test_validation_battery(hass, mock_energy_manager):
"""Test validating missing stat for device."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "battery",
"stat_energy_from": "sensor.battery_import",
"stat_energy_to": "sensor.battery_export",
}
]
}
)
hass.states.async_set(
"sensor.battery_import",
"10.10",
{"unit_of_measurement": "beers", "state_class": "total_increasing"},
)
hass.states.async_set(
"sensor.battery_export",
"10.10",
{"unit_of_measurement": "beers", "state_class": "total_increasing"},
)
assert (await validate.async_validate(hass)).as_dict() == {
"energy_sources": [
[
{
"type": "entity_unexpected_unit_energy",
"identifier": "sensor.battery_import",
"value": "beers",
},
{
"type": "entity_unexpected_unit_energy",
"identifier": "sensor.battery_export",
"value": "beers",
},
]
],
"device_consumption": [],
}
async def test_validation_grid(hass, mock_energy_manager, mock_is_entity_recorded):
"""Test validating grid with sensors for energy and cost/compensation."""
mock_is_entity_recorded["sensor.grid_cost_1"] = False
mock_is_entity_recorded["sensor.grid_compensation_1"] = False
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "grid",
"flow_from": [
{
"stat_energy_from": "sensor.grid_consumption_1",
"stat_cost": "sensor.grid_cost_1",
}
],
"flow_to": [
{
"stat_energy_to": "sensor.grid_production_1",
"stat_compensation": "sensor.grid_compensation_1",
}
],
}
]
}
)
hass.states.async_set(
"sensor.grid_consumption_1",
"10.10",
{"unit_of_measurement": "beers", "state_class": "total_increasing"},
)
hass.states.async_set(
"sensor.grid_production_1",
"10.10",
{"unit_of_measurement": "beers", "state_class": "total_increasing"},
)
assert (await validate.async_validate(hass)).as_dict() == {
"energy_sources": [
[
{
"type": "entity_unexpected_unit_energy",
"identifier": "sensor.grid_consumption_1",
"value": "beers",
},
{
"type": "recorder_untracked",
"identifier": "sensor.grid_cost_1",
"value": None,
},
{
"type": "entity_unexpected_unit_energy",
"identifier": "sensor.grid_production_1",
"value": "beers",
},
{
"type": "recorder_untracked",
"identifier": "sensor.grid_compensation_1",
"value": None,
},
]
],
"device_consumption": [],
}
async def test_validation_grid_price_not_exist(hass, mock_energy_manager):
"""Test validating grid with price entity that does not exist."""
hass.states.async_set(
"sensor.grid_consumption_1",
"10.10",
{"unit_of_measurement": "kWh", "state_class": "total_increasing"},
)
hass.states.async_set(
"sensor.grid_production_1",
"10.10",
{"unit_of_measurement": "kWh", "state_class": "total_increasing"},
)
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "grid",
"flow_from": [
{
"stat_energy_from": "sensor.grid_consumption_1",
"entity_energy_from": "sensor.grid_consumption_1",
"entity_energy_price": "sensor.grid_price_1",
}
],
"flow_to": [
{
"stat_energy_to": "sensor.grid_production_1",
"entity_energy_to": "sensor.grid_production_1",
"number_energy_price": 0.10,
}
],
}
]
}
)
await hass.async_block_till_done()
assert (await validate.async_validate(hass)).as_dict() == {
"energy_sources": [
[
{
"type": "entity_not_defined",
"identifier": "sensor.grid_price_1",
"value": None,
}
]
],
"device_consumption": [],
}
@pytest.mark.parametrize(
"state, unit, expected",
(
(
"123,123.12",
"$/kWh",
{
"type": "entity_state_non_numeric",
"identifier": "sensor.grid_price_1",
"value": "123,123.12",
},
),
(
"-100",
"$/kWh",
{
"type": "entity_negative_state",
"identifier": "sensor.grid_price_1",
"value": -100.0,
},
),
(
"123",
"$/Ws",
{
"type": "entity_unexpected_unit_price",
"identifier": "sensor.grid_price_1",
"value": "$/Ws",
},
),
),
)
async def test_validation_grid_price_errors(
hass, mock_energy_manager, state, unit, expected
):
"""Test validating grid with price data that gives errors."""
hass.states.async_set(
"sensor.grid_consumption_1",
"10.10",
{"unit_of_measurement": "kWh", "state_class": "total_increasing"},
)
hass.states.async_set(
"sensor.grid_price_1",
state,
{"unit_of_measurement": unit, "state_class": "total_increasing"},
)
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "grid",
"flow_from": [
{
"stat_energy_from": "sensor.grid_consumption_1",
"entity_energy_from": "sensor.grid_consumption_1",
"entity_energy_price": "sensor.grid_price_1",
}
],
"flow_to": [],
}
]
}
)
await hass.async_block_till_done()
assert (await validate.async_validate(hass)).as_dict() == {
"energy_sources": [
[expected],
],
"device_consumption": [],
}
...@@ -216,3 +216,19 @@ async def test_handle_duplicate_from_stat(hass, hass_ws_client) -> None: ...@@ -216,3 +216,19 @@ async def test_handle_duplicate_from_stat(hass, hass_ws_client) -> None:
assert msg["id"] == 5 assert msg["id"] == 5
assert not msg["success"] assert not msg["success"]
assert msg["error"]["code"] == "invalid_format" assert msg["error"]["code"] == "invalid_format"
async def test_validate(hass, hass_ws_client) -> None:
"""Test we can validate the preferences."""
client = await hass_ws_client(hass)
await client.send_json({"id": 5, "type": "energy/validate"})
msg = await client.receive_json()
assert msg["id"] == 5
assert msg["success"]
assert msg["result"] == {
"energy_sources": [],
"device_consumption": [],
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment