Add coordinator to suez_water (#129242)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
jb101010-2 2024-11-04 12:18:12 +01:00 committed by GitHub
parent d75dda0c05
commit 274c928ec0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 387 additions and 83 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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,
}

View File

@ -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()

View File

@ -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

View File

@ -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': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.WATER: 'water'>,
'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': <UnitOfVolume.LITERS: 'L'>,
})
# ---
# 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': <UnitOfVolume.LITERS: 'L'>,
}),
'context': <ANY>,
'entity_id': 'sensor.suez_mock_device_water_usage_yesterday',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '160',
})
# ---

View File

@ -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:

View File

@ -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

View File

@ -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