Add water price sensor to suez water (#130141)

* Suez water: add water price sensor

* sensor description

* clean up
This commit is contained in:
jb101010-2 2024-11-09 10:57:22 +01:00 committed by GitHub
parent d11012b2b7
commit 701f35488c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 175 additions and 48 deletions

View File

@ -1,6 +1,11 @@
"""Suez water update coordinator.""" """Suez water update coordinator."""
from pysuez import AggregatedData, PySuezError, SuezClient from collections.abc import Mapping
from dataclasses import dataclass
from datetime import date
from typing import Any
from pysuez import PySuezError, SuezClient
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
@ -11,7 +16,28 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import CONF_COUNTER_ID, DATA_REFRESH_INTERVAL, DOMAIN from .const import CONF_COUNTER_ID, DATA_REFRESH_INTERVAL, DOMAIN
class SuezWaterCoordinator(DataUpdateCoordinator[AggregatedData]): @dataclass
class SuezWaterAggregatedAttributes:
"""Class containing aggregated sensor extra attributes."""
this_month_consumption: dict[date, float]
previous_month_consumption: dict[date, float]
last_year_overall: dict[str, float]
this_year_overall: dict[str, float]
history: dict[date, float]
highest_monthly_consumption: float
@dataclass
class SuezWaterData:
"""Class used to hold all fetch data from suez api."""
aggregated_value: float
aggregated_attr: Mapping[str, Any]
price: float
class SuezWaterCoordinator(DataUpdateCoordinator[SuezWaterData]):
"""Suez water coordinator.""" """Suez water coordinator."""
_suez_client: SuezClient _suez_client: SuezClient
@ -37,10 +63,22 @@ class SuezWaterCoordinator(DataUpdateCoordinator[AggregatedData]):
if not await self._suez_client.check_credentials(): if not await self._suez_client.check_credentials():
raise ConfigEntryError("Invalid credentials for suez water") raise ConfigEntryError("Invalid credentials for suez water")
async def _async_update_data(self) -> AggregatedData: async def _async_update_data(self) -> SuezWaterData:
"""Fetch data from API endpoint.""" """Fetch data from API endpoint."""
try: try:
data = await self._suez_client.fetch_aggregated_data() aggregated = await self._suez_client.fetch_aggregated_data()
data = SuezWaterData(
aggregated_value=aggregated.value,
aggregated_attr={
"this_month_consumption": aggregated.current_month,
"previous_month_consumption": aggregated.previous_month,
"highest_monthly_consumption": aggregated.highest_monthly_consumption,
"last_year_overall": aggregated.previous_year,
"this_year_overall": aggregated.current_year,
"history": aggregated.history,
},
price=(await self._suez_client.get_price()).price,
)
except PySuezError as err: except PySuezError as err:
_LOGGER.exception(err) _LOGGER.exception(err)
raise UpdateFailed( raise UpdateFailed(

View File

@ -2,19 +2,53 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any from typing import Any
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from pysuez.const import ATTRIBUTION
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfVolume from homeassistant.const import CURRENCY_EURO, UnitOfVolume
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CONF_COUNTER_ID, DOMAIN from .const import CONF_COUNTER_ID, DOMAIN
from .coordinator import SuezWaterCoordinator from .coordinator import SuezWaterCoordinator, SuezWaterData
@dataclass(frozen=True, kw_only=True)
class SuezWaterSensorEntityDescription(SensorEntityDescription):
"""Describes Suez water sensor entity."""
value_fn: Callable[[SuezWaterData], float | str | None]
attr_fn: Callable[[SuezWaterData], Mapping[str, Any] | None] = lambda _: None
SENSORS: tuple[SuezWaterSensorEntityDescription, ...] = (
SuezWaterSensorEntityDescription(
key="water_usage_yesterday",
translation_key="water_usage_yesterday",
native_unit_of_measurement=UnitOfVolume.LITERS,
device_class=SensorDeviceClass.WATER,
value_fn=lambda suez_data: suez_data.aggregated_value,
attr_fn=lambda suez_data: suez_data.aggregated_attr,
),
SuezWaterSensorEntityDescription(
key="water_price",
translation_key="water_price",
native_unit_of_measurement=CURRENCY_EURO,
device_class=SensorDeviceClass.MONETARY,
value_fn=lambda suez_data: suez_data.price,
),
)
async def async_setup_entry( async def async_setup_entry(
@ -24,46 +58,42 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up Suez Water sensor from a config entry.""" """Set up Suez Water sensor from a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id] coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities([SuezAggregatedSensor(coordinator, entry.data[CONF_COUNTER_ID])]) counter_id = entry.data[CONF_COUNTER_ID]
async_add_entities(
SuezWaterSensor(coordinator, counter_id, description) for description in SENSORS
)
class SuezAggregatedSensor(CoordinatorEntity[SuezWaterCoordinator], SensorEntity): class SuezWaterSensor(CoordinatorEntity[SuezWaterCoordinator], SensorEntity):
"""Representation of a Sensor.""" """Representation of a Suez water sensor."""
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_translation_key = "water_usage_yesterday" _attr_attribution = ATTRIBUTION
_attr_native_unit_of_measurement = UnitOfVolume.LITERS entity_description: SuezWaterSensorEntityDescription
_attr_device_class = SensorDeviceClass.WATER
def __init__(self, coordinator: SuezWaterCoordinator, counter_id: int) -> None: def __init__(
"""Initialize the data object.""" self,
coordinator: SuezWaterCoordinator,
counter_id: int,
entity_description: SuezWaterSensorEntityDescription,
) -> None:
"""Initialize the suez water sensor entity."""
super().__init__(coordinator) super().__init__(coordinator)
self._attr_extra_state_attributes = {} self._attr_unique_id = f"{counter_id}_{entity_description.key}"
self._attr_unique_id = f"{counter_id}_water_usage_yesterday"
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, str(counter_id))}, identifiers={(DOMAIN, str(counter_id))},
entry_type=DeviceEntryType.SERVICE, entry_type=DeviceEntryType.SERVICE,
manufacturer="Suez", manufacturer="Suez",
) )
self.entity_description = entity_description
@property @property
def native_value(self) -> float: def native_value(self) -> float | str | None:
"""Return the current daily usage.""" """Return the state of the sensor."""
return self.coordinator.data.value return self.entity_description.value_fn(self.coordinator.data)
@property @property
def attribution(self) -> str: def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Return data attribution message.""" """Return extra state of the sensor."""
return self.coordinator.data.attribution return self.entity_description.attr_fn(self.coordinator.data)
@property
def extra_state_attributes(self) -> Mapping[str, Any]:
"""Return aggregated data."""
return {
"this_month_consumption": self.coordinator.data.current_month,
"previous_month_consumption": self.coordinator.data.previous_month,
"highest_monthly_consumption": self.coordinator.data.highest_monthly_consumption,
"last_year_overall": self.coordinator.data.previous_year,
"this_year_overall": self.coordinator.data.current_year,
"history": self.coordinator.data.history,
}

View File

@ -23,6 +23,9 @@
"sensor": { "sensor": {
"water_usage_yesterday": { "water_usage_yesterday": {
"name": "Water usage yesterday" "name": "Water usage yesterday"
},
"water_price": {
"name": "Water price"
} }
} }
} }

View File

@ -3,10 +3,11 @@
from collections.abc import Generator from collections.abc import Generator
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from pysuez import AggregatedData, PriceResult
from pysuez.const import ATTRIBUTION
import pytest import pytest
from homeassistant.components.suez_water.const import DOMAIN from homeassistant.components.suez_water.const import DOMAIN
from homeassistant.components.suez_water.coordinator import AggregatedData
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -38,7 +39,7 @@ def mock_setup_entry() -> Generator[AsyncMock]:
@pytest.fixture(name="suez_client") @pytest.fixture(name="suez_client")
def mock_suez_data() -> Generator[AsyncMock]: def mock_suez_client() -> Generator[AsyncMock]:
"""Create mock for suez_water external api.""" """Create mock for suez_water external api."""
with ( with (
patch( patch(
@ -64,7 +65,7 @@ def mock_suez_data() -> Generator[AsyncMock]:
}, },
current_year=1500, current_year=1500,
previous_year=1000, previous_year=1000,
attribution="suez water mock test", attribution=ATTRIBUTION,
highest_monthly_consumption=2558, highest_monthly_consumption=2558,
history={ history={
"2024-01-01": 130, "2024-01-01": 130,
@ -75,4 +76,5 @@ def mock_suez_data() -> Generator[AsyncMock]:
) )
suez_client.fetch_aggregated_data.return_value = result suez_client.fetch_aggregated_data.return_value = result
suez_client.get_price.return_value = PriceResult("4.74")
yield suez_client yield suez_client

View File

@ -1,4 +1,53 @@
# serializer version: 1 # serializer version: 1
# name: test_sensors_valid_state[sensor.suez_mock_device_water_price-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.suez_mock_device_water_price',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.MONETARY: 'monetary'>,
'original_icon': None,
'original_name': 'Water price',
'platform': 'suez_water',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'water_price',
'unique_id': 'test-counter_water_price',
'unit_of_measurement': '€',
})
# ---
# name: test_sensors_valid_state[sensor.suez_mock_device_water_price-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by toutsurmoneau.fr',
'device_class': 'monetary',
'friendly_name': 'Suez mock device Water price',
'unit_of_measurement': '€',
}),
'context': <ANY>,
'entity_id': 'sensor.suez_mock_device_water_price',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '4.74',
})
# ---
# name: test_sensors_valid_state[sensor.suez_mock_device_water_usage_yesterday-entry] # name: test_sensors_valid_state[sensor.suez_mock_device_water_usage_yesterday-entry]
EntityRegistryEntrySnapshot({ EntityRegistryEntrySnapshot({
'aliases': set({ 'aliases': set({
@ -35,7 +84,7 @@
# name: test_sensors_valid_state[sensor.suez_mock_device_water_usage_yesterday-state] # name: test_sensors_valid_state[sensor.suez_mock_device_water_usage_yesterday-state]
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
'attribution': 'suez water mock test', 'attribution': 'Data provided by toutsurmoneau.fr',
'device_class': 'water', 'device_class': 'water',
'friendly_name': 'Suez mock device Water usage yesterday', 'friendly_name': 'Suez mock device Water usage yesterday',
'highest_monthly_consumption': 2558, 'highest_monthly_consumption': 2558,

View File

@ -3,6 +3,7 @@
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy import SnapshotAssertion from syrupy import SnapshotAssertion
from homeassistant.components.suez_water.const import DATA_REFRESH_INTERVAL from homeassistant.components.suez_water.const import DATA_REFRESH_INTERVAL
@ -32,11 +33,13 @@ async def test_sensors_valid_state(
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize("method", [("fetch_aggregated_data"), ("get_price")])
async def test_sensors_failed_update( async def test_sensors_failed_update(
hass: HomeAssistant, hass: HomeAssistant,
suez_client: AsyncMock, suez_client: AsyncMock,
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory, freezer: FrozenDateTimeFactory,
method: str,
) -> None: ) -> None:
"""Test that suez_water sensor reflect failure when api fails.""" """Test that suez_water sensor reflect failure when api fails."""
@ -45,18 +48,20 @@ async def test_sensors_failed_update(
assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_config_entry.state is ConfigEntryState.LOADED
entity_ids = await hass.async_add_executor_job(hass.states.entity_ids) entity_ids = await hass.async_add_executor_job(hass.states.entity_ids)
assert len(entity_ids) == 1 assert len(entity_ids) == 2
state = hass.states.get(entity_ids[0]) for entity in entity_ids:
assert entity_ids[0] state = hass.states.get(entity)
assert entity
assert state.state != STATE_UNAVAILABLE assert state.state != STATE_UNAVAILABLE
suez_client.fetch_aggregated_data.side_effect = PySuezError("Should fail to update") getattr(suez_client, method).side_effect = PySuezError("Should fail to update")
freezer.tick(DATA_REFRESH_INTERVAL) freezer.tick(DATA_REFRESH_INTERVAL)
async_fire_time_changed(hass) async_fire_time_changed(hass)
await hass.async_block_till_done(True) await hass.async_block_till_done(True)
state = hass.states.get(entity_ids[0]) for entity in entity_ids:
assert state state = hass.states.get(entity)
assert entity
assert state.state == STATE_UNAVAILABLE assert state.state == STATE_UNAVAILABLE