diff --git a/homeassistant/components/suez_water/__init__.py b/homeassistant/components/suez_water/__init__.py index f5b2880e011..06f503b85c2 100644 --- a/homeassistant/components/suez_water/__init__.py +++ b/homeassistant/components/suez_water/__init__.py @@ -2,15 +2,12 @@ from __future__ import annotations -from pysuez import SuezClient -from pysuez.client import PySuezError - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady -from .const import CONF_COUNTER_ID, DOMAIN +from .const import DOMAIN +from .coordinator import SuezWaterCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -18,23 +15,10 @@ PLATFORMS: list[Platform] = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Suez Water from a config entry.""" - def get_client() -> SuezClient: - try: - client = SuezClient( - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - entry.data[CONF_COUNTER_ID], - provider=None, - ) - if not client.check_credentials(): - raise ConfigEntryError - except PySuezError as ex: - raise ConfigEntryNotReady from ex - return client + coordinator = SuezWaterCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[ - entry.entry_id - ] = await hass.async_add_executor_job(get_client) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/suez_water/const.py b/homeassistant/components/suez_water/const.py index 7afc0d3ce3e..cecd779c22c 100644 --- a/homeassistant/components/suez_water/const.py +++ b/homeassistant/components/suez_water/const.py @@ -1,5 +1,9 @@ """Constants for the Suez Water integration.""" +from datetime import timedelta + DOMAIN = "suez_water" CONF_COUNTER_ID = "counter_id" + +DATA_REFRESH_INTERVAL = timedelta(hours=12) diff --git a/homeassistant/components/suez_water/coordinator.py b/homeassistant/components/suez_water/coordinator.py new file mode 100644 index 00000000000..adcbd39c01b --- /dev/null +++ b/homeassistant/components/suez_water/coordinator.py @@ -0,0 +1,108 @@ +"""Suez water update coordinator.""" + +import asyncio +from dataclasses import dataclass +from datetime import date + +from pysuez import SuezClient +from pysuez.client import PySuezError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import _LOGGER, HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_COUNTER_ID, DATA_REFRESH_INTERVAL, DOMAIN + + +@dataclass +class AggregatedSensorData: + """Hold suez water aggregated sensor data.""" + + value: float + current_month: dict[date, float] + previous_month: dict[date, float] + previous_year: dict[str, float] + current_year: dict[str, float] + history: dict[date, float] + highest_monthly_consumption: float + attribution: str + + +class SuezWaterCoordinator(DataUpdateCoordinator[AggregatedSensorData]): + """Suez water coordinator.""" + + _sync_client: SuezClient + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize suez water coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=DATA_REFRESH_INTERVAL, + always_update=True, + config_entry=config_entry, + ) + + async def _async_setup(self) -> None: + self._sync_client = await self.hass.async_add_executor_job(self._get_client) + + async def _async_update_data(self) -> AggregatedSensorData: + """Fetch data from API endpoint.""" + async with asyncio.timeout(30): + return await self.hass.async_add_executor_job(self._fetch_data) + + def _fetch_data(self) -> AggregatedSensorData: + """Fetch latest data from Suez.""" + try: + self._sync_client.update() + except PySuezError as err: + raise UpdateFailed( + f"Suez coordinator error communicating with API: {err}" + ) from err + current_month = {} + for item in self._sync_client.attributes["thisMonthConsumption"]: + current_month[item] = self._sync_client.attributes["thisMonthConsumption"][ + item + ] + previous_month = {} + for item in self._sync_client.attributes["previousMonthConsumption"]: + previous_month[item] = self._sync_client.attributes[ + "previousMonthConsumption" + ][item] + highest_monthly_consumption = self._sync_client.attributes[ + "highestMonthlyConsumption" + ] + previous_year = self._sync_client.attributes["lastYearOverAll"] + current_year = self._sync_client.attributes["thisYearOverAll"] + history = {} + for item in self._sync_client.attributes["history"]: + history[item] = self._sync_client.attributes["history"][item] + _LOGGER.debug("Retrieved consumption: " + str(self._sync_client.state)) + return AggregatedSensorData( + self._sync_client.state, + current_month, + previous_month, + previous_year, + current_year, + history, + highest_monthly_consumption, + self._sync_client.attributes["attribution"], + ) + + def _get_client(self) -> SuezClient: + try: + client = SuezClient( + username=self.config_entry.data[CONF_USERNAME], + password=self.config_entry.data[CONF_PASSWORD], + counter_id=self.config_entry.data[CONF_COUNTER_ID], + provider=None, + ) + if not client.check_credentials(): + raise ConfigEntryError + except PySuezError as ex: + raise ConfigEntryNotReady from ex + return client diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index 5b00cbf2dc4..22a61c835e1 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -2,11 +2,8 @@ from __future__ import annotations -from datetime import timedelta -import logging - -from pysuez import SuezClient -from pysuez.client import PySuezError +from collections.abc import Mapping +from typing import Any from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry @@ -14,12 +11,10 @@ from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_COUNTER_ID, DOMAIN - -_LOGGER = logging.getLogger(__name__) - -SCAN_INTERVAL = timedelta(hours=12) +from .coordinator import SuezWaterCoordinator async def async_setup_entry( @@ -28,11 +23,11 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Suez Water sensor from a config entry.""" - client = hass.data[DOMAIN][entry.entry_id] - async_add_entities([SuezSensor(client, entry.data[CONF_COUNTER_ID])], True) + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([SuezAggregatedSensor(coordinator, entry.data[CONF_COUNTER_ID])]) -class SuezSensor(SensorEntity): +class SuezAggregatedSensor(CoordinatorEntity[SuezWaterCoordinator], SensorEntity): """Representation of a Sensor.""" _attr_has_entity_name = True @@ -40,9 +35,9 @@ class SuezSensor(SensorEntity): _attr_native_unit_of_measurement = UnitOfVolume.LITERS _attr_device_class = SensorDeviceClass.WATER - def __init__(self, client: SuezClient, counter_id: int) -> None: + def __init__(self, coordinator: SuezWaterCoordinator, counter_id: int) -> None: """Initialize the data object.""" - self.client = client + super().__init__(coordinator) self._attr_extra_state_attributes = {} self._attr_unique_id = f"{counter_id}_water_usage_yesterday" self._attr_device_info = DeviceInfo( @@ -51,45 +46,24 @@ class SuezSensor(SensorEntity): manufacturer="Suez", ) - def _fetch_data(self) -> None: - """Fetch latest data from Suez.""" - try: - self.client.update() - # _state holds the volume of consumed water during previous day - self._attr_native_value = self.client.state - self._attr_available = True - self._attr_attribution = self.client.attributes["attribution"] + @property + def native_value(self) -> float: + """Return the current daily usage.""" + return self.coordinator.data.value - self._attr_extra_state_attributes["this_month_consumption"] = {} - for item in self.client.attributes["thisMonthConsumption"]: - self._attr_extra_state_attributes["this_month_consumption"][item] = ( - self.client.attributes["thisMonthConsumption"][item] - ) - self._attr_extra_state_attributes["previous_month_consumption"] = {} - for item in self.client.attributes["previousMonthConsumption"]: - self._attr_extra_state_attributes["previous_month_consumption"][ - item - ] = self.client.attributes["previousMonthConsumption"][item] - self._attr_extra_state_attributes["highest_monthly_consumption"] = ( - self.client.attributes["highestMonthlyConsumption"] - ) - self._attr_extra_state_attributes["last_year_overall"] = ( - self.client.attributes["lastYearOverAll"] - ) - self._attr_extra_state_attributes["this_year_overall"] = ( - self.client.attributes["thisYearOverAll"] - ) - self._attr_extra_state_attributes["history"] = {} - for item in self.client.attributes["history"]: - self._attr_extra_state_attributes["history"][item] = ( - self.client.attributes["history"][item] - ) + @property + def attribution(self) -> str: + """Return data attribution message.""" + return self.coordinator.data.attribution - except PySuezError: - self._attr_available = False - _LOGGER.warning("Unable to fetch data") - - def update(self) -> None: - """Return the latest collected data from Suez.""" - self._fetch_data() - _LOGGER.debug("Suez data state is: %s", self.native_value) + @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, + } diff --git a/tests/components/suez_water/__init__.py b/tests/components/suez_water/__init__.py index 4605e06344a..a90df738454 100644 --- a/tests/components/suez_water/__init__.py +++ b/tests/components/suez_water/__init__.py @@ -1 +1,15 @@ """Tests for the Suez Water integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Init suez water integration.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/suez_water/conftest.py b/tests/components/suez_water/conftest.py index f218fb7d833..bcb817a5025 100644 --- a/tests/components/suez_water/conftest.py +++ b/tests/components/suez_water/conftest.py @@ -1,10 +1,31 @@ """Common fixtures for the Suez Water tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest +from homeassistant.components.suez_water.const import DOMAIN + +from tests.common import MockConfigEntry + +MOCK_DATA = { + "username": "test-username", + "password": "test-password", + "counter_id": "test-counter", +} + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Create mock config_entry needed by suez_water integration.""" + return MockConfigEntry( + unique_id=MOCK_DATA["username"], + domain=DOMAIN, + title="Suez mock device", + data=MOCK_DATA, + ) + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -13,3 +34,42 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.suez_water.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture(name="suez_client") +def mock_suez_client() -> Generator[MagicMock]: + """Create mock for suez_water external api.""" + with ( + patch( + "homeassistant.components.suez_water.coordinator.SuezClient", autospec=True + ) as mock_client, + patch( + "homeassistant.components.suez_water.config_flow.SuezClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.check_credentials.return_value = True + client.update.return_value = None + client.state = 160 + client.attributes = { + "thisMonthConsumption": { + "2024-01-01": 130, + "2024-01-02": 145, + }, + "previousMonthConsumption": { + "2024-12-01": 154, + "2024-12-02": 166, + }, + "highestMonthlyConsumption": 2558, + "lastYearOverAll": 1000, + "thisYearOverAll": 1500, + "history": { + "2024-01-01": 130, + "2024-01-02": 145, + "2024-12-01": 154, + "2024-12-02": 166, + }, + "attribution": "suez water mock test", + } + yield client diff --git a/tests/components/suez_water/snapshots/test_sensor.ambr b/tests/components/suez_water/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..acc3042f93b --- /dev/null +++ b/tests/components/suez_water/snapshots/test_sensor.ambr @@ -0,0 +1,67 @@ +# serializer version: 1 +# name: test_sensors_valid_state[sensor.suez_mock_device_water_usage_yesterday-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.suez_mock_device_water_usage_yesterday', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water usage yesterday', + 'platform': 'suez_water', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_usage_yesterday', + 'unique_id': 'test-counter_water_usage_yesterday', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_valid_state[sensor.suez_mock_device_water_usage_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'suez water mock test', + 'device_class': 'water', + 'friendly_name': 'Suez mock device Water usage yesterday', + 'highest_monthly_consumption': 2558, + 'history': dict({ + '2024-01-01': 130, + '2024-01-02': 145, + '2024-12-01': 154, + '2024-12-02': 166, + }), + 'last_year_overall': 1000, + 'previous_month_consumption': dict({ + '2024-12-01': 154, + '2024-12-02': 166, + }), + 'this_month_consumption': dict({ + '2024-01-01': 130, + '2024-01-02': 145, + }), + 'this_year_overall': 1500, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.suez_mock_device_water_usage_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '160', + }) +# --- diff --git a/tests/components/suez_water/test_config_flow.py b/tests/components/suez_water/test_config_flow.py index 3170a6779f0..ddf7bcd3d80 100644 --- a/tests/components/suez_water/test_config_flow.py +++ b/tests/components/suez_water/test_config_flow.py @@ -10,13 +10,9 @@ from homeassistant.components.suez_water.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from .conftest import MOCK_DATA -MOCK_DATA = { - "username": "test-username", - "password": "test-password", - "counter_id": "test-counter", -} +from tests.common import MockConfigEntry async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: diff --git a/tests/components/suez_water/test_init.py b/tests/components/suez_water/test_init.py new file mode 100644 index 00000000000..b9a8875a8a1 --- /dev/null +++ b/tests/components/suez_water/test_init.py @@ -0,0 +1,35 @@ +"""Test Suez_water integration initialization.""" + +from homeassistant.components.suez_water.coordinator import PySuezError +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_initialization_invalid_credentials( + hass: HomeAssistant, + suez_client, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that suez_water can't be loaded with invalid credentials.""" + + suez_client.check_credentials.return_value = False + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_initialization_setup_api_error( + hass: HomeAssistant, + suez_client, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that suez_water needs to retry loading if api failed to connect.""" + + suez_client.check_credentials.side_effect = PySuezError("Test failure") + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/suez_water/test_sensor.py b/tests/components/suez_water/test_sensor.py new file mode 100644 index 00000000000..d3da159ee28 --- /dev/null +++ b/tests/components/suez_water/test_sensor.py @@ -0,0 +1,62 @@ +"""Test Suez_water sensor platform.""" + +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from syrupy import SnapshotAssertion + +from homeassistant.components.suez_water.const import DATA_REFRESH_INTERVAL +from homeassistant.components.suez_water.coordinator import PySuezError +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_sensors_valid_state( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + suez_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that suez_water sensor is loaded and in a valid state.""" + with patch("homeassistant.components.suez_water.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_sensors_failed_update( + hass: HomeAssistant, + suez_client, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that suez_water sensor reflect failure when api fails.""" + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + entity_ids = await hass.async_add_executor_job(hass.states.entity_ids) + assert len(entity_ids) == 1 + + state = hass.states.get(entity_ids[0]) + assert entity_ids[0] + assert state.state != STATE_UNAVAILABLE + + suez_client.update.side_effect = PySuezError("Should fail to update") + + freezer.tick(DATA_REFRESH_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(True) + + state = hass.states.get(entity_ids[0]) + assert state + assert state.state == STATE_UNAVAILABLE