Make Hydrawise poll non-critical data less frequently (#130289)

This commit is contained in:
David Knowles 2024-11-15 04:30:37 -05:00 committed by Franck Nijhof
parent f821ddeab8
commit f6cd74e2d7
No known key found for this signature in database
GPG Key ID: D62583BA8AB11CA3
10 changed files with 177 additions and 78 deletions

View File

@ -7,8 +7,12 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from .const import DOMAIN, SCAN_INTERVAL
from .coordinator import HydrawiseDataUpdateCoordinator
from .const import DOMAIN
from .coordinator import (
HydrawiseMainDataUpdateCoordinator,
HydrawiseUpdateCoordinators,
HydrawiseWaterUseDataUpdateCoordinator,
)
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
@ -29,9 +33,18 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
auth.Auth(config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD])
)
coordinator = HydrawiseDataUpdateCoordinator(hass, hydrawise, SCAN_INTERVAL)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator
main_coordinator = HydrawiseMainDataUpdateCoordinator(hass, hydrawise)
await main_coordinator.async_config_entry_first_refresh()
water_use_coordinator = HydrawiseWaterUseDataUpdateCoordinator(
hass, hydrawise, main_coordinator
)
await water_use_coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = (
HydrawiseUpdateCoordinators(
main=main_coordinator,
water_use=water_use_coordinator,
)
)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True

View File

