Improve unit handling in recorder (#153941)

This commit is contained in:
Erik Montnemery
2025-10-09 10:29:42 +02:00
committed by GitHub
parent 2eba650064
commit d45114cd11
30 changed files with 2303 additions and 116 deletions

View File

@@ -29,6 +29,7 @@ from homeassistant.components.recorder.statistics import (
async_add_external_statistics,
async_import_statistics,
async_list_statistic_ids,
async_update_statistics_metadata,
get_last_short_term_statistics,
get_last_statistics,
get_latest_short_term_statistics_with_session,
@@ -48,6 +49,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_system import METRIC_SYSTEM
from .common import (
assert_dict_of_states_equal_without_context_and_last_changed,
@@ -63,6 +65,12 @@ from tests.common import MockPlatform, MockUser, mock_platform
from tests.typing import RecorderInstanceContextManager, WebSocketGenerator
from tests.util.test_unit_conversion import _ALL_CONVERTERS
POWER_SENSOR_KW_ATTRIBUTES = {
"device_class": "power",
"state_class": "measurement",
"unit_of_measurement": "kW",
}
@pytest.fixture
def multiple_start_time_chunk_sizes(
@@ -397,6 +405,7 @@ def mock_sensor_statistics():
"has_sum": False,
"name": None,
"statistic_id": entity_id,
"unit_class": None,
"unit_of_measurement": "dogs",
},
"stat": {"start": start},
@@ -839,7 +848,18 @@ async def test_statistics_duplicated(
caplog.clear()
# Integration frame mocked because of deprecation warnings about missing
# unit_class, can be removed in HA Core 2025.11
@pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"])
@pytest.mark.usefixtures("mock_integration_frame")
@pytest.mark.parametrize("last_reset_str", ["2022-01-01T00:00:00+02:00", None])
@pytest.mark.parametrize(
("external_metadata_extra"),
[
{},
{"unit_class": "energy"},
],
)
@pytest.mark.parametrize(
("source", "statistic_id", "import_fn"),
[
@@ -852,6 +872,7 @@ async def test_import_statistics(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
caplog: pytest.LogCaptureFixture,
external_metadata_extra: dict[str, str],
source,
statistic_id,
import_fn,
@@ -889,7 +910,7 @@ async def test_import_statistics(
"source": source,
"statistic_id": statistic_id,
"unit_of_measurement": "kWh",
}
} | external_metadata_extra
import_fn(hass, external_metadata, (external_statistics1, external_statistics2))
await async_wait_recording_done(hass)
@@ -939,6 +960,7 @@ async def test_import_statistics(
"name": "Total imported energy",
"source": source,
"statistic_id": statistic_id,
"unit_class": "energy",
"unit_of_measurement": "kWh",
},
)
@@ -1031,6 +1053,7 @@ async def test_import_statistics(
"name": "Total imported energy renamed",
"source": source,
"statistic_id": statistic_id,
"unit_class": "energy",
"unit_of_measurement": "kWh",
},
)
@@ -1119,6 +1142,7 @@ async def test_external_statistics_errors(
"name": "Total imported energy",
"source": "test",
"statistic_id": "test:total_energy_import",
"unit_class": "energy",
"unit_of_measurement": "kWh",
}
@@ -1207,6 +1231,7 @@ async def test_import_statistics_errors(
"name": "Total imported energy",
"source": "recorder",
"statistic_id": "sensor.total_energy_import",
"unit_class": "energy",
"unit_of_measurement": "kWh",
}
@@ -1270,6 +1295,213 @@ async def test_import_statistics_errors(
assert get_metadata(hass, statistic_ids={"sensor.total_energy_import"}) == {}
# Integration frame mocked because of deprecation warnings about missing
# unit_class, can be removed in HA Core 2025.11
@pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"])
@pytest.mark.usefixtures("mock_integration_frame")
@pytest.mark.parametrize(
(
"requested_new_unit",
"update_statistics_extra",
"new_unit",
"new_unit_class",
"new_display_unit",
),
[
("dogs", {}, "dogs", None, "dogs"),
("dogs", {"new_unit_class": None}, "dogs", None, "dogs"),
(None, {}, None, "unitless", None),
(None, {"new_unit_class": "unitless"}, None, "unitless", None),
("W", {}, "W", "power", "kW"),
("W", {"new_unit_class": "power"}, "W", "power", "kW"),
# Note: Display unit is guessed even if unit_class is None
("W", {"new_unit_class": None}, "W", None, "kW"),
],
)
@pytest.mark.usefixtures("recorder_mock")
async def test_update_statistics_metadata(
hass: HomeAssistant,
requested_new_unit,
update_statistics_extra,
new_unit,
new_unit_class,
new_display_unit,
) -> None:
"""Test removing statistics."""
now = get_start_time(dt_util.utcnow())
units = METRIC_SYSTEM
attributes = POWER_SENSOR_KW_ATTRIBUTES | {"device_class": None}
state = 10
hass.config.units = units
await async_setup_component(hass, "sensor", {})
await async_recorder_block_till_done(hass)
hass.states.async_set(
"sensor.test", state, attributes=attributes, timestamp=now.timestamp()
)
await async_wait_recording_done(hass)
do_adhoc_statistics(hass, period="hourly", start=now)
await async_recorder_block_till_done(hass)
statistic_ids = await async_list_statistic_ids(hass)
assert statistic_ids == [
{
"statistic_id": "sensor.test",
"display_unit_of_measurement": "kW",
"has_mean": True,
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
"name": None,
"source": "recorder",
"statistics_unit_of_measurement": "kW",
"unit_class": "power",
}
]
async_update_statistics_metadata(
hass,
"sensor.test",
new_unit_of_measurement=requested_new_unit,
**update_statistics_extra,
)
await async_recorder_block_till_done(hass)
statistic_ids = await async_list_statistic_ids(hass)
assert statistic_ids == [
{
"statistic_id": "sensor.test",
"display_unit_of_measurement": new_display_unit,
"has_mean": True,
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
"name": None,
"source": "recorder",
"statistics_unit_of_measurement": new_unit,
"unit_class": new_unit_class,
}
]
assert statistics_during_period(
hass,
now,
period="5minute",
statistic_ids={"sensor.test"},
units={"power": "W"},
) == {
"sensor.test": [
{
"end": (now + timedelta(minutes=5)).timestamp(),
"last_reset": None,
"max": 10.0,
"mean": 10.0,
"min": 10.0,
"start": now.timestamp(),
}
],
}
@pytest.mark.parametrize(
(
"requested_new_unit",
"update_statistics_extra",
"error_message",
),
[
("dogs", {"new_unit_class": "cats"}, "Unsupported unit_class: 'cats'"),
(
"dogs",
{"new_unit_class": "power"},
"Unsupported unit_of_measurement 'dogs' for unit_class 'power'",
),
],
)
@pytest.mark.usefixtures("recorder_mock")
async def test_update_statistics_metadata_error(
hass: HomeAssistant,
requested_new_unit,
update_statistics_extra,
error_message,
) -> None:
"""Test removing statistics."""
now = get_start_time(dt_util.utcnow())
units = METRIC_SYSTEM
attributes = POWER_SENSOR_KW_ATTRIBUTES | {"device_class": None}
state = 10
hass.config.units = units
await async_setup_component(hass, "sensor", {})
await async_recorder_block_till_done(hass)
hass.states.async_set(
"sensor.test", state, attributes=attributes, timestamp=now.timestamp()
)
await async_wait_recording_done(hass)
do_adhoc_statistics(hass, period="hourly", start=now)
await async_recorder_block_till_done(hass)
statistic_ids = await async_list_statistic_ids(hass)
assert statistic_ids == [
{
"statistic_id": "sensor.test",
"display_unit_of_measurement": "kW",
"has_mean": True,
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
"name": None,
"source": "recorder",
"statistics_unit_of_measurement": "kW",
"unit_class": "power",
}
]
with pytest.raises(HomeAssistantError, match=error_message):
async_update_statistics_metadata(
hass,
"sensor.test",
new_unit_of_measurement=requested_new_unit,
**update_statistics_extra,
)
await async_recorder_block_till_done(hass)
statistic_ids = await async_list_statistic_ids(hass)
assert statistic_ids == [
{
"statistic_id": "sensor.test",
"display_unit_of_measurement": "kW",
"has_mean": True,
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
"name": None,
"source": "recorder",
"statistics_unit_of_measurement": "kW",
"unit_class": "power",
}
]
assert statistics_during_period(
hass,
now,
period="5minute",
statistic_ids={"sensor.test"},
units={"power": "W"},
) == {
"sensor.test": [
{
"end": (now + timedelta(minutes=5)).timestamp(),
"last_reset": None,
"max": 10000.0,
"mean": 10000.0,
"min": 10000.0,
"start": now.timestamp(),
}
],
}
@pytest.mark.usefixtures("multiple_start_time_chunk_sizes")
@pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"])
@pytest.mark.freeze_time("2022-10-01 00:00:00+00:00")
@@ -1337,6 +1569,7 @@ async def test_daily_statistics_sum(
"name": "Total imported energy",
"source": "test",
"statistic_id": "test:total_energy_import",
"unit_class": "energy",
"unit_of_measurement": "kWh",
}
@@ -1518,6 +1751,7 @@ async def test_multiple_daily_statistics_sum(
"name": "Total imported energy 1",
"source": "test",
"statistic_id": "test:total_energy_import2",
"unit_class": "energy",
"unit_of_measurement": "kWh",
}
external_metadata2 = {
@@ -1526,6 +1760,7 @@ async def test_multiple_daily_statistics_sum(
"name": "Total imported energy 2",
"source": "test",
"statistic_id": "test:total_energy_import1",
"unit_class": "energy",
"unit_of_measurement": "kWh",
}
@@ -1716,6 +1951,7 @@ async def test_weekly_statistics_mean(
"name": "Total imported energy",
"source": "test",
"statistic_id": "test:total_energy_import",
"unit_class": "energy",
"unit_of_measurement": "kWh",
}
@@ -1862,6 +2098,7 @@ async def test_weekly_statistics_sum(
"name": "Total imported energy",
"source": "test",
"statistic_id": "test:total_energy_import",
"unit_class": "energy",
"unit_of_measurement": "kWh",
}
@@ -2043,6 +2280,7 @@ async def test_monthly_statistics_sum(
"name": "Total imported energy",
"source": "test",
"statistic_id": "test:total_energy_import",
"unit_class": "energy",
"unit_of_measurement": "kWh",
}
@@ -2372,6 +2610,7 @@ async def test_change(
"name": "Total imported energy",
"source": "recorder",
"statistic_id": "sensor.total_energy_import",
"unit_class": "energy",
"unit_of_measurement": "kWh",
}
@@ -2708,6 +2947,7 @@ async def test_change_multiple(
"name": "Total imported energy",
"source": "recorder",
"statistic_id": "sensor.total_energy_import1",
"unit_class": "energy",
"unit_of_measurement": "kWh",
}
external_metadata2 = {
@@ -2716,6 +2956,7 @@ async def test_change_multiple(
"name": "Total imported energy",
"source": "recorder",
"statistic_id": "sensor.total_energy_import2",
"unit_class": "energy",
"unit_of_measurement": "kWh",
}
async_import_statistics(hass, external_metadata1, external_statistics)
@@ -3097,6 +3338,7 @@ async def test_change_with_none(
"name": "Total imported energy",
"source": "test",
"statistic_id": "test:total_energy_import",
"unit_class": "energy",
"unit_of_measurement": "kWh",
}
@@ -3651,6 +3893,7 @@ async def test_get_statistics_service(
"name": "Total imported energy",
"source": "recorder",
"statistic_id": "sensor.total_energy_import1",
"unit_class": "energy",
"unit_of_measurement": "kWh",
}
external_metadata2 = {
@@ -3659,6 +3902,7 @@ async def test_get_statistics_service(
"name": "Total imported energy",
"source": "recorder",
"statistic_id": "sensor.total_energy_import2",
"unit_class": "energy",
"unit_of_measurement": "kWh",
}
async_import_statistics(hass, external_metadata1, external_statistics)