mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 20:27:08 +00:00
Add support to the energy integration for tracking water usage (#80888)
This commit is contained in:
parent
a4310d2085
commit
95fc641949
@ -87,13 +87,13 @@ class BatterySourceType(TypedDict):
|
|||||||
|
|
||||||
|
|
||||||
class GasSourceType(TypedDict):
|
class GasSourceType(TypedDict):
|
||||||
"""Dictionary holding the source of gas storage."""
|
"""Dictionary holding the source of gas consumption."""
|
||||||
|
|
||||||
type: Literal["gas"]
|
type: Literal["gas"]
|
||||||
|
|
||||||
stat_energy_from: str
|
stat_energy_from: str
|
||||||
|
|
||||||
# statistic_id of costs ($) incurred from the energy meter
|
# statistic_id of costs ($) incurred from the gas meter
|
||||||
# If set to None and entity_energy_price or number_energy_price are configured,
|
# If set to None and entity_energy_price or number_energy_price are configured,
|
||||||
# an EnergyCostSensor will be automatically created
|
# an EnergyCostSensor will be automatically created
|
||||||
stat_cost: str | None
|
stat_cost: str | None
|
||||||
@ -103,7 +103,26 @@ class GasSourceType(TypedDict):
|
|||||||
number_energy_price: float | None # Price for energy ($/m³)
|
number_energy_price: float | None # Price for energy ($/m³)
|
||||||
|
|
||||||
|
|
||||||
SourceType = Union[GridSourceType, SolarSourceType, BatterySourceType, GasSourceType]
|
class WaterSourceType(TypedDict):
|
||||||
|
"""Dictionary holding the source of water consumption."""
|
||||||
|
|
||||||
|
type: Literal["water"]
|
||||||
|
|
||||||
|
stat_energy_from: 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
|
||||||
|
stat_cost: str | None
|
||||||
|
|
||||||
|
# Used to generate costs if stat_cost is set to None
|
||||||
|
entity_energy_price: str | None # entity_id of an entity providing price ($/m³)
|
||||||
|
number_energy_price: float | None # Price for energy ($/m³)
|
||||||
|
|
||||||
|
|
||||||
|
SourceType = Union[
|
||||||
|
GridSourceType, SolarSourceType, BatterySourceType, GasSourceType, WaterSourceType
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class DeviceConsumption(TypedDict):
|
class DeviceConsumption(TypedDict):
|
||||||
@ -221,6 +240,15 @@ GAS_SOURCE_SCHEMA = vol.Schema(
|
|||||||
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
|
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
WATER_SOURCE_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "water",
|
||||||
|
vol.Required("stat_energy_from"): 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),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def check_type_limits(value: list[SourceType]) -> list[SourceType]:
|
def check_type_limits(value: list[SourceType]) -> list[SourceType]:
|
||||||
@ -243,6 +271,7 @@ ENERGY_SOURCE_SCHEMA = vol.All(
|
|||||||
"solar": SOLAR_SOURCE_SCHEMA,
|
"solar": SOLAR_SOURCE_SCHEMA,
|
||||||
"battery": BATTERY_SOURCE_SCHEMA,
|
"battery": BATTERY_SOURCE_SCHEMA,
|
||||||
"gas": GAS_SOURCE_SCHEMA,
|
"gas": GAS_SOURCE_SCHEMA,
|
||||||
|
"water": WATER_SOURCE_SCHEMA,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
@ -19,6 +19,8 @@ from homeassistant.const import (
|
|||||||
ATTR_UNIT_OF_MEASUREMENT,
|
ATTR_UNIT_OF_MEASUREMENT,
|
||||||
VOLUME_CUBIC_FEET,
|
VOLUME_CUBIC_FEET,
|
||||||
VOLUME_CUBIC_METERS,
|
VOLUME_CUBIC_METERS,
|
||||||
|
VOLUME_GALLONS,
|
||||||
|
VOLUME_LITERS,
|
||||||
UnitOfEnergy,
|
UnitOfEnergy,
|
||||||
)
|
)
|
||||||
from homeassistant.core import (
|
from homeassistant.core import (
|
||||||
@ -49,6 +51,12 @@ VALID_ENERGY_UNITS = [
|
|||||||
UnitOfEnergy.GIGA_JOULE,
|
UnitOfEnergy.GIGA_JOULE,
|
||||||
]
|
]
|
||||||
VALID_ENERGY_UNITS_GAS = [VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS] + VALID_ENERGY_UNITS
|
VALID_ENERGY_UNITS_GAS = [VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS] + VALID_ENERGY_UNITS
|
||||||
|
VALID_VOLUME_UNITS_WATER = [
|
||||||
|
VOLUME_CUBIC_FEET,
|
||||||
|
VOLUME_CUBIC_METERS,
|
||||||
|
VOLUME_GALLONS,
|
||||||
|
VOLUME_LITERS,
|
||||||
|
]
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -67,7 +75,7 @@ async def async_setup_platform(
|
|||||||
class SourceAdapter:
|
class SourceAdapter:
|
||||||
"""Adapter to allow sources and their flows to be used as sensors."""
|
"""Adapter to allow sources and their flows to be used as sensors."""
|
||||||
|
|
||||||
source_type: Literal["grid", "gas"]
|
source_type: Literal["grid", "gas", "water"]
|
||||||
flow_type: Literal["flow_from", "flow_to", None]
|
flow_type: Literal["flow_from", "flow_to", None]
|
||||||
stat_energy_key: Literal["stat_energy_from", "stat_energy_to"]
|
stat_energy_key: Literal["stat_energy_from", "stat_energy_to"]
|
||||||
total_money_key: Literal["stat_cost", "stat_compensation"]
|
total_money_key: Literal["stat_cost", "stat_compensation"]
|
||||||
@ -100,6 +108,14 @@ SOURCE_ADAPTERS: Final = (
|
|||||||
"Cost",
|
"Cost",
|
||||||
"cost",
|
"cost",
|
||||||
),
|
),
|
||||||
|
SourceAdapter(
|
||||||
|
"water",
|
||||||
|
None,
|
||||||
|
"stat_energy_from",
|
||||||
|
"stat_cost",
|
||||||
|
"Cost",
|
||||||
|
"cost",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -316,6 +332,10 @@ class EnergyCostSensor(SensorEntity):
|
|||||||
if energy_unit not in VALID_ENERGY_UNITS_GAS:
|
if energy_unit not in VALID_ENERGY_UNITS_GAS:
|
||||||
energy_unit = None
|
energy_unit = None
|
||||||
|
|
||||||
|
elif self._adapter.source_type == "water":
|
||||||
|
if energy_unit not in VALID_VOLUME_UNITS_WATER:
|
||||||
|
energy_unit = None
|
||||||
|
|
||||||
if energy_unit == UnitOfEnergy.WATT_HOUR:
|
if energy_unit == UnitOfEnergy.WATT_HOUR:
|
||||||
energy_price /= 1000
|
energy_price /= 1000
|
||||||
elif energy_unit == UnitOfEnergy.MEGA_WATT_HOUR:
|
elif energy_unit == UnitOfEnergy.MEGA_WATT_HOUR:
|
||||||
|
@ -13,6 +13,8 @@ from homeassistant.const import (
|
|||||||
STATE_UNKNOWN,
|
STATE_UNKNOWN,
|
||||||
VOLUME_CUBIC_FEET,
|
VOLUME_CUBIC_FEET,
|
||||||
VOLUME_CUBIC_METERS,
|
VOLUME_CUBIC_METERS,
|
||||||
|
VOLUME_GALLONS,
|
||||||
|
VOLUME_LITERS,
|
||||||
UnitOfEnergy,
|
UnitOfEnergy,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback, valid_entity_id
|
from homeassistant.core import HomeAssistant, callback, valid_entity_id
|
||||||
@ -52,6 +54,20 @@ GAS_PRICE_UNITS = tuple(
|
|||||||
)
|
)
|
||||||
GAS_UNIT_ERROR = "entity_unexpected_unit_gas"
|
GAS_UNIT_ERROR = "entity_unexpected_unit_gas"
|
||||||
GAS_PRICE_UNIT_ERROR = "entity_unexpected_unit_gas_price"
|
GAS_PRICE_UNIT_ERROR = "entity_unexpected_unit_gas_price"
|
||||||
|
WATER_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.WATER,)
|
||||||
|
WATER_USAGE_UNITS = {
|
||||||
|
sensor.SensorDeviceClass.WATER: (
|
||||||
|
VOLUME_CUBIC_METERS,
|
||||||
|
VOLUME_CUBIC_FEET,
|
||||||
|
VOLUME_GALLONS,
|
||||||
|
VOLUME_LITERS,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
WATER_PRICE_UNITS = tuple(
|
||||||
|
f"/{unit}" for units in WATER_USAGE_UNITS.values() for unit in units
|
||||||
|
)
|
||||||
|
WATER_UNIT_ERROR = "entity_unexpected_unit_water"
|
||||||
|
WATER_PRICE_UNIT_ERROR = "entity_unexpected_unit_water_price"
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
@ -437,6 +453,57 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
elif source["type"] == "water":
|
||||||
|
wanted_statistics_metadata.add(source["stat_energy_from"])
|
||||||
|
validate_calls.append(
|
||||||
|
functools.partial(
|
||||||
|
_async_validate_usage_stat,
|
||||||
|
hass,
|
||||||
|
statistics_metadata,
|
||||||
|
source["stat_energy_from"],
|
||||||
|
WATER_USAGE_DEVICE_CLASSES,
|
||||||
|
WATER_USAGE_UNITS,
|
||||||
|
WATER_UNIT_ERROR,
|
||||||
|
source_result,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (stat_cost := source.get("stat_cost")) is not None:
|
||||||
|
wanted_statistics_metadata.add(stat_cost)
|
||||||
|
validate_calls.append(
|
||||||
|
functools.partial(
|
||||||
|
_async_validate_cost_stat,
|
||||||
|
hass,
|
||||||
|
statistics_metadata,
|
||||||
|
stat_cost,
|
||||||
|
source_result,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif source.get("entity_energy_price") is not None:
|
||||||
|
validate_calls.append(
|
||||||
|
functools.partial(
|
||||||
|
_async_validate_price_entity,
|
||||||
|
hass,
|
||||||
|
source["entity_energy_price"],
|
||||||
|
source_result,
|
||||||
|
WATER_PRICE_UNITS,
|
||||||
|
WATER_PRICE_UNIT_ERROR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
source.get("entity_energy_price") is not None
|
||||||
|
or source.get("number_energy_price") is not None
|
||||||
|
):
|
||||||
|
validate_calls.append(
|
||||||
|
functools.partial(
|
||||||
|
_async_validate_auto_generated_cost_entity,
|
||||||
|
hass,
|
||||||
|
source["stat_energy_from"],
|
||||||
|
source_result,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
elif source["type"] == "solar":
|
elif source["type"] == "solar":
|
||||||
wanted_statistics_metadata.add(source["stat_energy_from"])
|
wanted_statistics_metadata.add(source["stat_energy_from"])
|
||||||
validate_calls.append(
|
validate_calls.append(
|
||||||
|
@ -933,6 +933,57 @@ async def test_cost_sensor_handle_gas_kwh(
|
|||||||
assert state.state == "50.0"
|
assert state.state == "50.0"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("unit", (VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS))
|
||||||
|
async def test_cost_sensor_handle_water(
|
||||||
|
setup_integration, hass, hass_storage, unit
|
||||||
|
) -> None:
|
||||||
|
"""Test water cost price from sensor entity."""
|
||||||
|
energy_attributes = {
|
||||||
|
ATTR_UNIT_OF_MEASUREMENT: unit,
|
||||||
|
ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING,
|
||||||
|
}
|
||||||
|
energy_data = data.EnergyManager.default_preferences()
|
||||||
|
energy_data["energy_sources"].append(
|
||||||
|
{
|
||||||
|
"type": "water",
|
||||||
|
"stat_energy_from": "sensor.water_consumption",
|
||||||
|
"stat_cost": None,
|
||||||
|
"entity_energy_price": None,
|
||||||
|
"number_energy_price": 0.5,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
hass_storage[data.STORAGE_KEY] = {
|
||||||
|
"version": 1,
|
||||||
|
"data": energy_data,
|
||||||
|
}
|
||||||
|
|
||||||
|
now = dt_util.utcnow()
|
||||||
|
|
||||||
|
hass.states.async_set(
|
||||||
|
"sensor.water_consumption",
|
||||||
|
100,
|
||||||
|
energy_attributes,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("homeassistant.util.dt.utcnow", return_value=now):
|
||||||
|
await setup_integration(hass)
|
||||||
|
|
||||||
|
state = hass.states.get("sensor.water_consumption_cost")
|
||||||
|
assert state.state == "0.0"
|
||||||
|
|
||||||
|
# water use bumped to 200 ft³/m³
|
||||||
|
hass.states.async_set(
|
||||||
|
"sensor.water_consumption",
|
||||||
|
200,
|
||||||
|
energy_attributes,
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("sensor.water_consumption_cost")
|
||||||
|
assert state.state == "50.0"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("state_class", [None])
|
@pytest.mark.parametrize("state_class", [None])
|
||||||
async def test_cost_sensor_wrong_state_class(
|
async def test_cost_sensor_wrong_state_class(
|
||||||
setup_integration, hass, hass_storage, caplog, state_class
|
setup_integration, hass, hass_storage, caplog, state_class
|
||||||
|
@ -946,3 +946,162 @@ async def test_validation_grid_no_costs_tracking(
|
|||||||
"energy_sources": [[]],
|
"energy_sources": [[]],
|
||||||
"device_consumption": [],
|
"device_consumption": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_validation_water(
|
||||||
|
hass, mock_energy_manager, mock_is_entity_recorded, mock_get_metadata
|
||||||
|
):
|
||||||
|
"""Test validating water with sensors for energy and cost/compensation."""
|
||||||
|
mock_is_entity_recorded["sensor.water_cost_1"] = False
|
||||||
|
mock_is_entity_recorded["sensor.water_compensation_1"] = False
|
||||||
|
await mock_energy_manager.async_update(
|
||||||
|
{
|
||||||
|
"energy_sources": [
|
||||||
|
{
|
||||||
|
"type": "water",
|
||||||
|
"stat_energy_from": "sensor.water_consumption_1",
|
||||||
|
"stat_cost": "sensor.water_cost_1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "water",
|
||||||
|
"stat_energy_from": "sensor.water_consumption_2",
|
||||||
|
"stat_cost": "sensor.water_cost_2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "water",
|
||||||
|
"stat_energy_from": "sensor.water_consumption_3",
|
||||||
|
"stat_cost": "sensor.water_cost_2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "water",
|
||||||
|
"stat_energy_from": "sensor.water_consumption_4",
|
||||||
|
"entity_energy_price": "sensor.water_price_1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "water",
|
||||||
|
"stat_energy_from": "sensor.water_consumption_3",
|
||||||
|
"entity_energy_price": "sensor.water_price_2",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
hass.states.async_set(
|
||||||
|
"sensor.water_consumption_1",
|
||||||
|
"10.10",
|
||||||
|
{
|
||||||
|
"device_class": "water",
|
||||||
|
"unit_of_measurement": "beers",
|
||||||
|
"state_class": "total_increasing",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
hass.states.async_set(
|
||||||
|
"sensor.water_consumption_2",
|
||||||
|
"10.10",
|
||||||
|
{
|
||||||
|
"device_class": "water",
|
||||||
|
"unit_of_measurement": "ft³",
|
||||||
|
"state_class": "total_increasing",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
hass.states.async_set(
|
||||||
|
"sensor.water_consumption_3",
|
||||||
|
"10.10",
|
||||||
|
{
|
||||||
|
"device_class": "water",
|
||||||
|
"unit_of_measurement": "m³",
|
||||||
|
"state_class": "total_increasing",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
hass.states.async_set(
|
||||||
|
"sensor.water_consumption_4",
|
||||||
|
"10.10",
|
||||||
|
{"unit_of_measurement": "beers", "state_class": "total_increasing"},
|
||||||
|
)
|
||||||
|
hass.states.async_set(
|
||||||
|
"sensor.water_cost_2",
|
||||||
|
"10.10",
|
||||||
|
{"unit_of_measurement": "EUR/kWh", "state_class": "total_increasing"},
|
||||||
|
)
|
||||||
|
hass.states.async_set(
|
||||||
|
"sensor.water_price_1",
|
||||||
|
"10.10",
|
||||||
|
{"unit_of_measurement": "EUR/m³", "state_class": "total_increasing"},
|
||||||
|
)
|
||||||
|
hass.states.async_set(
|
||||||
|
"sensor.water_price_2",
|
||||||
|
"10.10",
|
||||||
|
{"unit_of_measurement": "EUR/invalid", "state_class": "total_increasing"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (await validate.async_validate(hass)).as_dict() == {
|
||||||
|
"energy_sources": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "entity_unexpected_unit_water",
|
||||||
|
"identifier": "sensor.water_consumption_1",
|
||||||
|
"value": "beers",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "recorder_untracked",
|
||||||
|
"identifier": "sensor.water_cost_1",
|
||||||
|
"value": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "entity_not_defined",
|
||||||
|
"identifier": "sensor.water_cost_1",
|
||||||
|
"value": None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "entity_unexpected_device_class",
|
||||||
|
"identifier": "sensor.water_consumption_4",
|
||||||
|
"value": None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "entity_unexpected_unit_water_price",
|
||||||
|
"identifier": "sensor.water_price_2",
|
||||||
|
"value": "EUR/invalid",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
"device_consumption": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_validation_water_no_costs_tracking(
|
||||||
|
hass, mock_energy_manager, mock_is_entity_recorded, mock_get_metadata
|
||||||
|
):
|
||||||
|
"""Test validating water with sensors without cost tracking."""
|
||||||
|
await mock_energy_manager.async_update(
|
||||||
|
{
|
||||||
|
"energy_sources": [
|
||||||
|
{
|
||||||
|
"type": "water",
|
||||||
|
"stat_energy_from": "sensor.water_consumption_1",
|
||||||
|
"stat_cost": None,
|
||||||
|
"entity_energy_price": None,
|
||||||
|
"number_energy_price": None,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
hass.states.async_set(
|
||||||
|
"sensor.water_consumption_1",
|
||||||
|
"10.10",
|
||||||
|
{
|
||||||
|
"device_class": "water",
|
||||||
|
"unit_of_measurement": "m³",
|
||||||
|
"state_class": "total_increasing",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (await validate.async_validate(hass)).as_dict() == {
|
||||||
|
"energy_sources": [[]],
|
||||||
|
"device_consumption": [],
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user