mirror of
https://github.com/home-assistant/core.git
synced 2025-07-27 07:07:28 +00:00
Suez water: fetch historical data in statistics (#131166)
* Suez water: fetch historical data in statistics * test review * wip: fix few things * Python is smarter than me * use snapshots for statistics and add hard limit for historical stats * refactor refresh + handle missing price * No more auth error raised * fix after rebase * Review - much cleaner <3 * fix changes * test without snapshots * fix imports
This commit is contained in:
parent
4160ed190c
commit
4737091722
@ -1,18 +1,35 @@
|
|||||||
"""Suez water update coordinator."""
|
"""Suez water update coordinator."""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date, datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
from pysuez import PySuezError, SuezClient
|
from pysuez import PySuezError, SuezClient, TelemetryMeasure
|
||||||
|
|
||||||
|
from homeassistant.components.recorder import get_instance
|
||||||
|
from homeassistant.components.recorder.models import StatisticData, StatisticMetaData
|
||||||
|
from homeassistant.components.recorder.statistics import (
|
||||||
|
StatisticMeanType,
|
||||||
|
StatisticsRow,
|
||||||
|
async_add_external_statistics,
|
||||||
|
get_last_statistics,
|
||||||
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
from homeassistant.const import (
|
||||||
from homeassistant.core import _LOGGER, HomeAssistant
|
CONF_PASSWORD,
|
||||||
|
CONF_USERNAME,
|
||||||
|
CURRENCY_EURO,
|
||||||
|
UnitOfVolume,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryError
|
from homeassistant.exceptions import ConfigEntryError
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
from .const import CONF_COUNTER_ID, DATA_REFRESH_INTERVAL, DOMAIN
|
from .const import CONF_COUNTER_ID, DATA_REFRESH_INTERVAL, DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SuezWaterAggregatedAttributes:
|
class SuezWaterAggregatedAttributes:
|
||||||
@ -32,7 +49,7 @@ class SuezWaterData:
|
|||||||
|
|
||||||
aggregated_value: float
|
aggregated_value: float
|
||||||
aggregated_attr: SuezWaterAggregatedAttributes
|
aggregated_attr: SuezWaterAggregatedAttributes
|
||||||
price: float
|
price: float | None
|
||||||
|
|
||||||
|
|
||||||
type SuezWaterConfigEntry = ConfigEntry[SuezWaterCoordinator]
|
type SuezWaterConfigEntry = ConfigEntry[SuezWaterCoordinator]
|
||||||
@ -54,6 +71,11 @@ class SuezWaterCoordinator(DataUpdateCoordinator[SuezWaterData]):
|
|||||||
always_update=True,
|
always_update=True,
|
||||||
config_entry=config_entry,
|
config_entry=config_entry,
|
||||||
)
|
)
|
||||||
|
self._counter_id = self.config_entry.data[CONF_COUNTER_ID]
|
||||||
|
self._cost_statistic_id = f"{DOMAIN}:{self._counter_id}_water_cost_statistics"
|
||||||
|
self._water_statistic_id = (
|
||||||
|
f"{DOMAIN}:{self._counter_id}_water_consumption_statistics"
|
||||||
|
)
|
||||||
|
|
||||||
async def _async_setup(self) -> None:
|
async def _async_setup(self) -> None:
|
||||||
self._suez_client = SuezClient(
|
self._suez_client = SuezClient(
|
||||||
@ -72,7 +94,22 @@ class SuezWaterCoordinator(DataUpdateCoordinator[SuezWaterData]):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
aggregated = await self._suez_client.fetch_aggregated_data()
|
aggregated = await self._suez_client.fetch_aggregated_data()
|
||||||
data = SuezWaterData(
|
except PySuezError as err:
|
||||||
|
raise UpdateFailed("Suez coordinator error communicating with API") from err
|
||||||
|
|
||||||
|
price = None
|
||||||
|
try:
|
||||||
|
price = (await self._suez_client.get_price()).price
|
||||||
|
except PySuezError:
|
||||||
|
_LOGGER.debug("Failed to fetch water price", stack_info=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._update_statistics(price)
|
||||||
|
except PySuezError as err:
|
||||||
|
raise UpdateFailed("Failed to update suez water statistics") from err
|
||||||
|
|
||||||
|
_LOGGER.debug("Successfully fetched suez data")
|
||||||
|
return SuezWaterData(
|
||||||
aggregated_value=aggregated.value,
|
aggregated_value=aggregated.value,
|
||||||
aggregated_attr=SuezWaterAggregatedAttributes(
|
aggregated_attr=SuezWaterAggregatedAttributes(
|
||||||
this_month_consumption=map_dict(aggregated.current_month),
|
this_month_consumption=map_dict(aggregated.current_month),
|
||||||
@ -82,9 +119,140 @@ class SuezWaterCoordinator(DataUpdateCoordinator[SuezWaterData]):
|
|||||||
this_year_overall=aggregated.current_year,
|
this_year_overall=aggregated.current_year,
|
||||||
history=map_dict(aggregated.history),
|
history=map_dict(aggregated.history),
|
||||||
),
|
),
|
||||||
price=(await self._suez_client.get_price()).price,
|
price=price,
|
||||||
)
|
)
|
||||||
except PySuezError as err:
|
|
||||||
raise UpdateFailed(f"Suez data update failed: {err}") from err
|
async def _update_statistics(self, current_price: float | None) -> None:
|
||||||
_LOGGER.debug("Successfully fetched suez data")
|
"""Update daily statistics."""
|
||||||
return data
|
_LOGGER.debug("Updating statistics for %s", self._water_statistic_id)
|
||||||
|
|
||||||
|
water_last_stat = await self._get_last_stat(self._water_statistic_id)
|
||||||
|
cost_last_stat = await self._get_last_stat(self._cost_statistic_id)
|
||||||
|
consumption_sum = (
|
||||||
|
water_last_stat["sum"]
|
||||||
|
if water_last_stat and water_last_stat["sum"]
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
cost_sum = (
|
||||||
|
cost_last_stat["sum"] if cost_last_stat and cost_last_stat["sum"] else 0.0
|
||||||
|
)
|
||||||
|
last_stats = (
|
||||||
|
datetime.fromtimestamp(water_last_stat["start"]).date()
|
||||||
|
if water_last_stat
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Updating suez stat since %s for %s",
|
||||||
|
str(last_stats),
|
||||||
|
water_last_stat,
|
||||||
|
)
|
||||||
|
if not (
|
||||||
|
usage := await self._suez_client.fetch_all_daily_data(
|
||||||
|
since=last_stats,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
_LOGGER.debug("No recent usage data. Skipping update")
|
||||||
|
return
|
||||||
|
_LOGGER.debug("fetched data: %s", len(usage))
|
||||||
|
|
||||||
|
consumption_statistics, cost_statistics = self._build_statistics(
|
||||||
|
current_price, consumption_sum, cost_sum, last_stats, usage
|
||||||
|
)
|
||||||
|
|
||||||
|
self._persist_statistics(consumption_statistics, cost_statistics)
|
||||||
|
|
||||||
|
def _build_statistics(
|
||||||
|
self,
|
||||||
|
current_price: float | None,
|
||||||
|
consumption_sum: float,
|
||||||
|
cost_sum: float,
|
||||||
|
last_stats: date | None,
|
||||||
|
usage: list[TelemetryMeasure],
|
||||||
|
) -> tuple[list[StatisticData], list[StatisticData]]:
|
||||||
|
"""Build statistics data from fetched data."""
|
||||||
|
consumption_statistics = []
|
||||||
|
cost_statistics = []
|
||||||
|
|
||||||
|
for data in usage:
|
||||||
|
if (
|
||||||
|
(last_stats is not None and data.date <= last_stats)
|
||||||
|
or not data.index
|
||||||
|
or data.volume is None
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
consumption_date = dt_util.start_of_local_day(data.date)
|
||||||
|
|
||||||
|
consumption_sum += data.volume
|
||||||
|
consumption_statistics.append(
|
||||||
|
StatisticData(
|
||||||
|
start=consumption_date,
|
||||||
|
state=data.volume,
|
||||||
|
sum=consumption_sum,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if current_price is not None:
|
||||||
|
day_cost = (data.volume / 1000) * current_price
|
||||||
|
cost_sum += day_cost
|
||||||
|
cost_statistics.append(
|
||||||
|
StatisticData(
|
||||||
|
start=consumption_date,
|
||||||
|
state=day_cost,
|
||||||
|
sum=cost_sum,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return consumption_statistics, cost_statistics
|
||||||
|
|
||||||
|
def _persist_statistics(
|
||||||
|
self,
|
||||||
|
consumption_statistics: list[StatisticData],
|
||||||
|
cost_statistics: list[StatisticData],
|
||||||
|
) -> None:
|
||||||
|
"""Persist given statistics in recorder."""
|
||||||
|
consumption_metadata = self._get_statistics_metadata(
|
||||||
|
id=self._water_statistic_id, name="Consumption", unit=UnitOfVolume.LITERS
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Adding %s statistics for %s",
|
||||||
|
len(consumption_statistics),
|
||||||
|
self._water_statistic_id,
|
||||||
|
)
|
||||||
|
async_add_external_statistics(
|
||||||
|
self.hass, consumption_metadata, consumption_statistics
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(cost_statistics) > 0:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Adding %s statistics for %s",
|
||||||
|
len(cost_statistics),
|
||||||
|
self._cost_statistic_id,
|
||||||
|
)
|
||||||
|
cost_metadata = self._get_statistics_metadata(
|
||||||
|
id=self._cost_statistic_id, name="Cost", unit=CURRENCY_EURO
|
||||||
|
)
|
||||||
|
async_add_external_statistics(self.hass, cost_metadata, cost_statistics)
|
||||||
|
|
||||||
|
_LOGGER.debug("Updated statistics for %s", self._water_statistic_id)
|
||||||
|
|
||||||
|
def _get_statistics_metadata(
|
||||||
|
self, id: str, name: str, unit: str
|
||||||
|
) -> StatisticMetaData:
|
||||||
|
"""Build statistics metadata for requested configuration."""
|
||||||
|
return StatisticMetaData(
|
||||||
|
has_mean=False,
|
||||||
|
mean_type=StatisticMeanType.NONE,
|
||||||
|
has_sum=True,
|
||||||
|
name=f"Suez water {name} {self._counter_id}",
|
||||||
|
source=DOMAIN,
|
||||||
|
statistic_id=id,
|
||||||
|
unit_of_measurement=unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get_last_stat(self, id: str) -> StatisticsRow | None:
|
||||||
|
"""Find last registered statistics of given id."""
|
||||||
|
last_stat = await get_instance(self.hass).async_add_executor_job(
|
||||||
|
get_last_statistics, self.hass, 1, id, True, {"sum"}
|
||||||
|
)
|
||||||
|
return last_stat[id][0] if last_stat else None
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "suez_water",
|
"domain": "suez_water",
|
||||||
"name": "Suez Water",
|
"name": "Suez Water",
|
||||||
|
"after_dependencies": ["recorder"],
|
||||||
"codeowners": ["@ooii", "@jb101010-2"],
|
"codeowners": ["@ooii", "@jb101010-2"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/suez_water",
|
"documentation": "https://www.home-assistant.io/integrations/suez_water",
|
||||||
|
@ -87,6 +87,14 @@ class SuezWaterSensor(CoordinatorEntity[SuezWaterCoordinator], SensorEntity):
|
|||||||
)
|
)
|
||||||
self.entity_description = entity_description
|
self.entity_description = entity_description
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""Return if entity is available."""
|
||||||
|
return (
|
||||||
|
self.coordinator.last_update_success
|
||||||
|
and self.entity_description.value_fn(self.coordinator.data) is not None
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self) -> float | str | None:
|
def native_value(self) -> float | str | None:
|
||||||
"""Return the state of the sensor."""
|
"""Return the state of the sensor."""
|
||||||
|
@ -8,17 +8,26 @@ from pysuez import AggregatedData, PriceResult
|
|||||||
from pysuez.const import ATTRIBUTION
|
from pysuez.const import ATTRIBUTION
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.recorder import Recorder
|
||||||
from homeassistant.components.suez_water.const import CONF_COUNTER_ID, DOMAIN
|
from homeassistant.components.suez_water.const import CONF_COUNTER_ID, DOMAIN
|
||||||
|
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
|
from tests.conftest import RecorderInstanceContextManager
|
||||||
|
|
||||||
MOCK_DATA = {
|
MOCK_DATA = {
|
||||||
"username": "test-username",
|
"username": "test-username",
|
||||||
"password": "test-password",
|
"password": "test-password",
|
||||||
CONF_COUNTER_ID: "test-counter",
|
CONF_COUNTER_ID: "123456",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def mock_recorder_before_hass(
|
||||||
|
async_test_recorder: RecorderInstanceContextManager,
|
||||||
|
) -> None:
|
||||||
|
"""Set up recorder."""
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_config_entry() -> MockConfigEntry:
|
def mock_config_entry() -> MockConfigEntry:
|
||||||
"""Create mock config_entry needed by suez_water integration."""
|
"""Create mock config_entry needed by suez_water integration."""
|
||||||
@ -32,7 +41,7 @@ def mock_config_entry() -> MockConfigEntry:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
def mock_setup_entry(recorder_mock: Recorder) -> Generator[AsyncMock]:
|
||||||
"""Override async_setup_entry."""
|
"""Override async_setup_entry."""
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.suez_water.async_setup_entry", return_value=True
|
"homeassistant.components.suez_water.async_setup_entry", return_value=True
|
||||||
@ -41,7 +50,7 @@ def mock_setup_entry() -> Generator[AsyncMock]:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="suez_client")
|
@pytest.fixture(name="suez_client")
|
||||||
def mock_suez_client() -> Generator[AsyncMock]:
|
def mock_suez_client(recorder_mock: Recorder) -> Generator[AsyncMock]:
|
||||||
"""Create mock for suez_water external api."""
|
"""Create mock for suez_water external api."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
|
231
tests/components/suez_water/snapshots/test_init.ambr
Normal file
231
tests/components/suez_water/snapshots/test_init.ambr
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
# serializer version: 1
|
||||||
|
# name: test_statistics[water_consumption_statistics][test_statistics_call1]
|
||||||
|
defaultdict({
|
||||||
|
'suez_water:123456_water_consumption_statistics': list([
|
||||||
|
dict({
|
||||||
|
'end': 1733043600.0,
|
||||||
|
'last_reset': None,
|
||||||
|
'start': 1733040000.0,
|
||||||
|
'state': 500.0,
|
||||||
|
'sum': 500.0,
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'end': 1733130000.0,
|
||||||
|
'last_reset': None,
|
||||||
|
'start': 1733126400.0,
|
||||||
|
'state': 500.0,
|
||||||
|
'sum': 1000.0,
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'end': 1733216400.0,
|
||||||
|
'last_reset': None,
|
||||||
|
'start': 1733212800.0,
|
||||||
|
'state': 500.0,
|
||||||
|
'sum': 1500.0,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_statistics[water_consumption_statistics][test_statistics_call2]
|
||||||
|
defaultdict({
|
||||||
|
'suez_water:123456_water_consumption_statistics': list([
|
||||||
|
dict({
|
||||||
|
'end': 1733043600.0,
|
||||||
|
'last_reset': None,
|
||||||
|
'start': 1733040000.0,
|
||||||
|
'state': 500.0,
|
||||||
|
'sum': 500.0,
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'end': 1733130000.0,
|
||||||
|
'last_reset': None,
|
||||||
|
'start': 1733126400.0,
|
||||||
|
'state': 500.0,
|
||||||
|
'sum': 1000.0,
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'end': 1733216400.0,
|
||||||
|
'last_reset': None,
|
||||||
|
'start': 1733212800.0,
|
||||||
|
'state': 500.0,
|
||||||
|
'sum': 1500.0,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_statistics[water_consumption_statistics][test_statistics_call3]
|
||||||
|
defaultdict({
|
||||||
|
'suez_water:123456_water_consumption_statistics': list([
|
||||||
|
dict({
|
||||||
|
'end': 1733043600.0,
|
||||||
|
'last_reset': None,
|
||||||
|
'start': 1733040000.0,
|
||||||
|
'state': 500.0,
|
||||||
|
'sum': 500.0,
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'end': 1733130000.0,
|
||||||
|
'last_reset': None,
|
||||||
|
'start': 1733126400.0,
|
||||||
|
'state': 500.0,
|
||||||
|
'sum': 1000.0,
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'end': 1733216400.0,
|
||||||
|
'last_reset': None,
|
||||||
|
'start': 1733212800.0,
|
||||||
|
'state': 500.0,
|
||||||
|
'sum': 1500.0,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_statistics[water_consumption_statistics][test_statistics_call4]
|
||||||
|
defaultdict({
|
||||||
|
'suez_water:123456_water_consumption_statistics': list([
|
||||||
|
dict({
|
||||||
|
'end': 1733043600.0,
|
||||||
|
'last_reset': None,
|
||||||
|
'start': 1733040000.0,
|
||||||
|
'state': 500.0,
|
||||||
|
'sum': 500.0,
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'end': 1733130000.0,
|
||||||
|
'last_reset': None,
|
||||||
|
'start': 1733126400.0,
|
||||||
|
'state': 500.0,
|
||||||
|
'sum': 1000.0,
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'end': 1733216400.0,
|
||||||
|
'last_reset': None,
|
||||||
|
'start': 1733212800.0,
|
||||||
|
'state': 500.0,
|
||||||
|
'sum': 1500.0,
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'end': 1733389200.0,
|
||||||
|
'last_reset': None,
|
||||||
|
'start': 1733385600.0,
|
||||||
|
'state': 500.0,
|
||||||
|
'sum': 2000.0,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_statistics[water_cost_statistics][test_statistics_call1]
|
||||||
|
defaultdict({
|
||||||
|
'suez_water:123456_water_cost_statistics': list([
|
||||||
|
dict({
|
||||||
|
'end': 1733043600.0,
|
||||||
|
'last_reset': None,
|
||||||
|
'start': 1733040000.0,
|
||||||
|
'state': 2.37,
|
||||||
|
'sum': 2.37,
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'end': 1733130000.0,
|
||||||
|
'last_reset': None,
|
||||||
|
'start': 1733126400.0,
|
||||||
|
'state': 2.37,
|
||||||
|
'sum': 4.74,
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'end': 1733216400.0,
|
||||||
|
'last_reset': None,
|
||||||
|
'start': 1733212800.0,
|
||||||
|
'state': 2.37,
|
||||||
|
'sum': 7.11,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_statistics[water_cost_statistics][test_statistics_call2]
|
||||||
|
defaultdict({
|
||||||
|
'suez_water:123456_water_cost_statistics': list([
|
||||||
|
dict({
|
||||||
|
'end': 1733043600.0,
|
||||||
|
'last_reset': None,
|
||||||
|
'start': 1733040000.0,
|
||||||
|
'state': 2.37,
|
||||||
|
'sum': 2.37,
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'end': 1733130000.0,
|
||||||
|
'last_reset': None,
|
||||||
|
'start': 1733126400.0,
|
||||||
|
'state': 2.37,
|
||||||
|
'sum': 4.74,
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'end': 1733216400.0,
|
||||||
|
'last_reset': None,
|
||||||
|
'start': 1733212800.0,
|
||||||
|
'state': 2.37,
|
||||||
|
'sum': 7.11,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_statistics[water_cost_statistics][test_statistics_call3]
|
||||||
|
defaultdict({
|
||||||
|
'suez_water:123456_water_cost_statistics': list([
|
||||||
|
dict({
|
||||||
|
'end': 1733043600.0,
|
||||||
|
'last_reset': None,
|
||||||
|
'start': 1733040000.0,
|
||||||
|
'state': 2.37,
|
||||||
|
'sum': 2.37,
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'end': 1733130000.0,
|
||||||
|
'last_reset': None,
|
||||||
|
'start': 1733126400.0,
|
||||||
|
'state': 2.37,
|
||||||
|
'sum': 4.74,
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'end': 1733216400.0,
|
||||||
|
'last_reset': None,
|
||||||
|
'start': 1733212800.0,
|
||||||
|
'state': 2.37,
|
||||||
|
'sum': 7.11,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_statistics[water_cost_statistics][test_statistics_call4]
|
||||||
|
defaultdict({
|
||||||
|
'suez_water:123456_water_cost_statistics': list([
|
||||||
|
dict({
|
||||||
|
'end': 1733043600.0,
|
||||||
|
'last_reset': None,
|
||||||
|
'start': 1733040000.0,
|
||||||
|
'state': 2.37,
|
||||||
|
'sum': 2.37,
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'end': 1733130000.0,
|
||||||
|
'last_reset': None,
|
||||||
|
'start': 1733126400.0,
|
||||||
|
'state': 2.37,
|
||||||
|
'sum': 4.74,
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'end': 1733216400.0,
|
||||||
|
'last_reset': None,
|
||||||
|
'start': 1733212800.0,
|
||||||
|
'state': 2.37,
|
||||||
|
'sum': 7.11,
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'end': 1733389200.0,
|
||||||
|
'last_reset': None,
|
||||||
|
'start': 1733385600.0,
|
||||||
|
'state': 2.37,
|
||||||
|
'sum': 9.48,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
# ---
|
@ -29,7 +29,7 @@
|
|||||||
'previous_unique_id': None,
|
'previous_unique_id': None,
|
||||||
'supported_features': 0,
|
'supported_features': 0,
|
||||||
'translation_key': 'water_price',
|
'translation_key': 'water_price',
|
||||||
'unique_id': 'test-counter_water_price',
|
'unique_id': '123456_water_price',
|
||||||
'unit_of_measurement': '€',
|
'unit_of_measurement': '€',
|
||||||
})
|
})
|
||||||
# ---
|
# ---
|
||||||
@ -79,7 +79,7 @@
|
|||||||
'previous_unique_id': None,
|
'previous_unique_id': None,
|
||||||
'supported_features': 0,
|
'supported_features': 0,
|
||||||
'translation_key': 'water_usage_yesterday',
|
'translation_key': 'water_usage_yesterday',
|
||||||
'unique_id': 'test-counter_water_usage_yesterday',
|
'unique_id': '123456_water_usage_yesterday',
|
||||||
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
|
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
|
||||||
})
|
})
|
||||||
# ---
|
# ---
|
||||||
|
@ -6,6 +6,7 @@ from pysuez.exception import PySuezError
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.components.recorder import Recorder
|
||||||
from homeassistant.components.suez_water.const import CONF_COUNTER_ID, DOMAIN
|
from homeassistant.components.suez_water.const import CONF_COUNTER_ID, DOMAIN
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.data_entry_flow import FlowResultType
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
@ -70,7 +71,7 @@ async def test_form_invalid_auth(
|
|||||||
|
|
||||||
|
|
||||||
async def test_form_already_configured(
|
async def test_form_already_configured(
|
||||||
hass: HomeAssistant, suez_client: AsyncMock
|
hass: HomeAssistant, recorder_mock: Recorder, suez_client: AsyncMock
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test we abort when entry is already configured."""
|
"""Test we abort when entry is already configured."""
|
||||||
|
|
||||||
|
@ -1,30 +1,32 @@
|
|||||||
"""Test Suez_water integration initialization."""
|
"""Test Suez_water integration initialization."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
from homeassistant.components.suez_water.const import CONF_COUNTER_ID, DOMAIN
|
from freezegun.api import FrozenDateTimeFactory
|
||||||
from homeassistant.components.suez_water.coordinator import PySuezError
|
import pytest
|
||||||
|
from syrupy.assertion import SnapshotAssertion
|
||||||
|
|
||||||
|
from homeassistant.components.recorder.statistics import statistics_during_period
|
||||||
|
from homeassistant.components.suez_water.const import (
|
||||||
|
CONF_COUNTER_ID,
|
||||||
|
DATA_REFRESH_INTERVAL,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
from homeassistant.components.suez_water.coordinator import (
|
||||||
|
PySuezError,
|
||||||
|
TelemetryMeasure,
|
||||||
|
)
|
||||||
from homeassistant.config_entries import ConfigEntryState
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
from homeassistant.const import CONF_USERNAME
|
from homeassistant.const import CONF_USERNAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
from . import setup_integration
|
from . import setup_integration
|
||||||
from .conftest import MOCK_DATA
|
from .conftest import MOCK_DATA
|
||||||
|
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||||
|
from tests.components.recorder.common import async_wait_recording_done
|
||||||
|
|
||||||
async def test_initialization_invalid_credentials(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
suez_client: AsyncMock,
|
|
||||||
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(
|
async def test_initialization_setup_api_error(
|
||||||
@ -40,6 +42,210 @@ async def test_initialization_setup_api_error(
|
|||||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||||
|
|
||||||
|
|
||||||
|
async def test_init_auth_failed(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
suez_client: AsyncMock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test that suez_water reflect authentication failure."""
|
||||||
|
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_init_refresh_failed(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
suez_client: AsyncMock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test that suez_water reflect authentication failure."""
|
||||||
|
suez_client.fetch_aggregated_data.side_effect = PySuezError("Update failed")
|
||||||
|
await setup_integration(hass, mock_config_entry)
|
||||||
|
|
||||||
|
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||||
|
|
||||||
|
|
||||||
|
async def test_init_statistics_failed(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
suez_client: AsyncMock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test that suez_water reflect authentication failure."""
|
||||||
|
suez_client.fetch_all_daily_data.side_effect = PySuezError("Update failed")
|
||||||
|
await setup_integration(hass, mock_config_entry)
|
||||||
|
|
||||||
|
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("recorder_mock")
|
||||||
|
async def test_statistics_no_price(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
suez_client: AsyncMock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test that suez_water statistics does not register when no price."""
|
||||||
|
# New data retrieved but no price
|
||||||
|
suez_client.get_price.side_effect = PySuezError("will fail")
|
||||||
|
suez_client.fetch_all_daily_data.return_value = [
|
||||||
|
TelemetryMeasure(
|
||||||
|
(datetime.now().date()).strftime("%Y-%m-%d %H:%M:%S"), 0.5, 0.5
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
await setup_integration(hass, mock_config_entry)
|
||||||
|
|
||||||
|
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||||
|
|
||||||
|
statistic_id = (
|
||||||
|
f"{DOMAIN}:{mock_config_entry.data[CONF_COUNTER_ID]}_water_cost_statistics"
|
||||||
|
)
|
||||||
|
stats = await hass.async_add_executor_job(
|
||||||
|
statistics_during_period,
|
||||||
|
hass,
|
||||||
|
datetime.now() - timedelta(days=1),
|
||||||
|
None,
|
||||||
|
[statistic_id],
|
||||||
|
"hour",
|
||||||
|
None,
|
||||||
|
{"start", "state", "mean", "min", "max", "last_reset", "sum"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert stats.get(statistic_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("recorder_mock")
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"statistic",
|
||||||
|
[
|
||||||
|
"water_cost_statistics",
|
||||||
|
"water_consumption_statistics",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_statistics(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
suez_client: AsyncMock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
freezer: FrozenDateTimeFactory,
|
||||||
|
snapshot: SnapshotAssertion,
|
||||||
|
statistic: str,
|
||||||
|
) -> None:
|
||||||
|
"""Test that suez_water statistics are working."""
|
||||||
|
nb_samples = 3
|
||||||
|
|
||||||
|
start = datetime.fromisoformat("2024-12-04T02:00:00.0")
|
||||||
|
freezer.move_to(start)
|
||||||
|
|
||||||
|
origin = dt_util.start_of_local_day(start.date()) - timedelta(days=nb_samples)
|
||||||
|
result = [
|
||||||
|
TelemetryMeasure(
|
||||||
|
date=((origin + timedelta(days=d)).date()).strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
volume=0.5,
|
||||||
|
index=0.5 * (d + 1),
|
||||||
|
)
|
||||||
|
for d in range(nb_samples)
|
||||||
|
]
|
||||||
|
suez_client.fetch_all_daily_data.return_value = result
|
||||||
|
|
||||||
|
await setup_integration(hass, mock_config_entry)
|
||||||
|
|
||||||
|
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||||
|
|
||||||
|
# Init data retrieved
|
||||||
|
await _test_for_data(
|
||||||
|
hass,
|
||||||
|
suez_client,
|
||||||
|
snapshot,
|
||||||
|
statistic,
|
||||||
|
origin,
|
||||||
|
mock_config_entry.data[CONF_COUNTER_ID],
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# No new data retrieved
|
||||||
|
suez_client.fetch_all_daily_data.return_value = []
|
||||||
|
freezer.tick(DATA_REFRESH_INTERVAL)
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
|
||||||
|
await _test_for_data(
|
||||||
|
hass,
|
||||||
|
suez_client,
|
||||||
|
snapshot,
|
||||||
|
statistic,
|
||||||
|
origin,
|
||||||
|
mock_config_entry.data[CONF_COUNTER_ID],
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
# Old data retrieved
|
||||||
|
suez_client.fetch_all_daily_data.return_value = [
|
||||||
|
TelemetryMeasure(
|
||||||
|
date=(origin.date() - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
volume=0.5,
|
||||||
|
index=0.5 * (121 + 1),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
freezer.tick(DATA_REFRESH_INTERVAL)
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
|
||||||
|
await _test_for_data(
|
||||||
|
hass,
|
||||||
|
suez_client,
|
||||||
|
snapshot,
|
||||||
|
statistic,
|
||||||
|
origin,
|
||||||
|
mock_config_entry.data[CONF_COUNTER_ID],
|
||||||
|
3,
|
||||||
|
)
|
||||||
|
|
||||||
|
# New daily data retrieved
|
||||||
|
suez_client.fetch_all_daily_data.return_value = [
|
||||||
|
TelemetryMeasure(
|
||||||
|
date=(datetime.now().date()).strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
volume=0.5,
|
||||||
|
index=0.5 * (121 + 1),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
freezer.tick(DATA_REFRESH_INTERVAL)
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
|
||||||
|
await _test_for_data(
|
||||||
|
hass,
|
||||||
|
suez_client,
|
||||||
|
snapshot,
|
||||||
|
statistic,
|
||||||
|
origin,
|
||||||
|
mock_config_entry.data[CONF_COUNTER_ID],
|
||||||
|
4,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _test_for_data(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
suez_client: AsyncMock,
|
||||||
|
snapshot: SnapshotAssertion,
|
||||||
|
statistic: str,
|
||||||
|
origin: datetime,
|
||||||
|
counter_id: str,
|
||||||
|
nb_calls: int,
|
||||||
|
) -> None:
|
||||||
|
await hass.async_block_till_done(True)
|
||||||
|
await async_wait_recording_done(hass)
|
||||||
|
|
||||||
|
assert suez_client.fetch_all_daily_data.call_count == nb_calls
|
||||||
|
statistic_id = f"{DOMAIN}:{counter_id}_{statistic}"
|
||||||
|
stats = await hass.async_add_executor_job(
|
||||||
|
statistics_during_period,
|
||||||
|
hass,
|
||||||
|
origin - timedelta(days=1),
|
||||||
|
None,
|
||||||
|
[statistic_id],
|
||||||
|
"hour",
|
||||||
|
None,
|
||||||
|
{"start", "state", "mean", "min", "max", "last_reset", "sum"},
|
||||||
|
)
|
||||||
|
assert stats == snapshot(name=f"test_statistics_call{nb_calls}")
|
||||||
|
|
||||||
|
|
||||||
async def test_migration_version_rollback(
|
async def test_migration_version_rollback(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
suez_client: AsyncMock,
|
suez_client: AsyncMock,
|
||||||
|
@ -41,16 +41,23 @@ async def test_sensors_valid_state(
|
|||||||
assert previous.get(str(date.fromisoformat("2024-12-01"))) == 154
|
assert previous.get(str(date.fromisoformat("2024-12-01"))) == 154
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("method", [("fetch_aggregated_data"), ("get_price")])
|
@pytest.mark.parametrize(
|
||||||
|
("method", "price_on_error", "consumption_on_error"),
|
||||||
|
[
|
||||||
|
("fetch_aggregated_data", STATE_UNAVAILABLE, STATE_UNAVAILABLE),
|
||||||
|
("get_price", STATE_UNAVAILABLE, "160"),
|
||||||
|
],
|
||||||
|
)
|
||||||
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,
|
method: str,
|
||||||
|
price_on_error: str,
|
||||||
|
consumption_on_error: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that suez_water sensor reflect failure when api fails."""
|
"""Test that suez_water sensor reflect failure when api fails."""
|
||||||
|
|
||||||
await setup_integration(hass, mock_config_entry)
|
await setup_integration(hass, mock_config_entry)
|
||||||
|
|
||||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||||
@ -58,10 +65,10 @@ async def test_sensors_failed_update(
|
|||||||
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) == 2
|
assert len(entity_ids) == 2
|
||||||
|
|
||||||
for entity in entity_ids:
|
state = hass.states.get("sensor.suez_mock_device_water_price")
|
||||||
state = hass.states.get(entity)
|
assert state.state == "4.74"
|
||||||
assert entity
|
state = hass.states.get("sensor.suez_mock_device_water_usage_yesterday")
|
||||||
assert state.state != STATE_UNAVAILABLE
|
assert state.state == "160"
|
||||||
|
|
||||||
getattr(suez_client, method).side_effect = PySuezError("Should fail to update")
|
getattr(suez_client, method).side_effect = PySuezError("Should fail to update")
|
||||||
|
|
||||||
@ -69,7 +76,7 @@ async def test_sensors_failed_update(
|
|||||||
async_fire_time_changed(hass)
|
async_fire_time_changed(hass)
|
||||||
await hass.async_block_till_done(True)
|
await hass.async_block_till_done(True)
|
||||||
|
|
||||||
for entity in entity_ids:
|
state = hass.states.get("sensor.suez_mock_device_water_price")
|
||||||
state = hass.states.get(entity)
|
assert state.state == price_on_error
|
||||||
assert entity
|
state = hass.states.get("sensor.suez_mock_device_water_usage_yesterday")
|
||||||
assert state.state == STATE_UNAVAILABLE
|
assert state.state == consumption_on_error
|
||||||
|
Loading…
x
Reference in New Issue
Block a user