Skip to content

Commit 58d5f8d

Browse files
committed
Suez water: fetch historical data in statistics
1 parent fc987ee commit 58d5f8d

File tree

7 files changed

+362
-26
lines changed

7 files changed

+362
-26
lines changed

homeassistant/components/suez_water/coordinator.py

+154-3
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,26 @@
22

33
from collections.abc import Mapping
44
from dataclasses import dataclass
5-
from datetime import date
5+
from datetime import date, datetime, time
66
from typing import Any
7+
from zoneinfo import ZoneInfo
78

8-
from pysuez import PySuezError, SuezClient
9+
from pysuez import DayDataResult, PySuezError, SuezClient
910

11+
from homeassistant.components.recorder import get_instance
12+
from homeassistant.components.recorder.models import StatisticData, StatisticMetaData
13+
from homeassistant.components.recorder.statistics import (
14+
StatisticsRow,
15+
async_add_external_statistics,
16+
get_last_statistics,
17+
)
1018
from homeassistant.config_entries import ConfigEntry
11-
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
19+
from homeassistant.const import (
20+
CONF_PASSWORD,
21+
CONF_USERNAME,
22+
CURRENCY_EURO,
23+
UnitOfVolume,
24+
)
1225
from homeassistant.core import _LOGGER, HomeAssistant
1326
from homeassistant.exceptions import ConfigEntryError
1427
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -53,6 +66,12 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
5366
always_update=True,
5467
config_entry=config_entry,
5568
)
69+
self._counter_id = self.config_entry.data[CONF_COUNTER_ID]
70+
self._cost_statistic_id = f"{DOMAIN}:{self._counter_id}_water_cost_statistics"
71+
self._water_statistic_id = (
72+
f"{DOMAIN}:{self._counter_id}_water_consumption_statistics"
73+
)
74+
self.config_entry.async_on_unload(self._clear_statistics)
5675

5776
async def _async_setup(self) -> None:
5877
self._suez_client = SuezClient(
@@ -79,10 +98,142 @@ async def _async_update_data(self) -> SuezWaterData:
7998
},
8099
price=(await self._suez_client.get_price()).price,
81100
)
101+
await self._update_statistics(data.price)
82102
except PySuezError as err:
83103
_LOGGER.exception(err)
84104
raise UpdateFailed(
85105
f"Suez coordinator error communicating with API: {err}"
86106
) from err
87107
_LOGGER.debug("Successfully fetched suez data")
88108
return data
109+
110+
async def _update_statistics(self, current_price: float) -> None:
111+
"""Update daily statistics."""
112+
_LOGGER.debug("Updating statistics for %s", self._water_statistic_id)
113+
114+
water_last_stat = await self._get_last_stat(self._water_statistic_id)
115+
cost_last_stat = await self._get_last_stat(self._cost_statistic_id)
116+
117+
consumption_sum = 0.0
118+
cost_sum = 0.0
119+
last_stats = None
120+
121+
if water_last_stat is not None:
122+
last_stats = datetime.fromtimestamp(water_last_stat["start"]).date()
123+
if water_last_stat["sum"] is not None:
124+
consumption_sum = water_last_stat["sum"]
125+
if cost_last_stat is not None:
126+
if cost_last_stat["sum"] is not None and cost_last_stat["sum"] is not None:
127+
cost_sum = cost_last_stat["sum"]
128+
129+
_LOGGER.debug(
130+
"Updating suez stat since %s for %s",
131+
str(last_stats),
132+
water_last_stat,
133+
)
134+
usage = await self._suez_client.fetch_all_daily_data(
135+
since=last_stats,
136+
)
137+
if usage is None or len(usage) <= 0:
138+
_LOGGER.debug("No recent usage data. Skipping update")
139+
return
140+
_LOGGER.debug("fetched data: %s", len(usage))
141+
142+
consumption_statistics, cost_statistics = self._build_statistics(
143+
current_price, consumption_sum, cost_sum, last_stats, usage
144+
)
145+
146+
self._persist_statistics(consumption_statistics, cost_statistics)
147+
148+
def _build_statistics(
149+
self,
150+
current_price: float,
151+
consumption_sum: float,
152+
cost_sum: float,
153+
last_stats: date | None,
154+
usage: list[DayDataResult],
155+
) -> tuple[list[StatisticData], list[StatisticData]]:
156+
"""Build statistics data from fetched data."""
157+
consumption_statistics = []
158+
cost_statistics = []
159+
160+
for data in usage:
161+
if last_stats is not None and data.date <= last_stats:
162+
continue
163+
consumption_date = datetime.combine(
164+
data.date, time(0, 0, 0, 0), ZoneInfo("Europe/Paris")
165+
)
166+
167+
consumption_sum += data.day_consumption
168+
consumption_statistics.append(
169+
StatisticData(
170+
start=consumption_date,
171+
state=data.day_consumption,
172+
sum=consumption_sum,
173+
)
174+
)
175+
day_cost = (data.day_consumption / 1000) * current_price
176+
cost_sum += day_cost
177+
cost_statistics.append(
178+
StatisticData(
179+
start=consumption_date,
180+
state=day_cost,
181+
sum=cost_sum,
182+
)
183+
)
184+
185+
return consumption_statistics, cost_statistics
186+
187+
def _persist_statistics(
188+
self,
189+
consumption_statistics: list[StatisticData],
190+
cost_statistics: list[StatisticData],
191+
) -> None:
192+
"""Persist given statistics in recorder."""
193+
consumption_metadata = self._get_statistics_metadata(
194+
id=self._water_statistic_id, name="Consumption", unit=UnitOfVolume.LITERS
195+
)
196+
cost_metadata = self._get_statistics_metadata(
197+
id=self._cost_statistic_id, name="Cost", unit=CURRENCY_EURO
198+
)
199+
200+
_LOGGER.debug(
201+
"Adding %s statistics for %s",
202+
len(consumption_statistics),
203+
self._water_statistic_id,
204+
)
205+
async_add_external_statistics(
206+
self.hass, consumption_metadata, consumption_statistics
207+
)
208+
async_add_external_statistics(self.hass, cost_metadata, cost_statistics)
209+
210+
_LOGGER.debug("Updated statistics for %s", self._water_statistic_id)
211+
212+
def _get_statistics_metadata(
213+
self, id: str, name: str, unit: str
214+
) -> StatisticMetaData:
215+
"""Build statistics metadata for requested configuration."""
216+
return StatisticMetaData(
217+
has_mean=False,
218+
has_sum=True,
219+
name=f"Suez water {name} {self._counter_id}",
220+
source=DOMAIN,
221+
statistic_id=id,
222+
unit_of_measurement=unit,
223+
)
224+
225+
async def _get_last_stat(self, id: str) -> StatisticsRow | None:
226+
"""Find last registered statistics of given id."""
227+
last_stat = await get_instance(self.hass).async_add_executor_job(
228+
get_last_statistics, self.hass, 1, id, True, {"sum"}
229+
)
230+
if last_stat is None or len(last_stat) == 0:
231+
return None
232+
return last_stat[id][0]
233+
234+
def _clear_statistics(self) -> None:
235+
"""Clear suez water statistics."""
236+
instance = get_instance(self.hass)
237+
instance.async_clear_statistics(
238+
[self._water_statistic_id, self._cost_statistic_id]
239+
)

