1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-08 17:49:37 +01:00

Add flow rate (stat_rate) tracking for gas and water (#163274)

This commit is contained in:
Petar Petrov
2026-02-19 18:08:16 +01:00
committed by GitHub
parent 05abe7efe0
commit 36c560b7bf
4 changed files with 671 additions and 0 deletions
+8
View File
@@ -173,6 +173,9 @@ class GasSourceType(TypedDict):
stat_energy_from: str
# Instantaneous flow rate: m³/h, L/min, etc.
stat_rate: NotRequired[str]
# statistic_id of costs ($) incurred from the gas meter
# If set to None and entity_energy_price or number_energy_price are configured,
# an EnergyCostSensor will be automatically created
@@ -190,6 +193,9 @@ class WaterSourceType(TypedDict):
stat_energy_from: str
# Instantaneous flow rate: L/min, gal/min, m³/h, etc.
stat_rate: NotRequired[str]
# statistic_id of costs ($) incurred from the water meter
# If set to None and entity_energy_price or number_energy_price are configured,
# an EnergyCostSensor will be automatically created
@@ -440,6 +446,7 @@ GAS_SOURCE_SCHEMA = vol.Schema(
{
vol.Required("type"): "gas",
vol.Required("stat_energy_from"): str,
vol.Optional("stat_rate"): str,
vol.Optional("stat_cost"): vol.Any(str, None),
# entity_energy_from was removed in HA Core 2022.10
vol.Remove("entity_energy_from"): vol.Any(str, None),
@@ -451,6 +458,7 @@ WATER_SOURCE_SCHEMA = vol.Schema(
{
vol.Required("type"): "water",
vol.Required("stat_energy_from"): str,
vol.Optional("stat_rate"): str,
vol.Optional("stat_cost"): vol.Any(str, None),
vol.Optional("entity_energy_price"): vol.Any(str, None),
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
@@ -44,6 +44,10 @@
"description": "[%key:component::energy::issues::entity_unexpected_unit_energy_price::description%]",
"title": "[%key:component::energy::issues::entity_unexpected_unit_energy::title%]"
},
"entity_unexpected_unit_volume_flow_rate": {
"description": "The following entities do not have an expected unit of measurement (either of {flow_rate_units}):",
"title": "[%key:component::energy::issues::entity_unexpected_unit_energy::title%]"
},
"entity_unexpected_unit_water": {
"description": "The following entities do not have the expected unit of measurement (either of {water_units}):",
"title": "[%key:component::energy::issues::entity_unexpected_unit_energy::title%]"
@@ -14,6 +14,7 @@ from homeassistant.const import (
UnitOfEnergy,
UnitOfPower,
UnitOfVolume,
UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant, callback, valid_entity_id
@@ -28,6 +29,11 @@ POWER_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.POWER,)
POWER_USAGE_UNITS: dict[str, tuple[UnitOfPower, ...]] = {
sensor.SensorDeviceClass.POWER: tuple(UnitOfPower)
}
VOLUME_FLOW_RATE_DEVICE_CLASSES = (sensor.SensorDeviceClass.VOLUME_FLOW_RATE,)
VOLUME_FLOW_RATE_UNITS: dict[str, tuple[UnitOfVolumeFlowRate, ...]] = {
sensor.SensorDeviceClass.VOLUME_FLOW_RATE: tuple(UnitOfVolumeFlowRate)
}
VOLUME_FLOW_RATE_UNIT_ERROR = "entity_unexpected_unit_volume_flow_rate"
ENERGY_PRICE_UNITS = tuple(
f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units
@@ -109,6 +115,12 @@ def _get_placeholders(hass: HomeAssistant, issue_type: str) -> dict[str, str] |
return {
"price_units": ", ".join(f"{currency}{unit}" for unit in WATER_PRICE_UNITS),
}
if issue_type == VOLUME_FLOW_RATE_UNIT_ERROR:
return {
"flow_rate_units": ", ".join(
VOLUME_FLOW_RATE_UNITS[sensor.SensorDeviceClass.VOLUME_FLOW_RATE]
),
}
return None
@@ -590,6 +602,21 @@ def _validate_gas_source(
)
)
if stat_rate := source.get("stat_rate"):
wanted_statistics_metadata.add(stat_rate)
validate_calls.append(
functools.partial(
_async_validate_power_stat,
hass,
statistics_metadata,
stat_rate,
VOLUME_FLOW_RATE_DEVICE_CLASSES,
VOLUME_FLOW_RATE_UNITS,
VOLUME_FLOW_RATE_UNIT_ERROR,
source_result,
)
)
def _validate_water_source(
hass: HomeAssistant,
@@ -650,6 +677,21 @@ def _validate_water_source(
)
)
if stat_rate := source.get("stat_rate"):
wanted_statistics_metadata.add(stat_rate)
validate_calls.append(
functools.partial(
_async_validate_power_stat,
hass,
statistics_metadata,
stat_rate,
VOLUME_FLOW_RATE_DEVICE_CLASSES,
VOLUME_FLOW_RATE_UNITS,
VOLUME_FLOW_RATE_UNIT_ERROR,
source_result,
)
)
async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
"""Validate the energy configuration."""
@@ -0,0 +1,617 @@
"""Test flow rate (stat_rate) validation for gas and water sources."""
import pytest
from homeassistant.components.energy import validate
from homeassistant.components.energy.data import EnergyManager
from homeassistant.const import UnitOfVolumeFlowRate
from homeassistant.core import HomeAssistant
FLOW_RATE_UNITS_STRING = ", ".join(tuple(UnitOfVolumeFlowRate))
@pytest.fixture(autouse=True)
async def setup_energy_for_validation(
mock_energy_manager: EnergyManager,
) -> EnergyManager:
"""Ensure energy manager is set up for validation tests."""
return mock_energy_manager
async def test_validation_gas_flow_rate_valid(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating gas with valid flow rate sensor."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "gas",
"stat_energy_from": "sensor.gas_consumption",
"stat_rate": "sensor.gas_flow_rate",
}
]
}
)
hass.states.async_set(
"sensor.gas_consumption",
"10.10",
{
"device_class": "gas",
"unit_of_measurement": "",
"state_class": "total_increasing",
},
)
hass.states.async_set(
"sensor.gas_flow_rate",
"1.5",
{
"device_class": "volume_flow_rate",
"unit_of_measurement": UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
"state_class": "measurement",
},
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [[]],
"device_consumption": [],
"device_consumption_water": [],
}
async def test_validation_gas_flow_rate_wrong_unit(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating gas with flow rate sensor having wrong unit."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "gas",
"stat_energy_from": "sensor.gas_consumption",
"stat_rate": "sensor.gas_flow_rate",
}
]
}
)
hass.states.async_set(
"sensor.gas_consumption",
"10.10",
{
"device_class": "gas",
"unit_of_measurement": "",
"state_class": "total_increasing",
},
)
hass.states.async_set(
"sensor.gas_flow_rate",
"1.5",
{
"device_class": "volume_flow_rate",
"unit_of_measurement": "beers",
"state_class": "measurement",
},
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [
[
{
"type": "entity_unexpected_unit_volume_flow_rate",
"affected_entities": {("sensor.gas_flow_rate", "beers")},
"translation_placeholders": {
"flow_rate_units": FLOW_RATE_UNITS_STRING
},
}
]
],
"device_consumption": [],
"device_consumption_water": [],
}
async def test_validation_gas_flow_rate_wrong_state_class(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating gas with flow rate sensor having wrong state class."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "gas",
"stat_energy_from": "sensor.gas_consumption",
"stat_rate": "sensor.gas_flow_rate",
}
]
}
)
hass.states.async_set(
"sensor.gas_consumption",
"10.10",
{
"device_class": "gas",
"unit_of_measurement": "",
"state_class": "total_increasing",
},
)
hass.states.async_set(
"sensor.gas_flow_rate",
"1.5",
{
"device_class": "volume_flow_rate",
"unit_of_measurement": UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
"state_class": "total_increasing",
},
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [
[
{
"type": "entity_unexpected_state_class",
"affected_entities": {("sensor.gas_flow_rate", "total_increasing")},
"translation_placeholders": None,
}
]
],
"device_consumption": [],
"device_consumption_water": [],
}
async def test_validation_gas_flow_rate_entity_missing(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating gas with missing flow rate sensor."""
mock_get_metadata["sensor.missing_flow_rate"] = None
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "gas",
"stat_energy_from": "sensor.gas_consumption",
"stat_rate": "sensor.missing_flow_rate",
}
]
}
)
hass.states.async_set(
"sensor.gas_consumption",
"10.10",
{
"device_class": "gas",
"unit_of_measurement": "",
"state_class": "total_increasing",
},
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [
[
{
"type": "statistics_not_defined",
"affected_entities": {("sensor.missing_flow_rate", None)},
"translation_placeholders": None,
},
{
"type": "entity_not_defined",
"affected_entities": {("sensor.missing_flow_rate", None)},
"translation_placeholders": None,
},
]
],
"device_consumption": [],
"device_consumption_water": [],
}
async def test_validation_gas_without_flow_rate(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating gas without flow rate sensor still works."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "gas",
"stat_energy_from": "sensor.gas_consumption",
}
]
}
)
hass.states.async_set(
"sensor.gas_consumption",
"10.10",
{
"device_class": "gas",
"unit_of_measurement": "",
"state_class": "total_increasing",
},
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [[]],
"device_consumption": [],
"device_consumption_water": [],
}
async def test_validation_water_flow_rate_valid(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating water with valid flow rate sensor."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "water",
"stat_energy_from": "sensor.water_consumption",
"stat_rate": "sensor.water_flow_rate",
}
]
}
)
hass.states.async_set(
"sensor.water_consumption",
"10.10",
{
"device_class": "water",
"unit_of_measurement": "",
"state_class": "total_increasing",
},
)
hass.states.async_set(
"sensor.water_flow_rate",
"2.5",
{
"device_class": "volume_flow_rate",
"unit_of_measurement": UnitOfVolumeFlowRate.LITERS_PER_MINUTE,
"state_class": "measurement",
},
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [[]],
"device_consumption": [],
"device_consumption_water": [],
}
async def test_validation_water_flow_rate_wrong_unit(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating water with flow rate sensor having wrong unit."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "water",
"stat_energy_from": "sensor.water_consumption",
"stat_rate": "sensor.water_flow_rate",
}
]
}
)
hass.states.async_set(
"sensor.water_consumption",
"10.10",
{
"device_class": "water",
"unit_of_measurement": "",
"state_class": "total_increasing",
},
)
hass.states.async_set(
"sensor.water_flow_rate",
"2.5",
{
"device_class": "volume_flow_rate",
"unit_of_measurement": "beers",
"state_class": "measurement",
},
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [
[
{
"type": "entity_unexpected_unit_volume_flow_rate",
"affected_entities": {("sensor.water_flow_rate", "beers")},
"translation_placeholders": {
"flow_rate_units": FLOW_RATE_UNITS_STRING
},
}
]
],
"device_consumption": [],
"device_consumption_water": [],
}
async def test_validation_water_flow_rate_wrong_state_class(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating water with flow rate sensor having wrong state class."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "water",
"stat_energy_from": "sensor.water_consumption",
"stat_rate": "sensor.water_flow_rate",
}
]
}
)
hass.states.async_set(
"sensor.water_consumption",
"10.10",
{
"device_class": "water",
"unit_of_measurement": "",
"state_class": "total_increasing",
},
)
hass.states.async_set(
"sensor.water_flow_rate",
"2.5",
{
"device_class": "volume_flow_rate",
"unit_of_measurement": UnitOfVolumeFlowRate.LITERS_PER_MINUTE,
"state_class": "total_increasing",
},
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [
[
{
"type": "entity_unexpected_state_class",
"affected_entities": {
("sensor.water_flow_rate", "total_increasing")
},
"translation_placeholders": None,
}
]
],
"device_consumption": [],
"device_consumption_water": [],
}
async def test_validation_water_flow_rate_entity_missing(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating water with missing flow rate sensor."""
mock_get_metadata["sensor.missing_flow_rate"] = None
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "water",
"stat_energy_from": "sensor.water_consumption",
"stat_rate": "sensor.missing_flow_rate",
}
]
}
)
hass.states.async_set(
"sensor.water_consumption",
"10.10",
{
"device_class": "water",
"unit_of_measurement": "",
"state_class": "total_increasing",
},
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [
[
{
"type": "statistics_not_defined",
"affected_entities": {("sensor.missing_flow_rate", None)},
"translation_placeholders": None,
},
{
"type": "entity_not_defined",
"affected_entities": {("sensor.missing_flow_rate", None)},
"translation_placeholders": None,
},
]
],
"device_consumption": [],
"device_consumption_water": [],
}
async def test_validation_water_without_flow_rate(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating water without flow rate sensor still works."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "water",
"stat_energy_from": "sensor.water_consumption",
}
]
}
)
hass.states.async_set(
"sensor.water_consumption",
"10.10",
{
"device_class": "water",
"unit_of_measurement": "",
"state_class": "total_increasing",
},
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [[]],
"device_consumption": [],
"device_consumption_water": [],
}
async def test_validation_gas_flow_rate_different_units(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating gas with flow rate sensors using different valid units."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "gas",
"stat_energy_from": "sensor.gas_consumption_1",
"stat_rate": "sensor.gas_flow_m3h",
},
{
"type": "gas",
"stat_energy_from": "sensor.gas_consumption_2",
"stat_rate": "sensor.gas_flow_lmin",
},
]
}
)
hass.states.async_set(
"sensor.gas_consumption_1",
"10.10",
{
"device_class": "gas",
"unit_of_measurement": "",
"state_class": "total_increasing",
},
)
hass.states.async_set(
"sensor.gas_consumption_2",
"20.20",
{
"device_class": "gas",
"unit_of_measurement": "",
"state_class": "total_increasing",
},
)
hass.states.async_set(
"sensor.gas_flow_m3h",
"1.5",
{
"device_class": "volume_flow_rate",
"unit_of_measurement": UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
"state_class": "measurement",
},
)
hass.states.async_set(
"sensor.gas_flow_lmin",
"25.0",
{
"device_class": "volume_flow_rate",
"unit_of_measurement": UnitOfVolumeFlowRate.LITERS_PER_MINUTE,
"state_class": "measurement",
},
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [[], []],
"device_consumption": [],
"device_consumption_water": [],
}
async def test_validation_gas_flow_rate_recorder_untracked(
hass: HomeAssistant, mock_energy_manager, mock_is_entity_recorded, mock_get_metadata
) -> None:
"""Test validating gas with flow rate sensor not tracked by recorder."""
mock_is_entity_recorded["sensor.untracked_flow_rate"] = False
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "gas",
"stat_energy_from": "sensor.gas_consumption",
"stat_rate": "sensor.untracked_flow_rate",
}
]
}
)
hass.states.async_set(
"sensor.gas_consumption",
"10.10",
{
"device_class": "gas",
"unit_of_measurement": "",
"state_class": "total_increasing",
},
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [
[
{
"type": "recorder_untracked",
"affected_entities": {("sensor.untracked_flow_rate", None)},
"translation_placeholders": None,
}
]
],
"device_consumption": [],
"device_consumption_water": [],
}
async def test_validation_water_flow_rate_recorder_untracked(
hass: HomeAssistant, mock_energy_manager, mock_is_entity_recorded, mock_get_metadata
) -> None:
"""Test validating water with flow rate sensor not tracked by recorder."""
mock_is_entity_recorded["sensor.untracked_flow_rate"] = False
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "water",
"stat_energy_from": "sensor.water_consumption",
"stat_rate": "sensor.untracked_flow_rate",
}
]
}
)
hass.states.async_set(
"sensor.water_consumption",
"10.10",
{
"device_class": "water",
"unit_of_measurement": "",
"state_class": "total_increasing",
},
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [
[
{
"type": "recorder_untracked",
"affected_entities": {("sensor.untracked_flow_rate", None)},
"translation_placeholders": None,
}
]
],
"device_consumption": [],
"device_consumption_water": [],
}