diff --git a/homeassistant/components/energy/manifest.json b/homeassistant/components/energy/manifest.json index 3a3cbeff4e7aba143ff990090a61fb21ad3d40e1..5ddc6457a616d22a7b9c21b6c52052a9b8c2ad2b 100644 --- a/homeassistant/components/energy/manifest.json +++ b/homeassistant/components/energy/manifest.json @@ -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" } diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py new file mode 100644 index 0000000000000000000000000000000000000000..01709081d686e601e8602393423072258ff3b381 --- /dev/null +++ b/homeassistant/components/energy/validate.py @@ -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 diff --git a/homeassistant/components/energy/websocket_api.py b/homeassistant/components/energy/websocket_api.py index d1c8869a1c29f26f525ec050ab3e1be3d5e628ed..6d71a75b9b402ec780a327a52e75af31aa129a2e 100644 --- a/homeassistant/components/energy/websocket_api.py +++ b/homeassistant/components/energy/websocket_api.py @@ -18,6 +18,7 @@ from .data import ( EnergyPreferencesUpdate, async_get_manager, ) +from .validate import async_validate EnergyWebSocketCommandHandler = Callable[ [HomeAssistant, websocket_api.ActiveConnection, Dict[str, Any], "EnergyManager"], @@ -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( @@ -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()) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index e9c12e5f88ad7688a72683a42fd61e2faebc9b73..e6c15729d243898db3f0995d918e920c21626ded 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -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. diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py new file mode 100644 index 0000000000000000000000000000000000000000..9a0b21050075ec4fce60bb420e244efe5c40447a --- /dev/null +++ b/tests/components/energy/test_validate.py @@ -0,0 +1,443 @@ +"""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": [], + } diff --git a/tests/components/energy/test_websocket_api.py b/tests/components/energy/test_websocket_api.py index 60ac82108bc80d1c4520679353adb9a28875241c..732bdaa93cf50bba6422732d4a7bbbe52b5ce3e9 100644 --- a/tests/components/energy/test_websocket_api.py +++ b/tests/components/energy/test_websocket_api.py @@ -216,3 +216,19 @@ async def test_handle_duplicate_from_stat(hass, hass_ws_client) -> None: assert msg["id"] == 5 assert not msg["success"] 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": [], + }