homeassistant/components/suez_water/manifest.json

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"name": "Suez Water",
44
"codeowners": ["@ooii", "@jb101010-2"],
55
"config_flow": true,
6+
"dependencies": ["recorder"],
67
"documentation": "https://www.home-assistant.io/integrations/suez_water",
78
"iot_class": "cloud_polling",
89
"loggers": ["pysuez", "regex"],

tests/components/suez_water/conftest.py

+12-3
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,26 @@
77
from pysuez.const import ATTRIBUTION
88
import pytest
99

10+
from homeassistant.components.recorder import Recorder
1011
from homeassistant.components.suez_water.const import DOMAIN
1112

1213
from tests.common import MockConfigEntry
14+
from tests.conftest import RecorderInstanceGenerator
1315

1416
MOCK_DATA = {
1517
"username": "test-username",
1618
"password": "test-password",
17-
"counter_id": "test-counter",
19+
"counter_id": "123456",
1820
}
1921

2022

23+
@pytest.fixture
24+
async def mock_recorder_before_hass(
25+
async_test_recorder: RecorderInstanceGenerator,
26+
) -> None:
27+
"""Set up recorder."""
28+
29+
2130
@pytest.fixture
2231
def mock_config_entry() -> MockConfigEntry:
2332
"""Create mock config_entry needed by suez_water integration."""
@@ -30,7 +39,7 @@ def mock_config_entry() -> MockConfigEntry:
3039

3140

