Skip to content

Commit

Permalink
Improve sensor statistics validation (home-assistant#56892)
Browse files Browse the repository at this point in the history
  • Loading branch information
emontnemery authored Oct 4, 2021
1 parent 2f9943f commit 69875cb
Show file tree
Hide file tree
Showing 3 changed files with 486 additions and 212 deletions.
59 changes: 47 additions & 12 deletions homeassistant/components/sensor/recorder.py
Original file line number Diff line number Diff line change
Expand Up @@ -637,35 +637,70 @@ def validate_statistics(
"""Validate statistics."""
validation_result = defaultdict(list)

sensor_states = _get_sensor_states(hass)
sensor_states = hass.states.all(DOMAIN)
metadatas = statistics.get_metadata(hass, [i.entity_id for i in sensor_states])

for state in sensor_states:
entity_id = state.entity_id
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
state_class = state.attributes.get(ATTR_STATE_CLASS)
state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)

if device_class not in UNIT_CONVERSIONS:
metadata = statistics.get_metadata(hass, (entity_id,))
if not metadata:
continue
metadata_unit = metadata[entity_id][1]["unit_of_measurement"]
if state_unit != metadata_unit:
if metadata := metadatas.get(entity_id):
if not is_entity_recorded(hass, state.entity_id):
# Sensor was previously recorded, but no longer is
validation_result[entity_id].append(
statistics.ValidationIssue(
"entity_not_recorded",
{"statistic_id": entity_id},
)
)

if state_class not in STATE_CLASSES:
# Sensor no longer has a valid state class
validation_result[entity_id].append(
statistics.ValidationIssue(
"unsupported_state_class",
{"statistic_id": entity_id, "state_class": state_class},
)
)

metadata_unit = metadata[1]["unit_of_measurement"]
if device_class not in UNIT_CONVERSIONS:
if state_unit != metadata_unit:
# The unit has changed
validation_result[entity_id].append(
statistics.ValidationIssue(
"units_changed",
{
"statistic_id": entity_id,
"state_unit": state_unit,
"metadata_unit": metadata_unit,
},
)
)
elif metadata_unit != DEVICE_CLASS_UNITS[device_class]:
# The unit in metadata is not supported for this device class
validation_result[entity_id].append(
statistics.ValidationIssue(
"units_changed",
"unsupported_unit_metadata",
{
"statistic_id": entity_id,
"state_unit": state_unit,
"device_class": device_class,
"metadata_unit": metadata_unit,
"supported_unit": DEVICE_CLASS_UNITS[device_class],
},
)
)
continue

if state_unit not in UNIT_CONVERSIONS[device_class]:
if (
device_class in UNIT_CONVERSIONS
and state_unit not in UNIT_CONVERSIONS[device_class]
):
# The unit in the state is not supported for this device class
validation_result[entity_id].append(
statistics.ValidationIssue(
"unsupported_unit",
"unsupported_unit_state",
{
"statistic_id": entity_id,
"device_class": device_class,
Expand Down
202 changes: 3 additions & 199 deletions tests/components/recorder/test_websocket_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,56 +6,28 @@
from pytest import approx

from homeassistant.components.recorder.const import DATA_INSTANCE
from homeassistant.components.recorder.models import StatisticsMeta
from homeassistant.components.recorder.util import session_scope
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM
from homeassistant.util.unit_system import METRIC_SYSTEM

from .common import trigger_db_commit

from tests.common import init_recorder_component

BATTERY_SENSOR_ATTRIBUTES = {
"device_class": "battery",
"state_class": "measurement",
"unit_of_measurement": "%",
}
POWER_SENSOR_ATTRIBUTES = {
"device_class": "power",
"state_class": "measurement",
"unit_of_measurement": "kW",
}
NONE_SENSOR_ATTRIBUTES = {
"state_class": "measurement",
}
PRESSURE_SENSOR_ATTRIBUTES = {
"device_class": "pressure",
"state_class": "measurement",
"unit_of_measurement": "hPa",
}
TEMPERATURE_SENSOR_ATTRIBUTES = {
"device_class": "temperature",
"state_class": "measurement",
"unit_of_measurement": "°C",
}


@pytest.mark.parametrize(
"units, attributes, unit",
[
(IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"),
(METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"),
(IMPERIAL_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°F"),
(METRIC_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°C"),
(IMPERIAL_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "psi"),
(METRIC_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "Pa"),
],
)
async def test_validate_statistics_supported_device_class(
hass, hass_ws_client, units, attributes, unit
):
"""Test list_statistic_ids."""
async def test_validate_statistics(hass, hass_ws_client):
"""Test validate_statistics can be called."""
id = 1

def next_id():
Expand All @@ -71,177 +43,9 @@ async def assert_validation_result(client, expected_result):
assert response["success"]
assert response["result"] == expected_result

now = dt_util.utcnow()

hass.config.units = units
await hass.async_add_executor_job(init_recorder_component, hass)
await async_setup_component(hass, "sensor", {})
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
client = await hass_ws_client()

# No statistics, no state - empty response
await assert_validation_result(client, {})

# No statistics, valid state - empty response
hass.states.async_set(
"sensor.test", 10, attributes={**attributes, **{"unit_of_measurement": unit}}
)
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
await assert_validation_result(client, {})

# No statistics, invalid state - expect error
hass.states.async_set(
"sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": "dogs"}}
)
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
expected = {
"sensor.test": [
{
"data": {
"device_class": attributes["device_class"],
"state_unit": "dogs",
"statistic_id": "sensor.test",
},
"type": "unsupported_unit",
}
],
}
await assert_validation_result(client, expected)

# Statistics has run, invalid state - expect error
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now)
hass.states.async_set(
"sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": "dogs"}}
)
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
await assert_validation_result(client, expected)

# Valid state - empty response
hass.states.async_set(
"sensor.test", 13, attributes={**attributes, **{"unit_of_measurement": unit}}
)
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
await assert_validation_result(client, {})

# Valid state, statistic runs again - empty response
hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now)
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
await assert_validation_result(client, {})

# Remove the state - empty response
hass.states.async_remove("sensor.test")
await assert_validation_result(client, {})


@pytest.mark.parametrize(
"attributes",
[BATTERY_SENSOR_ATTRIBUTES, NONE_SENSOR_ATTRIBUTES],
)
async def test_validate_statistics_unsupported_device_class(
hass, hass_ws_client, attributes
):
"""Test list_statistic_ids."""
id = 1

def next_id():
nonlocal id
id += 1
return id

async def assert_validation_result(client, expected_result):
await client.send_json(
{"id": next_id(), "type": "recorder/validate_statistics"}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == expected_result

async def assert_statistic_ids(expected_result):
with session_scope(hass=hass) as session:
db_states = list(session.query(StatisticsMeta))
assert len(db_states) == len(expected_result)
for i in range(len(db_states)):
assert db_states[i].statistic_id == expected_result[i]["statistic_id"]
assert (
db_states[i].unit_of_measurement
== expected_result[i]["unit_of_measurement"]
)

now = dt_util.utcnow()

await hass.async_add_executor_job(init_recorder_component, hass)
await async_setup_component(hass, "sensor", {})
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
client = await hass_ws_client()
rec = hass.data[DATA_INSTANCE]

# No statistics, no state - empty response
await assert_validation_result(client, {})

# No statistics, original unit - empty response
hass.states.async_set("sensor.test", 10, attributes=attributes)
await assert_validation_result(client, {})

# No statistics, changed unit - empty response
hass.states.async_set(
"sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": "dogs"}}
)
await assert_validation_result(client, {})

# Run statistics, no statistics will be generated because of conflicting units
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
rec.do_adhoc_statistics(start=now)
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
await assert_statistic_ids([])

# No statistics, changed unit - empty response
hass.states.async_set(
"sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": "dogs"}}
)
await assert_validation_result(client, {})

# Run statistics one hour later, only the "dogs" state will be considered
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
rec.do_adhoc_statistics(start=now + timedelta(hours=1))
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
await assert_statistic_ids(
[{"statistic_id": "sensor.test", "unit_of_measurement": "dogs"}]
)
await assert_validation_result(client, {})

# Change back to original unit - expect error
hass.states.async_set("sensor.test", 13, attributes=attributes)
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
expected = {
"sensor.test": [
{
"data": {
"metadata_unit": "dogs",
"state_unit": attributes.get("unit_of_measurement"),
"statistic_id": "sensor.test",
},
"type": "units_changed",
}
],
}
await assert_validation_result(client, expected)

# Changed unit - empty response
hass.states.async_set(
"sensor.test", 14, attributes={**attributes, **{"unit_of_measurement": "dogs"}}
)
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
await assert_validation_result(client, {})

# Valid state, statistic runs again - empty response
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now)
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
await assert_validation_result(client, {})

# Remove the state - empty response
hass.states.async_remove("sensor.test")
await assert_validation_result(client, {})


Expand Down
Loading

0 comments on commit 69875cb

Please sign in to comment.