diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 462c3f3215d..1d919e07737 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -23,7 +23,13 @@ from homeassistant.const import ( ENERGY_WATT_HOUR, VOLUME_CUBIC_METERS, ) -from homeassistant.core import HomeAssistant, State, callback, split_entity_id +from homeassistant.core import ( + HomeAssistant, + State, + callback, + split_entity_id, + valid_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 @@ -177,9 +183,13 @@ class SensorManager: # 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 + if ( + config.get(adapter.entity_energy_key) is None + or not valid_entity_id(config[adapter.entity_energy_key]) + or ( + config.get("entity_energy_price") is None + and config.get("number_energy_price") is None + ) ): return diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index 24d060b4352..c7f6c46aa1c 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping, Sequence import dataclasses +import functools from typing import Any from homeassistant.components import recorder, sensor @@ -66,56 +67,68 @@ class EnergyPreferencesValidation: return dataclasses.asdict(self) -@callback -def _async_validate_usage_stat( +async def _async_validate_usage_stat( hass: HomeAssistant, - stat_value: str, + stat_id: str, allowed_device_classes: Sequence[str], allowed_units: Mapping[str, Sequence[str]], unit_error: str, result: list[ValidationIssue], ) -> None: """Validate a statistic.""" - has_entity_source = valid_entity_id(stat_value) + metadata = await hass.async_add_executor_job( + functools.partial( + recorder.statistics.get_metadata, + hass, + statistic_ids=(stat_id,), + ) + ) + + if stat_id not in metadata: + result.append(ValidationIssue("statistics_not_defined", stat_id)) + + has_entity_source = valid_entity_id(stat_id) if not has_entity_source: return - if not recorder.is_entity_recorded(hass, stat_value): + entity_id = stat_id + + if not recorder.is_entity_recorded(hass, entity_id): result.append( ValidationIssue( "recorder_untracked", - stat_value, + entity_id, ) ) return - state = hass.states.get(stat_value) + state = hass.states.get(entity_id) if state is None: result.append( ValidationIssue( "entity_not_defined", - stat_value, + entity_id, ) ) return if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): - result.append(ValidationIssue("entity_unavailable", stat_value, state.state)) + result.append(ValidationIssue("entity_unavailable", entity_id, state.state)) return try: current_value: float | None = float(state.state) except ValueError: result.append( - ValidationIssue("entity_state_non_numeric", stat_value, state.state) + ValidationIssue("entity_state_non_numeric", entity_id, state.state) ) return if current_value is not None and current_value < 0: result.append( - ValidationIssue("entity_negative_state", stat_value, current_value) + ValidationIssue("entity_negative_state", entity_id, current_value) ) device_class = state.attributes.get(ATTR_DEVICE_CLASS) @@ -123,7 +136,7 @@ def _async_validate_usage_stat( result.append( ValidationIssue( "entity_unexpected_device_class", - stat_value, + entity_id, device_class, ) ) @@ -131,7 +144,7 @@ def _async_validate_usage_stat( unit = state.attributes.get("unit_of_measurement") if device_class and unit not in allowed_units.get(device_class, []): - result.append(ValidationIssue(unit_error, stat_value, unit)) + result.append(ValidationIssue(unit_error, entity_id, unit)) state_class = state.attributes.get(sensor.ATTR_STATE_CLASS) @@ -144,7 +157,7 @@ def _async_validate_usage_stat( result.append( ValidationIssue( "entity_unexpected_state_class", - stat_value, + entity_id, state_class, ) ) @@ -154,7 +167,7 @@ def _async_validate_usage_stat( and sensor.ATTR_LAST_RESET not in state.attributes ): result.append( - ValidationIssue("entity_state_class_measurement_no_last_reset", stat_value) + ValidationIssue("entity_state_class_measurement_no_last_reset", entity_id) ) @@ -192,33 +205,33 @@ def _async_validate_price_entity( result.append(ValidationIssue(unit_error, entity_id, unit)) -@callback -def _async_validate_cost_stat( +async def _async_validate_cost_stat( hass: HomeAssistant, stat_id: str, result: list[ValidationIssue] ) -> None: """Validate that the cost stat is correct.""" + metadata = await hass.async_add_executor_job( + functools.partial( + recorder.statistics.get_metadata, + hass, + statistic_ids=(stat_id,), + ) + ) + + if stat_id not in metadata: + result.append(ValidationIssue("statistics_not_defined", stat_id)) + 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, - ) - ) + result.append(ValidationIssue("recorder_untracked", stat_id)) state = hass.states.get(stat_id) if state is None: - result.append( - ValidationIssue( - "entity_not_defined", - stat_id, - ) - ) + result.append(ValidationIssue("entity_not_defined", stat_id)) return state_class = state.attributes.get("state_class") @@ -244,16 +257,16 @@ def _async_validate_cost_stat( @callback def _async_validate_auto_generated_cost_entity( - hass: HomeAssistant, entity_id: str, result: list[ValidationIssue] + hass: HomeAssistant, energy_entity_id: str, result: list[ValidationIssue] ) -> None: """Validate that the auto generated cost entity is correct.""" - if not recorder.is_entity_recorded(hass, entity_id): - result.append( - ValidationIssue( - "recorder_untracked", - entity_id, - ) - ) + if energy_entity_id not in hass.data[DOMAIN]["cost_sensors"]: + # The cost entity has not been setup + return + + cost_entity_id = hass.data[DOMAIN]["cost_sensors"][energy_entity_id] + if not recorder.is_entity_recorded(hass, cost_entity_id): + result.append(ValidationIssue("recorder_untracked", cost_entity_id)) async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: @@ -271,7 +284,7 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: if source["type"] == "grid": for flow in source["flow_from"]: - _async_validate_usage_stat( + await _async_validate_usage_stat( hass, flow["stat_energy_from"], ENERGY_USAGE_DEVICE_CLASSES, @@ -281,7 +294,9 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: ) if flow.get("stat_cost") is not None: - _async_validate_cost_stat(hass, flow["stat_cost"], source_result) + await _async_validate_cost_stat( + hass, flow["stat_cost"], source_result + ) elif flow.get("entity_energy_price") is not None: _async_validate_price_entity( hass, @@ -291,18 +306,18 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: ENERGY_PRICE_UNIT_ERROR, ) - if ( + if flow.get("entity_energy_from") is not None and ( flow.get("entity_energy_price") is not None or flow.get("number_energy_price") is not None ): _async_validate_auto_generated_cost_entity( hass, - hass.data[DOMAIN]["cost_sensors"][flow["stat_energy_from"]], + flow["entity_energy_from"], source_result, ) for flow in source["flow_to"]: - _async_validate_usage_stat( + await _async_validate_usage_stat( hass, flow["stat_energy_to"], ENERGY_USAGE_DEVICE_CLASSES, @@ -312,7 +327,7 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: ) if flow.get("stat_compensation") is not None: - _async_validate_cost_stat( + await _async_validate_cost_stat( hass, flow["stat_compensation"], source_result ) elif flow.get("entity_energy_price") is not None: @@ -324,18 +339,18 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: ENERGY_PRICE_UNIT_ERROR, ) - if ( + if flow.get("entity_energy_to") is not None and ( flow.get("entity_energy_price") is not None or flow.get("number_energy_price") is not None ): _async_validate_auto_generated_cost_entity( hass, - hass.data[DOMAIN]["cost_sensors"][flow["stat_energy_to"]], + flow["entity_energy_to"], source_result, ) elif source["type"] == "gas": - _async_validate_usage_stat( + await _async_validate_usage_stat( hass, source["stat_energy_from"], GAS_USAGE_DEVICE_CLASSES, @@ -345,7 +360,9 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: ) if source.get("stat_cost") is not None: - _async_validate_cost_stat(hass, source["stat_cost"], source_result) + await _async_validate_cost_stat( + hass, source["stat_cost"], source_result + ) elif source.get("entity_energy_price") is not None: _async_validate_price_entity( hass, @@ -355,18 +372,18 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: GAS_PRICE_UNIT_ERROR, ) - if ( + if source.get("entity_energy_from") is not None and ( source.get("entity_energy_price") is not None or source.get("number_energy_price") is not None ): _async_validate_auto_generated_cost_entity( hass, - hass.data[DOMAIN]["cost_sensors"][source["stat_energy_from"]], + source["entity_energy_from"], source_result, ) elif source["type"] == "solar": - _async_validate_usage_stat( + await _async_validate_usage_stat( hass, source["stat_energy_from"], ENERGY_USAGE_DEVICE_CLASSES, @@ -376,7 +393,7 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: ) elif source["type"] == "battery": - _async_validate_usage_stat( + await _async_validate_usage_stat( hass, source["stat_energy_from"], ENERGY_USAGE_DEVICE_CLASSES, @@ -384,7 +401,7 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: ENERGY_UNIT_ERROR, source_result, ) - _async_validate_usage_stat( + await _async_validate_usage_stat( hass, source["stat_energy_to"], ENERGY_USAGE_DEVICE_CLASSES, @@ -396,7 +413,7 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: for device in manager.data["device_consumption"]: device_result: list[ValidationIssue] = [] result.device_consumption.append(device_result) - _async_validate_usage_stat( + await _async_validate_usage_stat( hass, device["stat_consumption"], ENERGY_USAGE_DEVICE_CLASSES, diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index 668f3113fea..5e3ad5c4aff 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -21,6 +21,20 @@ def mock_is_entity_recorded(): yield mocks +@pytest.fixture +def mock_get_metadata(): + """Mock recorder.statistics.get_metadata.""" + mocks = {} + + with patch( + "homeassistant.components.recorder.statistics.get_metadata", + side_effect=lambda hass, statistic_ids: mocks.get( + statistic_ids[0], {statistic_ids[0]: (1, {})} + ), + ): + yield mocks + + @pytest.fixture(autouse=True) async def mock_energy_manager(hass): """Set up energy.""" @@ -48,7 +62,9 @@ async def test_validation_empty_config(hass): ("measurement", {"last_reset": "abc"}), ], ) -async def test_validation(hass, mock_energy_manager, state_class, extra): +async def test_validation( + hass, mock_energy_manager, mock_get_metadata, state_class, extra +): """Test validating success.""" for key in ("device_cons", "battery_import", "battery_export", "solar_production"): hass.states.async_set( @@ -82,7 +98,7 @@ async def test_validation(hass, mock_energy_manager, state_class, extra): async def test_validation_device_consumption_entity_missing(hass, mock_energy_manager): - """Test validating missing stat for device.""" + """Test validating missing entity for device.""" await mock_energy_manager.async_update( {"device_consumption": [{"stat_consumption": "sensor.not_exist"}]} ) @@ -90,10 +106,34 @@ async def test_validation_device_consumption_entity_missing(hass, mock_energy_ma "energy_sources": [], "device_consumption": [ [ + { + "type": "statistics_not_defined", + "identifier": "sensor.not_exist", + "value": None, + }, { "type": "entity_not_defined", "identifier": "sensor.not_exist", "value": None, + }, + ] + ], + } + + +async def test_validation_device_consumption_stat_missing(hass, mock_energy_manager): + """Test validating missing statistic for device with non entity stats.""" + await mock_energy_manager.async_update( + {"device_consumption": [{"stat_consumption": "external:not_exist"}]} + ) + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [], + "device_consumption": [ + [ + { + "type": "statistics_not_defined", + "identifier": "external:not_exist", + "value": None, } ] ], @@ -101,7 +141,7 @@ async def test_validation_device_consumption_entity_missing(hass, mock_energy_ma async def test_validation_device_consumption_entity_unavailable( - hass, mock_energy_manager + hass, mock_energy_manager, mock_get_metadata ): """Test validating missing stat for device.""" await mock_energy_manager.async_update( @@ -124,7 +164,7 @@ async def test_validation_device_consumption_entity_unavailable( async def test_validation_device_consumption_entity_non_numeric( - hass, mock_energy_manager + hass, mock_energy_manager, mock_get_metadata ): """Test validating missing stat for device.""" await mock_energy_manager.async_update( @@ -147,7 +187,7 @@ async def test_validation_device_consumption_entity_non_numeric( async def test_validation_device_consumption_entity_unexpected_unit( - hass, mock_energy_manager + hass, mock_energy_manager, mock_get_metadata ): """Test validating missing stat for device.""" await mock_energy_manager.async_update( @@ -178,7 +218,7 @@ async def test_validation_device_consumption_entity_unexpected_unit( async def test_validation_device_consumption_recorder_not_tracked( - hass, mock_energy_manager, mock_is_entity_recorded + hass, mock_energy_manager, mock_is_entity_recorded, mock_get_metadata ): """Test validating device based on untracked entity.""" mock_is_entity_recorded["sensor.not_recorded"] = False @@ -200,7 +240,9 @@ async def test_validation_device_consumption_recorder_not_tracked( } -async def test_validation_device_consumption_no_last_reset(hass, mock_energy_manager): +async def test_validation_device_consumption_no_last_reset( + hass, mock_energy_manager, mock_get_metadata +): """Test validating device based on untracked entity.""" await mock_energy_manager.async_update( {"device_consumption": [{"stat_consumption": "sensor.no_last_reset"}]} @@ -229,7 +271,7 @@ async def test_validation_device_consumption_no_last_reset(hass, mock_energy_man } -async def test_validation_solar(hass, mock_energy_manager): +async def test_validation_solar(hass, mock_energy_manager, mock_get_metadata): """Test validating missing stat for device.""" await mock_energy_manager.async_update( { @@ -262,7 +304,7 @@ async def test_validation_solar(hass, mock_energy_manager): } -async def test_validation_battery(hass, mock_energy_manager): +async def test_validation_battery(hass, mock_energy_manager, mock_get_metadata): """Test validating missing stat for device.""" await mock_energy_manager.async_update( { @@ -313,10 +355,14 @@ async def test_validation_battery(hass, mock_energy_manager): } -async def test_validation_grid(hass, mock_energy_manager, mock_is_entity_recorded): +async def test_validation_grid( + hass, mock_energy_manager, mock_is_entity_recorded, mock_get_metadata +): """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 + mock_get_metadata["sensor.grid_cost_1"] = {} + mock_get_metadata["sensor.grid_compensation_1"] = {} await mock_energy_manager.async_update( { "energy_sources": [ @@ -365,6 +411,11 @@ async def test_validation_grid(hass, mock_energy_manager, mock_is_entity_recorde "identifier": "sensor.grid_consumption_1", "value": "beers", }, + { + "type": "statistics_not_defined", + "identifier": "sensor.grid_cost_1", + "value": None, + }, { "type": "recorder_untracked", "identifier": "sensor.grid_cost_1", @@ -380,6 +431,11 @@ async def test_validation_grid(hass, mock_energy_manager, mock_is_entity_recorde "identifier": "sensor.grid_production_1", "value": "beers", }, + { + "type": "statistics_not_defined", + "identifier": "sensor.grid_compensation_1", + "value": None, + }, { "type": "recorder_untracked", "identifier": "sensor.grid_compensation_1", @@ -396,8 +452,91 @@ async def test_validation_grid(hass, mock_energy_manager, mock_is_entity_recorde } -async def test_validation_grid_price_not_exist(hass, mock_energy_manager): - """Test validating grid with price entity that does not exist.""" +async def test_validation_grid_external_cost_compensation( + hass, mock_energy_manager, mock_is_entity_recorded, mock_get_metadata +): + """Test validating grid with non entity stats for energy and cost/compensation.""" + mock_get_metadata["external:grid_cost_1"] = {} + mock_get_metadata["external:grid_compensation_1"] = {} + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.grid_consumption_1", + "stat_cost": "external:grid_cost_1", + } + ], + "flow_to": [ + { + "stat_energy_to": "sensor.grid_production_1", + "stat_compensation": "external:grid_compensation_1", + } + ], + } + ] + } + ) + hass.states.async_set( + "sensor.grid_consumption_1", + "10.10", + { + "device_class": "energy", + "unit_of_measurement": "beers", + "state_class": "total_increasing", + }, + ) + hass.states.async_set( + "sensor.grid_production_1", + "10.10", + { + "device_class": "energy", + "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": "statistics_not_defined", + "identifier": "external:grid_cost_1", + "value": None, + }, + { + "type": "entity_unexpected_unit_energy", + "identifier": "sensor.grid_production_1", + "value": "beers", + }, + { + "type": "statistics_not_defined", + "identifier": "external:grid_compensation_1", + "value": None, + }, + ] + ], + "device_consumption": [], + } + + +async def test_validation_grid_price_not_exist( + hass, mock_energy_manager, mock_get_metadata, mock_is_entity_recorded +): + """Test validating grid with errors. + + - The price entity for the auto generated cost entity does not exist. + - The auto generated cost entities are not recorded. + """ + mock_is_entity_recorded["sensor.grid_consumption_1_cost"] = False + mock_is_entity_recorded["sensor.grid_production_1_compensation"] = False hass.states.async_set( "sensor.grid_consumption_1", "10.10", @@ -450,13 +589,82 @@ async def test_validation_grid_price_not_exist(hass, mock_energy_manager): "type": "entity_not_defined", "identifier": "sensor.grid_price_1", "value": None, - } + }, + { + "type": "recorder_untracked", + "identifier": "sensor.grid_consumption_1_cost", + "value": None, + }, + { + "type": "recorder_untracked", + "identifier": "sensor.grid_production_1_compensation", + "value": None, + }, ] ], "device_consumption": [], } +async def test_validation_grid_auto_cost_entity_errors( + hass, mock_energy_manager, mock_get_metadata, mock_is_entity_recorded, caplog +): + """Test validating grid when the auto generated cost entity config is incorrect. + + The intention of the test is to make sure the validation does not throw due to the + bad config. + """ + hass.states.async_set( + "sensor.grid_consumption_1", + "10.10", + { + "device_class": "energy", + "unit_of_measurement": "kWh", + "state_class": "total_increasing", + }, + ) + hass.states.async_set( + "sensor.grid_production_1", + "10.10", + { + "device_class": "energy", + "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": None, + "entity_energy_price": None, + "number_energy_price": 0.20, + } + ], + "flow_to": [ + { + "stat_energy_to": "sensor.grid_production_1", + "entity_energy_to": "invalid", + "entity_energy_price": None, + "number_energy_price": 0.10, + } + ], + } + ] + } + ) + await hass.async_block_till_done() + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [[]], + "device_consumption": [], + } + + @pytest.mark.parametrize( "state, unit, expected", ( @@ -481,7 +689,7 @@ async def test_validation_grid_price_not_exist(hass, mock_energy_manager): ), ) async def test_validation_grid_price_errors( - hass, mock_energy_manager, state, unit, expected + hass, mock_energy_manager, mock_get_metadata, state, unit, expected ): """Test validating grid with price data that gives errors.""" hass.states.async_set( @@ -526,7 +734,9 @@ async def test_validation_grid_price_errors( } -async def test_validation_gas(hass, mock_energy_manager, mock_is_entity_recorded): +async def test_validation_gas( + hass, mock_energy_manager, mock_is_entity_recorded, mock_get_metadata +): """Test validating gas with sensors for energy and cost/compensation.""" mock_is_entity_recorded["sensor.gas_cost_1"] = False mock_is_entity_recorded["sensor.gas_compensation_1"] = False @@ -653,7 +863,7 @@ async def test_validation_gas(hass, mock_energy_manager, mock_is_entity_recorded async def test_validation_gas_no_costs_tracking( - hass, mock_energy_manager, mock_is_entity_recorded + hass, mock_energy_manager, mock_is_entity_recorded, mock_get_metadata ): """Test validating gas with sensors without cost tracking.""" await mock_energy_manager.async_update( @@ -687,7 +897,7 @@ async def test_validation_gas_no_costs_tracking( async def test_validation_grid_no_costs_tracking( - hass, mock_energy_manager, mock_is_entity_recorded + hass, mock_energy_manager, mock_is_entity_recorded, mock_get_metadata ): """Test validating grid with sensors for energy without cost tracking.""" await mock_energy_manager.async_update(