3241
@pytest.fixture
33-
def mock_setup_entry() -> Generator[AsyncMock]:
42+
def mock_setup_entry(recorder_mock: Recorder) -> Generator[AsyncMock]:
3443
"""Override async_setup_entry."""
3544
with patch(
3645
"homeassistant.components.suez_water.async_setup_entry", return_value=True
@@ -39,7 +48,7 @@ def mock_setup_entry() -> Generator[AsyncMock]:
3948

4049

4150
@pytest.fixture(name="suez_client")
42-
def mock_suez_client() -> Generator[AsyncMock]:
51+
def mock_suez_client(recorder_mock: Recorder) -> Generator[AsyncMock]:
4352
"""Create mock for suez_water external api."""
4453
with (
4554
patch(

tests/components/suez_water/snapshots/test_sensor.ambr

+2-2
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
'previous_unique_id': None,
2929
'supported_features': 0,
3030
'translation_key': 'water_price',
31-
'unique_id': 'test-counter_water_price',
31+
'unique_id': '123456_water_price',
3232
'unit_of_measurement': '€',
3333
})
3434
# ---
@@ -77,7 +77,7 @@
7777
'previous_unique_id': None,
7878
'supported_features': 0,
7979
'translation_key': 'water_usage_yesterday',
80-
'unique_id': 'test-counter_water_usage_yesterday',
80+
'unique_id': '123456_water_usage_yesterday',
8181
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
8282
})
8383
# ---

tests/components/suez_water/test_config_flow.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import pytest
77

88
from homeassistant import config_entries
9+
from homeassistant.components.recorder import Recorder
910
from homeassistant.components.suez_water.const import CONF_COUNTER_ID, DOMAIN
1011
from homeassistant.core import HomeAssistant
1112
from homeassistant.data_entry_flow import FlowResultType
@@ -69,7 +70,9 @@ async def test_form_invalid_auth(
6970
assert len(mock_setup_entry.mock_calls) == 1
7071

7172

72-
async def test_form_already_configured(hass: HomeAssistant) -> None:
73+
async def test_form_already_configured(
74+
hass: HomeAssistant, recorder_mock: Recorder
75+
) -> None:
7376
"""Test we abort when entry is already configured."""
7477

7578
entry = MockConfigEntry(

tests/components/suez_water/test_sensor.py

+29-17
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,13 @@ async def test_sensors_valid_state(
2626
entity_registry: er.EntityRegistry,
2727
) -> None:
2828
"""Test that suez_water sensor is loaded and in a valid state."""
29-
with patch("homeassistant.components.suez_water.PLATFORMS", [Platform.SENSOR]):
29+
with (
30+
patch("homeassistant.components.suez_water.PLATFORMS", [Platform.SENSOR]),
31+
patch(
32+
"homeassistant.components.suez_water.coordinator.SuezWaterCoordinator._update_statistics",
33+
return_value=None,
34+
),
35+
):
3036
await setup_integration(hass, mock_config_entry)
3137

3238
assert mock_config_entry.state is ConfigEntryState.LOADED
@@ -43,25 +49,31 @@ async def test_sensors_failed_update(
4349
) -> None:
4450
"""Test that suez_water sensor reflect failure when api fails."""
4551

46-
await setup_integration(hass, mock_config_entry)
52+
with (
53+
patch(
54+
"homeassistant.components.suez_water.coordinator.SuezWaterCoordinator._update_statistics",
55+
return_value=None,
56+
),
57+
):
58+
await setup_integration(hass, mock_config_entry)
4759

48-
assert mock_config_entry.state is ConfigEntryState.LOADED
60+
assert mock_config_entry.state is ConfigEntryState.LOADED
4961

50-
entity_ids = await hass.async_add_executor_job(hass.states.entity_ids)
51-
assert len(entity_ids) == 2
62+
entity_ids = await hass.async_add_executor_job(hass.states.entity_ids)
63+
assert len(entity_ids) == 2
5264

53-
for entity in entity_ids:
54-
state = hass.states.get(entity)
55-
assert entity
56-
assert state.state != STATE_UNAVAILABLE
65+
for entity in entity_ids:
66+
state = hass.states.get(entity)
67+
assert entity
68+
assert state.state != STATE_UNAVAILABLE
5769

58-
getattr(suez_client, method).side_effect = PySuezError("Should fail to update")
70+
getattr(suez_client, method).side_effect = PySuezError("Should fail to update")
5971

60-
freezer.tick(DATA_REFRESH_INTERVAL)
61-
async_fire_time_changed(hass)
62-
await hass.async_block_till_done(True)
72+
freezer.tick(DATA_REFRESH_INTERVAL)
73+
async_fire_time_changed(hass)
74+
await hass.async_block_till_done(True)
6375

64-
for entity in entity_ids:
65-
state = hass.states.get(entity)
66-
assert entity
67-
assert state.state == STATE_UNAVAILABLE
76+
for entity in entity_ids:
77+
state = hass.states.get(entity)
78+
assert entity
79+
assert state.state == STATE_UNAVAILABLE

0 commit comments

Comments
 (0)