@ -21,7 +21,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from .const import DOMAIN, SERVICE_RESUME, SERVICE_START_WATERING, SERVICE_SUSPEND
from .coordinator import HydrawiseDataUpdateCoordinator
from .coordinator import HydrawiseUpdateCoordinators
from .entity import HydrawiseEntity
@ -81,18 +81,16 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Hydrawise binary_sensor platform."""
coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][
config_entry.entry_id
]
coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
entities: list[HydrawiseBinarySensor] = []
for controller in coordinator.data.controllers.values():
for controller in coordinators.main.data.controllers.values():
entities.extend(
HydrawiseBinarySensor(coordinator, description, controller)
HydrawiseBinarySensor(coordinators.main, description, controller)
for description in CONTROLLER_BINARY_SENSORS
)
entities.extend(
HydrawiseBinarySensor(
coordinator,
coordinators.main,
description,
controller,
sensor_id=sensor.id,
@ -103,7 +101,7 @@ async def async_setup_entry(
)
entities.extend(
HydrawiseZoneBinarySensor(
coordinator, description, controller, zone_id=zone.id
coordinators.main, description, controller, zone_id=zone.id
)
for zone in controller.zones
for description in ZONE_BINARY_SENSORS

View File

@ -10,7 +10,8 @@ DEFAULT_WATERING_TIME = timedelta(minutes=15)
MANUFACTURER = "Hydrawise"
SCAN_INTERVAL = timedelta(seconds=60)
MAIN_SCAN_INTERVAL = timedelta(seconds=60)
WATER_USE_SCAN_INTERVAL = timedelta(minutes=60)
SIGNAL_UPDATE_HYDRAWISE = "hydrawise_update"

View File

@ -2,8 +2,7 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from dataclasses import dataclass, field
from pydrawise import Hydrawise
from pydrawise.schema import Controller, ControllerWaterUseSummary, Sensor, User, Zone
@ -12,7 +11,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util.dt import now
from .const import DOMAIN, LOGGER
from .const import DOMAIN, LOGGER, MAIN_SCAN_INTERVAL, WATER_USE_SCAN_INTERVAL
@dataclass
@ -20,22 +19,39 @@ class HydrawiseData:
"""Container for data fetched from the Hydrawise API."""
user: User
controllers: dict[int, Controller]
zones: dict[int, Zone]
sensors: dict[int, Sensor]
daily_water_summary: dict[int, ControllerWaterUseSummary]
controllers: dict[int, Controller] = field(default_factory=dict)
zones: dict[int, Zone] = field(default_factory=dict)
sensors: dict[int, Sensor] = field(default_factory=dict)
daily_water_summary: dict[int, ControllerWaterUseSummary] = field(
default_factory=dict
)
@dataclass
class HydrawiseUpdateCoordinators:
"""Container for all Hydrawise DataUpdateCoordinator instances."""
main: HydrawiseMainDataUpdateCoordinator
water_use: HydrawiseWaterUseDataUpdateCoordinator
class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]):
"""The Hydrawise Data Update Coordinator."""
"""Base class for Hydrawise Data Update Coordinators."""
api: Hydrawise
def __init__(
self, hass: HomeAssistant, api: Hydrawise, scan_interval: timedelta
) -> None:
class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
"""The main Hydrawise Data Update Coordinator.
This fetches the primary state data for Hydrawise controllers and zones
at a relatively frequent interval so that the primary functions of the
integration are updated in a timely manner.
"""
def __init__(self, hass: HomeAssistant, api: Hydrawise) -> None:
"""Initialize HydrawiseDataUpdateCoordinator."""
super().__init__(hass, LOGGER, name=DOMAIN, update_interval=scan_interval)
super().__init__(hass, LOGGER, name=DOMAIN, update_interval=MAIN_SCAN_INTERVAL)
self.api = api
async def _async_update_data(self) -> HydrawiseData:
@ -43,28 +59,56 @@ class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]):
# Don't fetch zones. We'll fetch them for each controller later.
# This is to prevent 502 errors in some cases.
# See: https://github.com/home-assistant/core/issues/120128
user = await self.api.get_user(fetch_zones=False)
controllers = {}
zones = {}
sensors = {}
daily_water_summary: dict[int, ControllerWaterUseSummary] = {}
for controller in user.controllers:
controllers[controller.id] = controller
data = HydrawiseData(user=await self.api.get_user(fetch_zones=False))
for controller in data.user.controllers:
data.controllers[controller.id] = controller
controller.zones = await self.api.get_zones(controller)
for zone in controller.zones:
zones[zone.id] = zone
data.zones[zone.id] = zone
for sensor in controller.sensors:
sensors[sensor.id] = sensor
data.sensors[sensor.id] = sensor
return data
class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
"""Data Update Coordinator for Hydrawise Water Use.
This fetches data that is more expensive for the Hydrawise API to compute
at a less frequent interval as to not overload the Hydrawise servers.
"""
_main_coordinator: HydrawiseMainDataUpdateCoordinator
def __init__(
self,
hass: HomeAssistant,
api: Hydrawise,
main_coordinator: HydrawiseMainDataUpdateCoordinator,
) -> None:
"""Initialize HydrawiseWaterUseDataUpdateCoordinator."""
super().__init__(
hass,
LOGGER,
name=f"{DOMAIN} water use",
update_interval=WATER_USE_SCAN_INTERVAL,
)
self.api = api
self._main_coordinator = main_coordinator
async def _async_update_data(self) -> HydrawiseData:
"""Fetch the latest data from Hydrawise."""
daily_water_summary: dict[int, ControllerWaterUseSummary] = {}
for controller in self._main_coordinator.data.controllers.values():
daily_water_summary[controller.id] = await self.api.get_water_use_summary(
controller,
now().replace(hour=0, minute=0, second=0, microsecond=0),
now(),
)
main_data = self._main_coordinator.data
return HydrawiseData(
user=user,
controllers=controllers,
zones=zones,
sensors=sensors,
user=main_data.user,
controllers=main_data.controllers,
zones=main_data.zones,
sensors=main_data.sensors,
daily_water_summary=daily_water_summary,
)

View File

@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import HydrawiseDataUpdateCoordinator
from .coordinator import HydrawiseUpdateCoordinators
from .entity import HydrawiseEntity
@ -92,7 +92,7 @@ def _get_controller_daily_total_water_use(sensor: HydrawiseSensor) -> float | No
return daily_water_summary.total_use
CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
WATER_USE_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
HydrawiseSensorEntityDescription(
key="daily_active_water_time",
translation_key="daily_active_water_time",
@ -103,6 +103,16 @@ CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
)
WATER_USE_ZONE_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
HydrawiseSensorEntityDescription(
key="daily_active_water_time",
translation_key="daily_active_water_time",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
value_fn=_get_zone_daily_active_water_time,
),
)
FLOW_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
HydrawiseSensorEntityDescription(
key="daily_total_water_use",
@ -150,13 +160,6 @@ ZONE_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTime.MINUTES,
value_fn=_get_zone_watering_time,
),
HydrawiseSensorEntityDescription(
key="daily_active_water_time",
translation_key="daily_active_water_time",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
value_fn=_get_zone_daily_active_water_time,
),
)
FLOW_MEASUREMENT_KEYS = [x.key for x in FLOW_CONTROLLER_SENSORS]
@ -168,29 +171,37 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Hydrawise sensor platform."""
coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][
config_entry.entry_id
]
coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
entities: list[HydrawiseSensor] = []
for controller in coordinator.data.controllers.values():
for controller in coordinators.main.data.controllers.values():
entities.extend(
HydrawiseSensor(coordinator, description, controller)
for description in CONTROLLER_SENSORS
HydrawiseSensor(coordinators.water_use, description, controller)
for description in WATER_USE_CONTROLLER_SENSORS
)
entities.extend(
HydrawiseSensor(coordinator, description, controller, zone_id=zone.id)
HydrawiseSensor(
coordinators.water_use, description, controller, zone_id=zone.id
)
for zone in controller.zones
for description in WATER_USE_ZONE_SENSORS
)
entities.extend(
HydrawiseSensor(coordinators.main, description, controller, zone_id=zone.id)
for zone in controller.zones
for description in ZONE_SENSORS
)
if coordinator.data.daily_water_summary[controller.id].total_use is not None:
if (
coordinators.water_use.data.daily_water_summary[controller.id].total_use
is not None
):
# we have a flow sensor for this controller
entities.extend(
HydrawiseSensor(coordinator, description, controller)
HydrawiseSensor(coordinators.water_use, description, controller)
for description in FLOW_CONTROLLER_SENSORS
)
entities.extend(
HydrawiseSensor(
coordinator,
coordinators.water_use,
description,
controller,
zone_id=zone.id,

View File

@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DEFAULT_WATERING_TIME, DOMAIN
from .coordinator import HydrawiseDataUpdateCoordinator
from .coordinator import HydrawiseUpdateCoordinators
from .entity import HydrawiseEntity
@ -66,12 +66,10 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Hydrawise switch platform."""
coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][
config_entry.entry_id
]
coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities(
HydrawiseSwitch(coordinator, description, controller, zone_id=zone.id)
for controller in coordinator.data.controllers.values()
HydrawiseSwitch(coordinators.main, description, controller, zone_id=zone.id)
for controller in coordinators.main.data.controllers.values()
for zone in controller.zones
for description in SWITCH_TYPES
)

View File

@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import HydrawiseDataUpdateCoordinator
from .coordinator import HydrawiseUpdateCoordinators
from .entity import HydrawiseEntity
VALVE_TYPES: tuple[ValveEntityDescription, ...] = (
@ -34,12 +34,10 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Hydrawise valve platform."""
coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][
config_entry.entry_id
]
coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities(
HydrawiseValve(coordinator, description, controller, zone_id=zone.id)
for controller in coordinator.data.controllers.values()
HydrawiseValve(coordinators.main, description, controller, zone_id=zone.id)
for controller in coordinators.main.data.controllers.values()
for zone in controller.zones
for description in VALVE_TYPES
)

View File

@ -9,7 +9,7 @@ from freezegun.api import FrozenDateTimeFactory
from pydrawise.schema import Controller
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.hydrawise.const import SCAN_INTERVAL
from homeassistant.components.hydrawise.const import MAIN_SCAN_INTERVAL
from homeassistant.const import STATE_OFF, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@ -42,7 +42,8 @@ async def test_update_data_fails(
# Make the coordinator refresh data.
mock_pydrawise.get_user.reset_mock(return_value=True)
mock_pydrawise.get_user.side_effect = ClientError
freezer.tick(SCAN_INTERVAL + timedelta(seconds=30))
mock_pydrawise.get_water_use_summary.side_effect = ClientError
freezer.tick(MAIN_SCAN_INTERVAL + timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done()
@ -61,7 +62,7 @@ async def test_controller_offline(
"""Test the binary_sensor for the controller being online."""
# Make the coordinator refresh data.
controller.online = False
freezer.tick(SCAN_INTERVAL + timedelta(seconds=30))
freezer.tick(MAIN_SCAN_INTERVAL + timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done()

View File

@ -8,7 +8,7 @@ from aiohttp import ClientError
from freezegun.api import FrozenDateTimeFactory
from pydrawise.schema import Controller
from homeassistant.components.hydrawise.const import SCAN_INTERVAL
from homeassistant.components.hydrawise.const import WATER_USE_SCAN_INTERVAL
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
@ -42,7 +42,8 @@ async def test_api_offline(
config_entry = await mock_add_config_entry()
mock_pydrawise.get_user.reset_mock(return_value=True)
mock_pydrawise.get_user.side_effect = ClientError
freezer.tick(SCAN_INTERVAL + timedelta(seconds=30))
mock_pydrawise.get_water_use_summary.side_effect = ClientError
freezer.tick(WATER_USE_SCAN_INTERVAL + timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done()
_test_availability(hass, config_entry, entity_registry)

View File

@ -1,12 +1,18 @@
"""Test Hydrawise sensor."""
from collections.abc import Awaitable, Callable
from unittest.mock import patch
from datetime import timedelta
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
from pydrawise.schema import Controller, ControllerWaterUseSummary, User, Zone
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.hydrawise.const import (
MAIN_SCAN_INTERVAL,
WATER_USE_SCAN_INTERVAL,
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@ -16,7 +22,7 @@ from homeassistant.util.unit_system import (
UnitSystem,
)
from tests.common import MockConfigEntry, snapshot_platform
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.mark.freeze_time("2023-10-01 00:00:00+00:00")
@ -50,6 +56,34 @@ async def test_suspended_state(
assert next_cycle.state == "unknown"
@pytest.mark.freeze_time("2024-11-01 00:00:00+00:00")
async def test_usage_refresh(
hass: HomeAssistant,
mock_added_config_entry: MockConfigEntry,
mock_pydrawise: AsyncMock,
controller_water_use_summary: ControllerWaterUseSummary,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that water usage summaries refresh less frequently than other data."""
assert hass.states.get("sensor.zone_one_daily_active_water_use") is not None
mock_pydrawise.get_water_use_summary.assert_called_once()
# Make the coordinator refresh data.
mock_pydrawise.get_water_use_summary.reset_mock()
freezer.tick(MAIN_SCAN_INTERVAL + timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Make sure we didn't fetch water use summary again.
mock_pydrawise.get_water_use_summary.assert_not_called()
# Wait for enough time to pass for a water use summary fetch.
mock_pydrawise.get_water_use_summary.return_value = controller_water_use_summary
freezer.tick(WATER_USE_SCAN_INTERVAL + timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done()
mock_pydrawise.get_water_use_summary.assert_called_once()
async def test_no_sensor_and_water_state(
hass: HomeAssistant,
controller: Controller,