1
mirror of https://github.com/home-assistant/core synced 2024-09-15 17:29:45 +02:00

Tweak energy validator (#58018)

* Tweak energy validator

* Update code and tests

* Tweak implementation

* Update tests

* Update after rebase
This commit is contained in:
Erik Montnemery 2021-10-22 10:38:04 +02:00 committed by GitHub
parent d67c1118dc
commit 547e36ae94
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 311 additions and 74 deletions

View File

@ -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

View File

@ -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,

View File

@ -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(