Teach Hydrawise to auto-add/remove devices (#149547)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
David Knowles 2025-07-28 10:06:15 -04:00 committed by GitHub
parent 386f709fd3
commit 8fc8220924
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 321 additions and 69 deletions

View File

@ -2,11 +2,11 @@
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from datetime import datetime
from pydrawise import Zone
from pydrawise import Controller, Zone
import voluptuous as vol
from homeassistant.components.binary_sensor import (
@ -81,31 +81,46 @@ async def async_setup_entry(
) -> None:
"""Set up the Hydrawise binary_sensor platform."""
coordinators = config_entry.runtime_data
entities: list[HydrawiseBinarySensor] = []
for controller in coordinators.main.data.controllers.values():
entities.extend(
HydrawiseBinarySensor(coordinators.main, description, controller)
for description in CONTROLLER_BINARY_SENSORS
)
entities.extend(
HydrawiseBinarySensor(
coordinators.main,
description,
controller,
sensor_id=sensor.id,
def _add_new_controllers(controllers: Iterable[Controller]) -> None:
entities: list[HydrawiseBinarySensor] = []
for controller in controllers:
entities.extend(
HydrawiseBinarySensor(coordinators.main, description, controller)
for description in CONTROLLER_BINARY_SENSORS
)
for sensor in controller.sensors
for description in RAIN_SENSOR_BINARY_SENSOR
if "rain sensor" in sensor.model.name.lower()
)
entities.extend(
entities.extend(
HydrawiseBinarySensor(
coordinators.main,
description,
controller,
sensor_id=sensor.id,
)
for sensor in controller.sensors
for description in RAIN_SENSOR_BINARY_SENSOR
if "rain sensor" in sensor.model.name.lower()
)
async_add_entities(entities)
def _add_new_zones(zones: Iterable[tuple[Zone, Controller]]) -> None:
async_add_entities(
HydrawiseZoneBinarySensor(
coordinators.main, description, controller, zone_id=zone.id
)
for zone in controller.zones
for zone, controller in zones
for description in ZONE_BINARY_SENSORS
)
async_add_entities(entities)
_add_new_controllers(coordinators.main.data.controllers.values())
_add_new_zones(
[
(zone, coordinators.main.data.zone_id_to_controller[zone.id])
for zone in coordinators.main.data.zones.values()
]
)
coordinators.main.new_controllers_callbacks.append(_add_new_controllers)
coordinators.main.new_zones_callbacks.append(_add_new_zones)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(SERVICE_RESUME, None, "resume")
platform.async_register_entity_service(

View File

@ -13,6 +13,7 @@ DOMAIN = "hydrawise"
DEFAULT_WATERING_TIME = timedelta(minutes=15)
MANUFACTURER = "Hydrawise"
MODEL_ZONE = "Zone"
MAIN_SCAN_INTERVAL = timedelta(minutes=5)
WATER_USE_SCAN_INTERVAL = timedelta(minutes=60)

View File

@ -2,17 +2,26 @@
from __future__ import annotations
from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
from pydrawise import HydrawiseBase
from pydrawise.schema import Controller, ControllerWaterUseSummary, Sensor, User, Zone
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util.dt import now
from .const import DOMAIN, LOGGER, MAIN_SCAN_INTERVAL, WATER_USE_SCAN_INTERVAL
from .const import (
DOMAIN,
LOGGER,
MAIN_SCAN_INTERVAL,
MODEL_ZONE,
WATER_USE_SCAN_INTERVAL,
)
type HydrawiseConfigEntry = ConfigEntry[HydrawiseUpdateCoordinators]
@ -24,6 +33,7 @@ class HydrawiseData:
user: User
controllers: dict[int, Controller] = field(default_factory=dict)
zones: dict[int, Zone] = field(default_factory=dict)
zone_id_to_controller: dict[int, Controller] = field(default_factory=dict)
sensors: dict[int, Sensor] = field(default_factory=dict)
daily_water_summary: dict[int, ControllerWaterUseSummary] = field(
default_factory=dict
@ -68,6 +78,13 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
update_interval=MAIN_SCAN_INTERVAL,
)
self.api = api
self.new_controllers_callbacks: list[
Callable[[Iterable[Controller]], None]
] = []
self.new_zones_callbacks: list[
Callable[[Iterable[tuple[Zone, Controller]]], None]
] = []
self.async_add_listener(self._add_remove_zones)
async def _async_update_data(self) -> HydrawiseData:
"""Fetch the latest data from Hydrawise."""
@ -80,10 +97,81 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
controller.zones = await self.api.get_zones(controller)
for zone in controller.zones:
data.zones[zone.id] = zone
data.zone_id_to_controller[zone.id] = controller
for sensor in controller.sensors:
data.sensors[sensor.id] = sensor
return data
@callback
def _add_remove_zones(self) -> None:
"""Add newly discovered zones and remove nonexistent ones."""
if self.data is None:
# Likely a setup error; ignore.
# Despite what mypy thinks, this is still reachable. Without this check,
# the test_connect_retry test in test_init.py fails.
return # type: ignore[unreachable]
device_registry = dr.async_get(self.hass)
devices = dr.async_entries_for_config_entry(
device_registry, self.config_entry.entry_id
)
previous_zones: set[str] = set()
previous_zones_by_id: dict[str, DeviceEntry] = {}
previous_controllers: set[str] = set()
previous_controllers_by_id: dict[str, DeviceEntry] = {}
for device in devices:
for domain, identifier in device.identifiers:
if domain == DOMAIN:
if device.model == MODEL_ZONE:
previous_zones.add(identifier)
previous_zones_by_id[identifier] = device
else:
previous_controllers.add(identifier)
previous_controllers_by_id[identifier] = device
continue
current_zones = {str(zone_id) for zone_id in self.data.zones}
current_controllers = {
str(controller_id) for controller_id in self.data.controllers
}
if removed_zones := previous_zones - current_zones:
LOGGER.debug("Removed zones: %s", ", ".join(removed_zones))
for zone_id in removed_zones:
device_registry.async_update_device(
device_id=previous_zones_by_id[zone_id].id,
remove_config_entry_id=self.config_entry.entry_id,
)
if removed_controllers := previous_controllers - current_controllers:
LOGGER.debug("Removed controllers: %s", ", ".join(removed_controllers))
for controller_id in removed_controllers:
device_registry.async_update_device(
device_id=previous_controllers_by_id[controller_id].id,
remove_config_entry_id=self.config_entry.entry_id,
)
if new_controller_ids := current_controllers - previous_controllers:
LOGGER.debug("New controllers found: %s", ", ".join(new_controller_ids))
new_controllers = [
self.data.controllers[controller_id]
for controller_id in map(int, new_controller_ids)
]
for new_controller_callback in self.new_controllers_callbacks:
new_controller_callback(new_controllers)
if new_zone_ids := current_zones - previous_zones:
LOGGER.debug("New zones found: %s", ", ".join(new_zone_ids))
new_zones = [
(
self.data.zones[zone_id],
self.data.zone_id_to_controller[zone_id],
)
for zone_id in map(int, new_zone_ids)
]
for new_zone_callback in self.new_zones_callbacks:
new_zone_callback(new_zones)
class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
"""Data Update Coordinator for Hydrawise Water Use.

View File

@ -9,7 +9,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .const import DOMAIN, MANUFACTURER, MODEL_ZONE
from .coordinator import HydrawiseDataUpdateCoordinator
@ -40,7 +40,9 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]):
identifiers={(DOMAIN, self._device_id)},
name=self.zone.name if zone_id is not None else controller.name,
model=(
"Zone" if zone_id is not None else controller.hardware.model.description
MODEL_ZONE
if zone_id is not None
else controller.hardware.model.description
),
manufacturer=MANUFACTURER,
)

View File

@ -2,12 +2,12 @@
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from datetime import timedelta
from typing import Any
from pydrawise.schema import ControllerWaterUseSummary
from pydrawise.schema import Controller, ControllerWaterUseSummary, Zone
from homeassistant.components.sensor import (
SensorDeviceClass,
@ -31,7 +31,9 @@ class HydrawiseSensorEntityDescription(SensorEntityDescription):
def _get_water_use(sensor: HydrawiseSensor) -> ControllerWaterUseSummary:
return sensor.coordinator.data.daily_water_summary[sensor.controller.id]
return sensor.coordinator.data.daily_water_summary.get(
sensor.controller.id, ControllerWaterUseSummary()
)
WATER_USE_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
@ -133,44 +135,65 @@ async def async_setup_entry(
) -> None:
"""Set up the Hydrawise sensor platform."""
coordinators = config_entry.runtime_data
entities: list[HydrawiseSensor] = []
for controller in coordinators.main.data.controllers.values():
entities.extend(
HydrawiseSensor(coordinators.water_use, description, controller)
for description in WATER_USE_CONTROLLER_SENSORS
def _has_flow_sensor(controller: Controller) -> bool:
daily_water_use_summary = coordinators.water_use.data.daily_water_summary.get(
controller.id, ControllerWaterUseSummary()
)
entities.extend(
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 (
coordinators.water_use.data.daily_water_summary[controller.id].total_use
is not None
):
# we have a flow sensor for this controller
return daily_water_use_summary.total_use is not None
def _add_new_controllers(controllers: Iterable[Controller]) -> None:
entities: list[HydrawiseSensor] = []
for controller in controllers:
entities.extend(
HydrawiseSensor(coordinators.water_use, description, controller)
for description in FLOW_CONTROLLER_SENSORS
for description in WATER_USE_CONTROLLER_SENSORS
)
entities.extend(
if _has_flow_sensor(controller):
entities.extend(
HydrawiseSensor(coordinators.water_use, description, controller)
for description in FLOW_CONTROLLER_SENSORS
)
async_add_entities(entities)
def _add_new_zones(zones: Iterable[tuple[Zone, Controller]]) -> None:
async_add_entities(
[
HydrawiseSensor(
coordinators.water_use, description, controller, zone_id=zone.id
)
for zone, controller in zones
for description in WATER_USE_ZONE_SENSORS
]
+ [
HydrawiseSensor(
coordinators.main, description, controller, zone_id=zone.id
)
for zone, controller in zones
for description in ZONE_SENSORS
]
+ [
HydrawiseSensor(
coordinators.water_use,
description,
controller,
zone_id=zone.id,
)
for zone in controller.zones
for zone, controller in zones
for description in FLOW_ZONE_SENSORS
)
async_add_entities(entities)
if _has_flow_sensor(controller)
]
)
_add_new_controllers(coordinators.main.data.controllers.values())
_add_new_zones(
[
(zone, coordinators.main.data.zone_id_to_controller[zone.id])
for zone in coordinators.main.data.zones.values()
]
)
coordinators.main.new_controllers_callbacks.append(_add_new_controllers)
coordinators.main.new_zones_callbacks.append(_add_new_zones)
class HydrawiseSensor(HydrawiseEntity, SensorEntity):

View File

@ -2,12 +2,12 @@
from __future__ import annotations
from collections.abc import Callable, Coroutine
from collections.abc import Callable, Coroutine, Iterable
from dataclasses import dataclass
from datetime import timedelta
from typing import Any
from pydrawise import HydrawiseBase, Zone
from pydrawise import Controller, HydrawiseBase, Zone
from homeassistant.components.switch import (
SwitchDeviceClass,
@ -66,12 +66,21 @@ async def async_setup_entry(
) -> None:
"""Set up the Hydrawise switch platform."""
coordinators = config_entry.runtime_data
async_add_entities(
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
def _add_new_zones(zones: Iterable[tuple[Zone, Controller]]) -> None:
async_add_entities(
HydrawiseSwitch(coordinators.main, description, controller, zone_id=zone.id)
for zone, controller in zones
for description in SWITCH_TYPES
)
_add_new_zones(
[
(zone, coordinators.main.data.zone_id_to_controller[zone.id])
for zone in coordinators.main.data.zones.values()
]
)
coordinators.main.new_zones_callbacks.append(_add_new_zones)
class HydrawiseSwitch(HydrawiseEntity, SwitchEntity):

View File

@ -2,9 +2,10 @@
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from pydrawise.schema import Zone
from pydrawise.schema import Controller, Zone
from homeassistant.components.valve import (
ValveDeviceClass,
@ -33,12 +34,21 @@ async def async_setup_entry(
) -> None:
"""Set up the Hydrawise valve platform."""
coordinators = config_entry.runtime_data
async_add_entities(
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
def _add_new_zones(zones: Iterable[tuple[Zone, Controller]]) -> None:
async_add_entities(
HydrawiseValve(coordinators.main, description, controller, zone_id=zone.id)
for zone, controller in zones
for description in VALVE_TYPES
)
_add_new_zones(
[
(zone, coordinators.main.data.zone_id_to_controller[zone.id])
for zone in coordinators.main.data.zones.values()
]
)
coordinators.main.new_zones_callbacks.append(_add_new_zones)
class HydrawiseValve(HydrawiseEntity, ValveEntity):

View File

@ -1,13 +1,19 @@
"""Tests for the Hydrawise integration."""
from copy import deepcopy
from unittest.mock import AsyncMock
from aiohttp import ClientError
from freezegun.api import FrozenDateTimeFactory
from pydrawise.schema import Controller, User, Zone
from homeassistant.components.hydrawise.const import DOMAIN, MAIN_SCAN_INTERVAL
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceRegistry
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
async def test_connect_retry(
@ -32,3 +38,101 @@ async def test_update_version(
# Make sure reauth flow has been initiated
assert any(mock_config_entry_legacy.async_get_active_flows(hass, {"reauth"}))
async def test_auto_add_devices(
hass: HomeAssistant,
device_registry: DeviceRegistry,
mock_added_config_entry: MockConfigEntry,
mock_pydrawise: AsyncMock,
user: User,
controller: Controller,
zones: list[Zone],
freezer: FrozenDateTimeFactory,
) -> None:
"""Test new devices are auto-added to the device registry."""
device = device_registry.async_get_device(
identifiers={(DOMAIN, str(controller.id))}
)
assert device is not None
for zone in zones:
zone_device = device_registry.async_get_device(
identifiers={(DOMAIN, str(zone.id))}
)
assert zone_device is not None
all_devices = dr.async_entries_for_config_entry(
device_registry, mock_added_config_entry.entry_id
)
# 1 controller + 2 zones
assert len(all_devices) == 3
controller2 = deepcopy(controller)
controller2.id += 10
controller2.name += " 2"
controller2.sensors = []
zones2 = deepcopy(zones)
for zone in zones2:
zone.id += 10
zone.name += " 2"
user.controllers = [controller, controller2]
mock_pydrawise.get_zones.side_effect = [zones, zones2]
# Make the coordinator refresh data.
freezer.tick(MAIN_SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
new_controller_device = device_registry.async_get_device(
identifiers={(DOMAIN, str(controller2.id))}
)
assert new_controller_device is not None
for zone in zones2:
new_zone_device = device_registry.async_get_device(
identifiers={(DOMAIN, str(zone.id))}
)
assert new_zone_device is not None
all_devices = dr.async_entries_for_config_entry(
device_registry, mock_added_config_entry.entry_id
)
# 2 controllers + 4 zones
assert len(all_devices) == 6
async def test_auto_remove_devices(
hass: HomeAssistant,
device_registry: DeviceRegistry,
mock_added_config_entry: MockConfigEntry,
user: User,
controller: Controller,
zones: list[Zone],
freezer: FrozenDateTimeFactory,
) -> None:
"""Test old devices are auto-removed from the device registry."""
assert (
device_registry.async_get_device(identifiers={(DOMAIN, str(controller.id))})
is not None
)
for zone in zones:
device = device_registry.async_get_device(identifiers={(DOMAIN, str(zone.id))})
assert device is not None
user.controllers = []
# Make the coordinator refresh data.
freezer.tick(MAIN_SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert (
device_registry.async_get_device(identifiers={(DOMAIN, str(controller.id))})
is None
)
for zone in zones:
device = device_registry.async_get_device(identifiers={(DOMAIN, str(zone.id))})
assert device is None
all_devices = dr.async_entries_for_config_entry(
device_registry, mock_added_config_entry.entry_id
)
assert len(all_devices) == 0