Skip to content

Commit 189b426

Browse files
authored
Add water cost statistic and exclude estimations from computation (#18)
1 parent 0c89ff7 commit 189b426

9 files changed

Lines changed: 244 additions & 68 deletions

File tree

README.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ If installed via HACS, you can also uninstall the integration from HACS after re
5555
| **Email** | Yes | The email address used to log in to the SEDIF customer portal at [connexion.leaudiledefrance.fr](https://connexion.leaudiledefrance.fr). |
5656
| **Password** | Yes | The password for your SEDIF portal account. Stored locally in Home Assistant and only sent to the SEDIF portal for authentication. |
5757

58-
Each contract appears as a separate device named **SEDIF Contract {number}** (e.g. "SEDIF Contract 9235380"), where the number matches your SEDIF contract reference.
58+
Each contract appears as a separate device named **SEDIF Contract {number}** (e.g. "SEDIF Contract XXXXXXX"), where the number matches your SEDIF contract reference.
5959

6060
## Entities
6161

@@ -111,14 +111,23 @@ The meter reading and daily consumption sensors expose the following additional
111111

112112
The integration imports historical water consumption data as **external statistics** with correct timestamps, so the Energy dashboard attributes usage to the right day (not when the integration polled).
113113

114-
On first setup, up to **90 days** of history are imported. After that, only the last 7 days are fetched on each update cycle, keeping the statistics up to date without redundant API calls.
114+
On first setup, up to **90 days** of history are imported. After that, only the last 7 days are fetched on each update cycle, keeping the statistics up to date without redundant API calls. Estimated readings are excluded from statistics to avoid double-counting when the actual value is published later.
115+
116+
Two external statistics are created per contract:
117+
118+
| Statistic | Unit | Description |
119+
|---|---|---|
120+
| **SEDIF {contract_number} water consumption** || Cumulative water consumption (for Energy Dashboard water tracking) |
121+
| **SEDIF {contract_number} water cost** | EUR | Cumulative water cost based on the average price per m³ reported by the SEDIF portal |
115122

116123
To add water tracking:
117124

118125
1. Go to **Settings > Dashboards > Energy**
119126
2. In the **Water consumption** section, click **Add water source**
120127
3. Search for **SEDIF {contract_number} water consumption** — this is the external statistic created by the integration
121128

129+
The water cost statistic can be used in custom cards or automations. It uses the average price per cubic meter provided by the SEDIF portal to compute daily costs.
130+
122131
> **Important:** Use the external statistic, not the sensor entities. The **Meter Reading** sensor updates every 6 hours and timestamps data at poll time, which causes consumption to appear on the wrong day. The external statistic uses the actual date reported by SEDIF, so the Energy dashboard shows accurate daily breakdowns.
123132
124133
## Data updates

custom_components/eauidf/coordinator.py

Lines changed: 125 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
get_last_statistics,
1919
)
2020
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfVolume
21-
from homeassistant.exceptions import ConfigEntryAuthFailed
21+
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
2222
from homeassistant.helpers.aiohttp_client import async_create_clientsession
2323
from homeassistant.helpers.issue_registry import (
2424
IssueSeverity,
@@ -29,7 +29,12 @@
2929
from homeassistant.util import dt as dt_util
3030
from homeassistant.util.unit_conversion import VolumeConverter
3131
from pyeauidf import EauIDFClient
32-
from pyeauidf.client import AuthenticationError, ConsumptionRecord, EauIDFError
32+
from pyeauidf.client import (
33+
AuthenticationError,
34+
ConsumptionData,
35+
ConsumptionRecord,
36+
EauIDFError,
37+
)
3338

3439
from .const import CONF_CONTRACTS, DOMAIN
3540

@@ -56,7 +61,7 @@ class ContractData:
5661
is_estimated: bool
5762

5863

59-
type FetchResult = tuple[SedifData, dict[str, list[ConsumptionRecord]]]
64+
type FetchResult = tuple[SedifData, dict[str, ConsumptionData]]
6065
type SedifData = dict[str, ContractData]
6166

6267

@@ -88,9 +93,7 @@ async def _async_update_data(self) -> SedifData:
8893
username, password, session=async_create_clientsession(self.hass)
8994
)
9095
try:
91-
sensor_data, all_records = await self._fetch_all(
92-
client, contracts, start_date
93-
)
96+
sensor_data, all_data = await self._fetch_all(client, contracts, start_date)
9497
except AuthenticationError as err:
9598
self._on_failure()
9699
raise ConfigEntryAuthFailed(str(err)) from err
@@ -104,7 +107,7 @@ async def _async_update_data(self) -> SedifData:
104107
raise UpdateFailed(msg) from err
105108
else:
106109
self._on_success()
107-
await self._insert_statistics(all_records)
110+
await self._insert_statistics(all_data)
108111
return sensor_data
109112
finally:
110113
await client.close()
@@ -119,16 +122,19 @@ async def _compute_start_date(
119122

120123
for contract in contracts:
121124
statistic_id = f"{DOMAIN}:{contract['number']}_water_consumption"
122-
last_stat: dict[str, list[dict[str, Any]]] = await get_instance(
123-
self.hass
124-
).async_add_executor_job(
125-
get_last_statistics, # type: ignore[arg-type]
126-
self.hass,
127-
1,
128-
statistic_id,
129-
True, # noqa: FBT003
130-
set(),
131-
)
125+
try:
126+
last_stat: dict[str, list[dict[str, Any]]] = await get_instance(
127+
self.hass
128+
).async_add_executor_job(
129+
get_last_statistics, # type: ignore[arg-type]
130+
self.hass,
131+
1,
132+
statistic_id,
133+
True, # noqa: FBT003
134+
set(),
135+
)
136+
except HomeAssistantError:
137+
last_stat = {}
132138
if not last_stat:
133139
_LOGGER.debug(
134140
"No existing statistics for %s, importing %d days",
@@ -171,17 +177,24 @@ def _on_failure(self) -> None:
171177

172178
async def _insert_statistics(
173179
self,
174-
records_by_contract: dict[str, list[ConsumptionRecord]],
180+
data_by_contract: dict[str, ConsumptionData],
175181
) -> None:
176182
"""Import consumption records as external statistics."""
177-
for contract_number, records in records_by_contract.items():
183+
for contract_number, data in data_by_contract.items():
178184
try:
179-
await self._insert_contract_statistics(contract_number, records)
185+
await self._insert_contract_statistics(contract_number, data.records)
180186
except Exception:
181187
_LOGGER.exception(
182188
"Failed to insert statistics for contract %s",
183189
contract_number,
184190
)
191+
try:
192+
await self._insert_cost_statistics(contract_number, data)
193+
except Exception:
194+
_LOGGER.exception(
195+
"Failed to insert cost statistics for contract %s",
196+
contract_number,
197+
)
185198

186199
async def _insert_contract_statistics(
187200
self,
@@ -200,16 +213,20 @@ async def _insert_contract_statistics(
200213
unit_of_measurement=UnitOfVolume.CUBIC_METERS,
201214
)
202215

203-
last_stat: dict[str, list[dict[str, Any]]] = await get_instance(
204-
self.hass
205-
).async_add_executor_job(
206-
get_last_statistics, # type: ignore[arg-type]
207-
self.hass,
208-
1,
209-
statistic_id,
210-
True, # noqa: FBT003
211-
set(),
212-
)
216+
try:
217+
last_stat: dict[str, list[dict[str, Any]]] = await get_instance(
218+
self.hass
219+
).async_add_executor_job(
220+
get_last_statistics, # type: ignore[arg-type]
221+
self.hass,
222+
1,
223+
statistic_id,
224+
True, # noqa: FBT003
225+
set(),
226+
)
227+
except HomeAssistantError:
228+
_LOGGER.debug("No existing statistics for %s", statistic_id)
229+
last_stat = {}
213230
last_stats_time: float | None = None
214231
if last_stat:
215232
raw_start = last_stat[statistic_id][0]["start"]
@@ -221,6 +238,8 @@ async def _insert_contract_statistics(
221238
local_tz = dt_util.get_default_time_zone()
222239
statistics: list[StatisticData] = []
223240
for record in sorted(records, key=lambda r: r.date):
241+
if record.is_estimated:
242+
continue
224243
d = record.date.date()
225244
start = datetime(d.year, d.month, d.day, tzinfo=local_tz)
226245
if last_stats_time is not None and start.timestamp() <= last_stats_time:
@@ -243,6 +262,76 @@ async def _insert_contract_statistics(
243262
else:
244263
_LOGGER.debug("No new statistics to insert for %s", statistic_id)
245264

265+
async def _insert_cost_statistics(
266+
self,
267+
contract_number: str,
268+
data: ConsumptionData,
269+
) -> None:
270+
"""Import cost statistics for a single contract."""
271+
statistic_id = f"{DOMAIN}:{contract_number}_water_cost"
272+
metadata = StatisticMetaData(
273+
mean_type=StatisticMeanType.NONE,
274+
has_sum=True,
275+
name=f"SEDIF {contract_number} water cost",
276+
source=DOMAIN,
277+
statistic_id=statistic_id,
278+
unit_class=None,
279+
unit_of_measurement="EUR",
280+
)
281+
282+
last_stats_time: float | None = None
283+
running_sum: float = 0.0
284+
try:
285+
last_stat: dict[str, list[dict[str, Any]]] = await get_instance(
286+
self.hass
287+
).async_add_executor_job(
288+
get_last_statistics, # type: ignore[arg-type]
289+
self.hass,
290+
1,
291+
statistic_id,
292+
True, # noqa: FBT003
293+
{"sum"},
294+
)
295+
except HomeAssistantError:
296+
_LOGGER.debug("No existing cost statistics for %s", statistic_id)
297+
last_stat = {}
298+
if last_stat:
299+
raw_start = last_stat[statistic_id][0]["start"]
300+
if isinstance(raw_start, (int, float)):
301+
last_stats_time = raw_start
302+
else:
303+
last_stats_time = raw_start.timestamp()
304+
running_sum = last_stat[statistic_id][0].get("sum", 0.0) or 0.0
305+
306+
local_tz = dt_util.get_default_time_zone()
307+
statistics: list[StatisticData] = []
308+
for record in sorted(data.records, key=lambda r: r.date):
309+
if record.is_estimated:
310+
continue
311+
d = record.date.date()
312+
start = datetime(d.year, d.month, d.day, tzinfo=local_tz)
313+
if last_stats_time is not None and start.timestamp() <= last_stats_time:
314+
continue
315+
daily_cost = data.daily_cost(record)
316+
running_sum += daily_cost
317+
statistics.append(
318+
StatisticData(
319+
start=start,
320+
state=daily_cost,
321+
sum=running_sum,
322+
)
323+
)
324+
325+
async_add_external_statistics(self.hass, metadata, statistics)
326+
if statistics:
327+
_LOGGER.debug(
328+
"Inserted %d cost statistics for %s",
329+
len(statistics),
330+
statistic_id,
331+
)
332+
else:
333+
_LOGGER.debug("No new cost statistics to insert for %s", statistic_id)
334+
246335
@staticmethod
247336
async def _fetch_all(
248337
client: EauIDFClient,
@@ -252,18 +341,18 @@ async def _fetch_all(
252341
"""Fetch consumption data for all contracts."""
253342
await client.login()
254343
sensor_data: SedifData = {}
255-
all_records: dict[str, list[ConsumptionRecord]] = {}
344+
all_data: dict[str, ConsumptionData] = {}
256345
end = datetime.now(UTC).date()
257346
for contract in contracts:
258347
cid = contract["id"]
259348
number = contract["number"]
260349
try:
261-
records = await client.get_daily_consumption(
350+
data = await client.get_daily_consumption(
262351
contract_id=cid, start_date=start_date, end_date=end
263352
)
264-
if records:
265-
all_records[number] = records
266-
latest = records[-1]
353+
if data.records:
354+
all_data[number] = data
355+
latest = data.records[-1]
267356
sensor_data[number] = ContractData(
268357
meter_reading_m3=latest.meter_reading,
269358
daily_consumption_l=latest.consumption_liters,
@@ -282,4 +371,4 @@ async def _fetch_all(
282371
if not sensor_data and contracts:
283372
msg = "Failed to fetch data for any contract"
284373
raise EauIDFError(msg)
285-
return sensor_data, all_records
374+
return sensor_data, all_data

custom_components/eauidf/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@
88
"iot_class": "cloud_polling",
99
"issue_tracker": "https://github.com/TimoPtr/ha_eauidf/issues",
1010
"quality_scale": "platinum",
11-
"requirements": ["pyeauidf==1.0.1"],
11+
"requirements": ["pyeauidf==1.2.0"],
1212
"version": "1.0.1"
1313
}

custom_components/eauidf/sensor.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from dataclasses import dataclass
6-
from typing import TYPE_CHECKING, Any
6+
from typing import TYPE_CHECKING
77

88
from homeassistant.components.sensor import (
99
SensorDeviceClass,
@@ -22,6 +22,7 @@
2222

2323
if TYPE_CHECKING:
2424
from collections.abc import Callable
25+
from datetime import date
2526

2627
from homeassistant.core import HomeAssistant
2728
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -33,7 +34,7 @@
3334
class SedifSensorDescription(SensorEntityDescription):
3435
"""Describe a SEDIF sensor."""
3536

36-
value_fn: Callable[[ContractData], Any]
37+
value_fn: Callable[[ContractData], float | date]
3738
has_extra_attributes: bool = False
3839

3940

@@ -118,7 +119,7 @@ def __init__(
118119
)
119120

120121
@property
121-
def native_value(self) -> Any:
122+
def native_value(self) -> float | date | None:
122123
"""Return the sensor value."""
123124
if not self.coordinator.data:
124125
return None

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
name = "ha-eauidf"
33
version = "1.0.1"
44
requires-python = ">=3.14"
5-
dependencies = ["pyeauidf==1.0.0"]
5+
dependencies = ["pyeauidf==1.2.0"]
66

77
[project.optional-dependencies]
88
test = [

tests/conftest.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@ def mock_recorder_before_hass(recorder_db_url: str) -> None:
2727
MOCK_USERNAME = "test@example.com"
2828
MOCK_PASSWORD = "secret"
2929
MOCK_CONTRACT_ID = "CONTRACT_001"
30-
MOCK_CONTRACT_NUMBER = "9235380"
30+
MOCK_CONTRACT_NUMBER = "1234567"
3131
MOCK_CONTRACTS = [{"id": MOCK_CONTRACT_ID, "number": MOCK_CONTRACT_NUMBER}]
32+
MOCK_PRICE_PER_M3 = 4.5
3233

3334

3435
@pytest.fixture
@@ -74,6 +75,23 @@ def make_consumption_record(
7475
return record
7576

7677

78+
def make_consumption_data(
79+
records: list[MagicMock], price_per_m3: float = MOCK_PRICE_PER_M3
80+
) -> MagicMock:
81+
"""Create a mock ConsumptionData with records and price."""
82+
data = MagicMock()
83+
data.records = records
84+
data.price_per_m3 = price_per_m3
85+
data.daily_cost = lambda record: (record.consumption_liters / 1000) * price_per_m3
86+
return data
87+
88+
89+
@pytest.fixture
90+
def mock_consumption_data(mock_record: MagicMock) -> MagicMock:
91+
"""Single-record ConsumptionData mock."""
92+
return make_consumption_data([mock_record])
93+
94+
7795
@pytest.fixture
7896
def mock_records_list() -> list[MagicMock]:
7997
base = date(2026, 5, 11)
@@ -82,3 +100,9 @@ def mock_records_list() -> list[MagicMock]:
82100
make_consumption_record(base + timedelta(days=1), 120.0, 1234.12),
83101
make_consumption_record(base + timedelta(days=2), 150.0, 1234.27),
84102
]
103+
104+
105+
@pytest.fixture
106+
def mock_consumption_data_list(mock_records_list: list[MagicMock]) -> MagicMock:
107+
"""Multi-record ConsumptionData mock."""
108+
return make_consumption_data(mock_records_list)

0 commit comments

Comments
 (0)