mirror of
https://github.com/home-assistant/core.git
synced 2025-12-20 10:59:24 +00:00
Add statistics importing for Anglian Water (#157757)
This commit is contained in:
@@ -4,13 +4,28 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from pyanglianwater import AnglianWater
|
from pyanglianwater import AnglianWater
|
||||||
from pyanglianwater.exceptions import ExpiredAccessTokenError, UnknownEndpointError
|
from pyanglianwater.exceptions import ExpiredAccessTokenError, UnknownEndpointError
|
||||||
|
|
||||||
|
from homeassistant.components.recorder import get_instance
|
||||||
|
from homeassistant.components.recorder.models import (
|
||||||
|
StatisticData,
|
||||||
|
StatisticMeanType,
|
||||||
|
StatisticMetaData,
|
||||||
|
)
|
||||||
|
from homeassistant.components.recorder.statistics import (
|
||||||
|
async_add_external_statistics,
|
||||||
|
get_last_statistics,
|
||||||
|
statistics_during_period,
|
||||||
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import UnitOfVolume
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
from homeassistant.util import dt as dt_util
|
||||||
|
from homeassistant.util.unit_conversion import VolumeConverter
|
||||||
|
|
||||||
from .const import CONF_ACCOUNT_NUMBER, DOMAIN
|
from .const import CONF_ACCOUNT_NUMBER, DOMAIN
|
||||||
|
|
||||||
@@ -44,6 +59,107 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
|
|||||||
async def _async_update_data(self) -> None:
|
async def _async_update_data(self) -> None:
|
||||||
"""Update data from Anglian Water's API."""
|
"""Update data from Anglian Water's API."""
|
||||||
try:
|
try:
|
||||||
return await self.api.update(self.config_entry.data[CONF_ACCOUNT_NUMBER])
|
await self.api.update(self.config_entry.data[CONF_ACCOUNT_NUMBER])
|
||||||
|
await self._insert_statistics()
|
||||||
except (ExpiredAccessTokenError, UnknownEndpointError) as err:
|
except (ExpiredAccessTokenError, UnknownEndpointError) as err:
|
||||||
raise UpdateFailed from err
|
raise UpdateFailed from err
|
||||||
|
|
||||||
|
async def _insert_statistics(self) -> None:
|
||||||
|
"""Insert statistics for water meters into Home Assistant."""
|
||||||
|
for meter in self.api.meters.values():
|
||||||
|
id_prefix = (
|
||||||
|
f"{self.config_entry.data[CONF_ACCOUNT_NUMBER]}_{meter.serial_number}"
|
||||||
|
)
|
||||||
|
usage_statistic_id = f"{DOMAIN}:{id_prefix}_usage".lower()
|
||||||
|
_LOGGER.debug("Updating statistics for meter %s", meter.serial_number)
|
||||||
|
name_prefix = (
|
||||||
|
f"Anglian Water {self.config_entry.data[CONF_ACCOUNT_NUMBER]} "
|
||||||
|
f"{meter.serial_number}"
|
||||||
|
)
|
||||||
|
usage_metadata = StatisticMetaData(
|
||||||
|
mean_type=StatisticMeanType.NONE,
|
||||||
|
has_sum=True,
|
||||||
|
name=f"{name_prefix} Usage",
|
||||||
|
source=DOMAIN,
|
||||||
|
statistic_id=usage_statistic_id,
|
||||||
|
unit_class=VolumeConverter.UNIT_CLASS,
|
||||||
|
unit_of_measurement=UnitOfVolume.CUBIC_METERS,
|
||||||
|
)
|
||||||
|
last_stat = await get_instance(self.hass).async_add_executor_job(
|
||||||
|
get_last_statistics, self.hass, 1, usage_statistic_id, True, set()
|
||||||
|
)
|
||||||
|
if not last_stat:
|
||||||
|
_LOGGER.debug("Updating statistics for the first time")
|
||||||
|
usage_sum = 0.0
|
||||||
|
last_stats_time = None
|
||||||
|
else:
|
||||||
|
if not meter.readings or len(meter.readings) == 0:
|
||||||
|
_LOGGER.debug("No recent usage statistics found, skipping update")
|
||||||
|
continue
|
||||||
|
# Anglian Water stats are hourly, the read_at time is the time that the meter took the reading
|
||||||
|
# We remove 1 hour from this so that the data is shown in the correct hour on the dashboards
|
||||||
|
parsed_read_at = dt_util.parse_datetime(meter.readings[0]["read_at"])
|
||||||
|
if not parsed_read_at:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Could not parse read_at time %s, skipping update",
|
||||||
|
meter.readings[0]["read_at"],
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
start = dt_util.as_local(parsed_read_at) - timedelta(hours=1)
|
||||||
|
_LOGGER.debug("Getting statistics at %s", start)
|
||||||
|
for end in (start + timedelta(seconds=1), None):
|
||||||
|
stats = await get_instance(self.hass).async_add_executor_job(
|
||||||
|
statistics_during_period,
|
||||||
|
self.hass,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
{
|
||||||
|
usage_statistic_id,
|
||||||
|
},
|
||||||
|
"hour",
|
||||||
|
None,
|
||||||
|
{"sum"},
|
||||||
|
)
|
||||||
|
if stats:
|
||||||
|
break
|
||||||
|
if end:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Not found, trying to find oldest statistic after %s",
|
||||||
|
start,
|
||||||
|
)
|
||||||
|
assert stats
|
||||||
|
|
||||||
|
def _safe_get_sum(records: list[Any]) -> float:
|
||||||
|
if records and "sum" in records[0]:
|
||||||
|
return float(records[0]["sum"])
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
usage_sum = _safe_get_sum(stats.get(usage_statistic_id, []))
|
||||||
|
last_stats_time = stats[usage_statistic_id][0]["start"]
|
||||||
|
|
||||||
|
usage_statistics = []
|
||||||
|
|
||||||
|
for read in meter.readings:
|
||||||
|
parsed_read_at = dt_util.parse_datetime(read["read_at"])
|
||||||
|
if not parsed_read_at:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Could not parse read_at time %s, skipping reading",
|
||||||
|
read["read_at"],
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
start = dt_util.as_local(parsed_read_at) - timedelta(hours=1)
|
||||||
|
if last_stats_time is not None and start.timestamp() <= last_stats_time:
|
||||||
|
continue
|
||||||
|
usage_state = max(0, read["consumption"] / 1000)
|
||||||
|
usage_sum = max(0, read["read"])
|
||||||
|
usage_statistics.append(
|
||||||
|
StatisticData(
|
||||||
|
start=start,
|
||||||
|
state=usage_state,
|
||||||
|
sum=usage_sum,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Adding %s statistics for %s", len(usage_statistics), usage_statistic_id
|
||||||
|
)
|
||||||
|
async_add_external_statistics(self.hass, usage_metadata, usage_statistics)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "anglian_water",
|
"domain": "anglian_water",
|
||||||
"name": "Anglian Water",
|
"name": "Anglian Water",
|
||||||
|
"after_dependencies": ["recorder"],
|
||||||
"codeowners": ["@pantherale0"],
|
"codeowners": ["@pantherale0"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/anglian_water",
|
"documentation": "https://www.home-assistant.io/integrations/anglian_water",
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ def mock_smart_meter() -> SmartMeter:
|
|||||||
mock.latest_read = 50
|
mock.latest_read = 50
|
||||||
mock.yesterday_water_cost = 0.5
|
mock.yesterday_water_cost = 0.5
|
||||||
mock.yesterday_sewerage_cost = 0.5
|
mock.yesterday_sewerage_cost = 0.5
|
||||||
|
mock.readings = [
|
||||||
|
{"read_at": "2024-06-01T12:00:00Z", "consumption": 10, "read": 10},
|
||||||
|
{"read_at": "2024-06-01T13:00:00Z", "consumption": 15, "read": 25},
|
||||||
|
{"read_at": "2024-06-01T14:00:00Z", "consumption": 25, "read": 50},
|
||||||
|
]
|
||||||
return mock
|
return mock
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# serializer version: 1
|
||||||
|
# name: test_coordinator_first_run
|
||||||
|
defaultdict({
|
||||||
|
'anglian_water:12345678_testsn_usage': list([
|
||||||
|
dict({
|
||||||
|
'end': 1717243200.0,
|
||||||
|
'start': 1717239600.0,
|
||||||
|
'state': 0.01,
|
||||||
|
'sum': 10.0,
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'end': 1717246800.0,
|
||||||
|
'start': 1717243200.0,
|
||||||
|
'state': 0.015,
|
||||||
|
'sum': 25.0,
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'end': 1717250400.0,
|
||||||
|
'start': 1717246800.0,
|
||||||
|
'state': 0.025,
|
||||||
|
'sum': 50.0,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_coordinator_subsequent_run
|
||||||
|
defaultdict({
|
||||||
|
'anglian_water:12345678_testsn_usage': list([
|
||||||
|
dict({
|
||||||
|
'end': 1717243200.0,
|
||||||
|
'start': 1717239600.0,
|
||||||
|
'state': 0.01,
|
||||||
|
'sum': 10.0,
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'end': 1717246800.0,
|
||||||
|
'start': 1717243200.0,
|
||||||
|
'state': 0.015,
|
||||||
|
'sum': 25.0,
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'end': 1717250400.0,
|
||||||
|
'start': 1717246800.0,
|
||||||
|
'state': 0.035,
|
||||||
|
'sum': 70.0,
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'end': 1717254000.0,
|
||||||
|
'start': 1717250400.0,
|
||||||
|
'state': 0.02,
|
||||||
|
'sum': 90.0,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
# ---
|
||||||
164
tests/components/anglian_water/test_coordinator.py
Normal file
164
tests/components/anglian_water/test_coordinator.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
"""Tests for the Anglian Water coordinator."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
from pyanglianwater.meter import SmartMeter
|
||||||
|
import pytest
|
||||||
|
from syrupy.assertion import SnapshotAssertion
|
||||||
|
|
||||||
|
from homeassistant.components.anglian_water.coordinator import (
|
||||||
|
AnglianWaterUpdateCoordinator,
|
||||||
|
)
|
||||||
|
from homeassistant.components.recorder import Recorder
|
||||||
|
from homeassistant.components.recorder.statistics import (
|
||||||
|
get_last_statistics,
|
||||||
|
statistics_during_period,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
|
from .const import ACCOUNT_NUMBER
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
from tests.components.recorder.common import async_wait_recording_done
|
||||||
|
|
||||||
|
|
||||||
|
async def test_coordinator_first_run(
|
||||||
|
recorder_mock: Recorder,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
mock_anglian_water_client: AsyncMock,
|
||||||
|
snapshot: SnapshotAssertion,
|
||||||
|
) -> None:
|
||||||
|
"""Test the coordinator on its first run with no existing statistics."""
|
||||||
|
coordinator = AnglianWaterUpdateCoordinator(
|
||||||
|
hass, mock_anglian_water_client, mock_config_entry
|
||||||
|
)
|
||||||
|
await coordinator._async_update_data()
|
||||||
|
await async_wait_recording_done(hass)
|
||||||
|
|
||||||
|
stats = await hass.async_add_executor_job(
|
||||||
|
statistics_during_period,
|
||||||
|
hass,
|
||||||
|
dt_util.utc_from_timestamp(0),
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
f"anglian_water:{ACCOUNT_NUMBER}_testsn_usage",
|
||||||
|
},
|
||||||
|
"hour",
|
||||||
|
None,
|
||||||
|
{"state", "sum"},
|
||||||
|
)
|
||||||
|
assert stats == snapshot
|
||||||
|
|
||||||
|
|
||||||
|
async def test_coordinator_subsequent_run(
|
||||||
|
recorder_mock: Recorder,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
mock_smart_meter: SmartMeter,
|
||||||
|
mock_anglian_water_client: AsyncMock,
|
||||||
|
snapshot: SnapshotAssertion,
|
||||||
|
) -> None:
|
||||||
|
"""Test the coordinator correctly updates statistics on subsequent runs."""
|
||||||
|
# 1st run
|
||||||
|
coordinator = AnglianWaterUpdateCoordinator(
|
||||||
|
hass, mock_anglian_water_client, mock_config_entry
|
||||||
|
)
|
||||||
|
await coordinator._async_update_data()
|
||||||
|
await async_wait_recording_done(hass)
|
||||||
|
|
||||||
|
# 2nd run with an updated reading for one read and a new read added.
|
||||||
|
mock_smart_meter.readings[-1] = {
|
||||||
|
"read_at": "2024-06-01T14:00:00Z",
|
||||||
|
"consumption": 35,
|
||||||
|
"read": 70,
|
||||||
|
}
|
||||||
|
mock_smart_meter.readings.append(
|
||||||
|
{"read_at": "2024-06-01T15:00:00Z", "consumption": 20, "read": 90}
|
||||||
|
)
|
||||||
|
await coordinator._async_update_data()
|
||||||
|
await async_wait_recording_done(hass)
|
||||||
|
|
||||||
|
# Check all stats
|
||||||
|
stats = await hass.async_add_executor_job(
|
||||||
|
statistics_during_period,
|
||||||
|
hass,
|
||||||
|
dt_util.utc_from_timestamp(0),
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
f"anglian_water:{ACCOUNT_NUMBER}_testsn_usage",
|
||||||
|
},
|
||||||
|
"hour",
|
||||||
|
None,
|
||||||
|
{"state", "sum"},
|
||||||
|
)
|
||||||
|
assert stats == snapshot
|
||||||
|
|
||||||
|
|
||||||
|
async def test_coordinator_subsequent_run_no_energy_data(
|
||||||
|
recorder_mock: Recorder,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
mock_smart_meter: SmartMeter,
|
||||||
|
mock_anglian_water_client: AsyncMock,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Test the coordinator handles no recent usage/cost data."""
|
||||||
|
# 1st run
|
||||||
|
coordinator = AnglianWaterUpdateCoordinator(
|
||||||
|
hass, mock_anglian_water_client, mock_config_entry
|
||||||
|
)
|
||||||
|
await coordinator._async_update_data()
|
||||||
|
await async_wait_recording_done(hass)
|
||||||
|
|
||||||
|
# 2nd run with no readings
|
||||||
|
mock_smart_meter.readings = []
|
||||||
|
await coordinator._async_update_data()
|
||||||
|
|
||||||
|
assert "No recent usage statistics found, skipping update" in caplog.text
|
||||||
|
# Verify no new stats were added by checking the sum remains 50
|
||||||
|
statistic_id = f"anglian_water:{ACCOUNT_NUMBER}_testsn_usage"
|
||||||
|
stats = await hass.async_add_executor_job(
|
||||||
|
get_last_statistics, hass, 1, statistic_id, True, {"sum"}
|
||||||
|
)
|
||||||
|
assert stats[statistic_id][0]["sum"] == 50
|
||||||
|
|
||||||
|
|
||||||
|
async def test_coordinator_invalid_readings(
|
||||||
|
recorder_mock: Recorder,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
mock_smart_meter: SmartMeter,
|
||||||
|
mock_anglian_water_client: AsyncMock,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Test the coordinator handles bad data / invalid readings correctly."""
|
||||||
|
coordinator = AnglianWaterUpdateCoordinator(
|
||||||
|
hass, mock_anglian_water_client, mock_config_entry
|
||||||
|
)
|
||||||
|
await coordinator._async_update_data()
|
||||||
|
await async_wait_recording_done(hass)
|
||||||
|
|
||||||
|
# Test that an invalid read_at on the first reading skips the entire update
|
||||||
|
mock_smart_meter.readings = [
|
||||||
|
{"read_at": "invalid-date-format", "consumption": 10, "read": 10},
|
||||||
|
]
|
||||||
|
await coordinator._async_update_data()
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"Could not parse read_at time invalid-date-format, skipping update"
|
||||||
|
in caplog.text
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test that individual invalid readings are skipped
|
||||||
|
mock_smart_meter.readings = [
|
||||||
|
{"read_at": "2024-06-01T12:00:00Z", "consumption": 10, "read": 10},
|
||||||
|
{"read_at": "also-invalid-date", "consumption": 15, "read": 25},
|
||||||
|
]
|
||||||
|
await coordinator._async_update_data()
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"Could not parse read_at time also-invalid-date, skipping reading"
|
||||||
|
in caplog.text
|
||||||
|
)
|
||||||
@@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch
|
|||||||
|
|
||||||
from syrupy.assertion import SnapshotAssertion
|
from syrupy.assertion import SnapshotAssertion
|
||||||
|
|
||||||
|
from homeassistant.components.recorder import Recorder
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
@@ -14,6 +15,7 @@ from tests.common import MockConfigEntry, snapshot_platform
|
|||||||
|
|
||||||
|
|
||||||
async def test_sensor(
|
async def test_sensor(
|
||||||
|
recorder_mock: Recorder,
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_config_entry: MockConfigEntry,
|
mock_config_entry: MockConfigEntry,
|
||||||
mock_anglian_water_client: AsyncMock,
|
mock_anglian_water_client: AsyncMock,
|
||||||
|
|||||||
Reference in New Issue
Block a user