Add flow detection to Rachio hose timer (#144075)

* flow binary sensor

* rename property

* Move const and update coordinator reference

* update controller descriptions

* Address review comments

* Use lookup for rain sensor

* Update online binary sensor

* make it a bit more readable

---------

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Brian Rogers 2025-05-14 15:01:01 +02:00 committed by GitHub
parent ef99658919
commit b0ff4b5841
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 146 additions and 94 deletions

View File

@ -1,28 +1,31 @@
"""Integration with the Rachio Iro sprinkler system controller."""
from abc import abstractmethod
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Any
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
DOMAIN,
KEY_BATTERY_STATUS,
DOMAIN as DOMAIN_RACHIO,
KEY_BATTERY,
KEY_DETECT_FLOW,
KEY_DEVICE_ID,
KEY_LOW,
KEY_FLOW,
KEY_ONLINE,
KEY_RAIN_SENSOR,
KEY_RAIN_SENSOR_TRIPPED,
KEY_REPLACE,
KEY_REPORTED_STATE,
KEY_STATE,
KEY_STATUS,
KEY_SUBTYPE,
SIGNAL_RACHIO_CONTROLLER_UPDATE,
@ -30,7 +33,7 @@ from .const import (
STATUS_ONLINE,
)
from .coordinator import RachioUpdateCoordinator
from .device import RachioPerson
from .device import RachioIro, RachioPerson
from .entity import RachioDevice, RachioHoseTimerEntity
from .webhooks import (
SUBTYPE_COLD_REBOOT,
@ -43,6 +46,67 @@ from .webhooks import (
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class RachioControllerBinarySensorDescription(BinarySensorEntityDescription):
"""Describe a Rachio controller binary sensor."""
update_received: Callable[[str], bool | None]
is_on: Callable[[RachioIro], bool]
signal_string: str
CONTROLLER_BINARY_SENSOR_TYPES: tuple[RachioControllerBinarySensorDescription, ...] = (
RachioControllerBinarySensorDescription(
key=KEY_ONLINE,
device_class=BinarySensorDeviceClass.CONNECTIVITY,
signal_string=SIGNAL_RACHIO_CONTROLLER_UPDATE,
is_on=lambda controller: controller.init_data[KEY_STATUS] == STATUS_ONLINE,
update_received={
SUBTYPE_ONLINE: True,
SUBTYPE_COLD_REBOOT: True,
SUBTYPE_OFFLINE: False,
}.get,
),
RachioControllerBinarySensorDescription(
key=KEY_RAIN_SENSOR,
translation_key="rain",
device_class=BinarySensorDeviceClass.MOISTURE,
signal_string=SIGNAL_RACHIO_RAIN_SENSOR_UPDATE,
is_on=lambda controller: controller.init_data[KEY_RAIN_SENSOR_TRIPPED],
update_received={
SUBTYPE_RAIN_SENSOR_DETECTION_ON: True,
SUBTYPE_RAIN_SENSOR_DETECTION_OFF: False,
}.get,
),
)
@dataclass(frozen=True, kw_only=True)
class RachioHoseTimerBinarySensorDescription(BinarySensorEntityDescription):
"""Describe a Rachio hose timer binary sensor."""
value_fn: Callable[[RachioHoseTimerEntity], bool]
exists_fn: Callable[[dict[str, Any]], bool] = lambda _: True
HOSE_TIMER_BINARY_SENSOR_TYPES: tuple[RachioHoseTimerBinarySensorDescription, ...] = (
RachioHoseTimerBinarySensorDescription(
key=KEY_BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
device_class=BinarySensorDeviceClass.BATTERY,
value_fn=lambda device: device.battery,
),
RachioHoseTimerBinarySensorDescription(
key=KEY_FLOW,
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
translation_key="flow",
value_fn=lambda device: device.no_flow_detected,
exists_fn=lambda valve: valve[KEY_DETECT_FLOW],
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
@ -55,23 +119,38 @@ async def async_setup_entry(
def _create_entities(hass: HomeAssistant, config_entry: ConfigEntry) -> list[Entity]:
entities: list[Entity] = []
person: RachioPerson = hass.data[DOMAIN][config_entry.entry_id]
for controller in person.controllers:
entities.append(RachioControllerOnlineBinarySensor(controller))
entities.append(RachioRainSensor(controller))
person: RachioPerson = hass.data[DOMAIN_RACHIO][config_entry.entry_id]
entities.extend(
RachioHoseTimerBattery(valve, base_station.status_coordinator)
RachioControllerBinarySensor(controller, description)
for controller in person.controllers
for description in CONTROLLER_BINARY_SENSOR_TYPES
)
entities.extend(
RachioHoseTimerBinarySensor(valve, base_station.status_coordinator, description)
for base_station in person.base_stations
for valve in base_station.status_coordinator.data.values()
for description in HOSE_TIMER_BINARY_SENSOR_TYPES
if description.exists_fn(valve)
)
return entities
class RachioControllerBinarySensor(RachioDevice, BinarySensorEntity):
"""Represent a binary sensor that reflects a Rachio state."""
"""Represent a binary sensor that reflects a Rachio controller state."""
entity_description: RachioControllerBinarySensorDescription
_attr_has_entity_name = True
def __init__(
self,
controller: RachioIro,
description: RachioControllerBinarySensorDescription,
) -> None:
"""Initialize a controller binary sensor."""
super().__init__(controller)
self.entity_description = description
self._attr_unique_id = f"{controller.controller_id}-{description.key}"
@callback
def _async_handle_any_update(self, *args, **kwargs) -> None:
"""Determine whether an update event applies to this device."""
@ -82,97 +161,49 @@ class RachioControllerBinarySensor(RachioDevice, BinarySensorEntity):
# For this device
self._async_handle_update(args, kwargs)
@abstractmethod
def _async_handle_update(self, *args, **kwargs) -> None:
"""Handle an update to the state of this sensor."""
class RachioControllerOnlineBinarySensor(RachioControllerBinarySensor):
"""Represent a binary sensor that reflects if the controller is online."""
_attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
@property
def unique_id(self) -> str:
"""Return a unique id for this entity."""
return f"{self._controller.controller_id}-online"
@callback
def _async_handle_update(self, *args, **kwargs) -> None:
"""Handle an update to the state of this sensor."""
if args[0][0][KEY_SUBTYPE] in (SUBTYPE_ONLINE, SUBTYPE_COLD_REBOOT):
self._attr_is_on = True
elif args[0][0][KEY_SUBTYPE] == SUBTYPE_OFFLINE:
self._attr_is_on = False
if (
updated_state := self.entity_description.update_received(
args[0][0][KEY_SUBTYPE]
)
) is not None:
self._attr_is_on = updated_state
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Subscribe to updates."""
self._attr_is_on = self._controller.init_data[KEY_STATUS] == STATUS_ONLINE
self._attr_is_on = self.entity_description.is_on(self._controller)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
SIGNAL_RACHIO_CONTROLLER_UPDATE,
self.entity_description.signal_string,
self._async_handle_any_update,
)
)
class RachioRainSensor(RachioControllerBinarySensor):
"""Represent a binary sensor that reflects the status of the rain sensor."""
class RachioHoseTimerBinarySensor(RachioHoseTimerEntity, BinarySensorEntity):
"""Represents a binary sensor for a smart hose timer."""
_attr_device_class = BinarySensorDeviceClass.MOISTURE
_attr_translation_key = "rain"
@property
def unique_id(self) -> str:
"""Return a unique id for this entity."""
return f"{self._controller.controller_id}-rain_sensor"
@callback
def _async_handle_update(self, *args, **kwargs) -> None:
"""Handle an update to the state of this sensor."""
if args[0][0][KEY_SUBTYPE] == SUBTYPE_RAIN_SENSOR_DETECTION_ON:
self._attr_is_on = True
elif args[0][0][KEY_SUBTYPE] == SUBTYPE_RAIN_SENSOR_DETECTION_OFF:
self._attr_is_on = False
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Subscribe to updates."""
self._attr_is_on = self._controller.init_data[KEY_RAIN_SENSOR_TRIPPED]
self.async_on_remove(
async_dispatcher_connect(
self.hass,
SIGNAL_RACHIO_RAIN_SENSOR_UPDATE,
self._async_handle_any_update,
)
)
class RachioHoseTimerBattery(RachioHoseTimerEntity, BinarySensorEntity):
"""Represents a battery sensor for a smart hose timer."""
_attr_device_class = BinarySensorDeviceClass.BATTERY
entity_description: RachioHoseTimerBinarySensorDescription
def __init__(
self, data: dict[str, Any], coordinator: RachioUpdateCoordinator
self,
data: dict[str, Any],
coordinator: RachioUpdateCoordinator,
description: RachioHoseTimerBinarySensorDescription,
) -> None:
"""Initialize a smart hose timer battery sensor."""
"""Initialize a smart hose timer binary sensor."""
super().__init__(data, coordinator)
self._attr_unique_id = f"{self.id}-battery"
self.entity_description = description
self._attr_unique_id = f"{self.id}-{description.key}"
self._update_attr()
@callback
def _update_attr(self) -> None:
"""Handle updated coordinator data."""
data = self.coordinator.data[self.id]
self._static_attrs = data[KEY_STATE][KEY_REPORTED_STATE]
self._attr_is_on = self._static_attrs[KEY_BATTERY_STATUS] in [
KEY_LOW,
KEY_REPLACE,
]
self._attr_is_on = self.entity_description.value_fn(self)

View File

@ -25,10 +25,12 @@ KEY_ID = "id"
KEY_NAME = "name"
KEY_MODEL = "model"
KEY_ON = "on"
KEY_ONLINE = "online"
KEY_DURATION = "totalDuration"
KEY_DURATION_MINUTES = "duration"
KEY_RAIN_DELAY = "rainDelayExpirationDate"
KEY_RAIN_DELAY_END = "endTime"
KEY_RAIN_SENSOR = "rain_sensor"
KEY_RAIN_SENSOR_TRIPPED = "rainSensorTripped"
KEY_STATUS = "status"
KEY_SUBTYPE = "subType"
@ -57,6 +59,8 @@ KEY_STATE = "state"
KEY_CONNECTED = "connected"
KEY_CURRENT_STATUS = "lastWateringAction"
KEY_DETECT_FLOW = "detectFlow"
KEY_BATTERY = "battery"
KEY_FLOW = "flow"
KEY_BATTERY_STATUS = "batteryStatus"
KEY_LOW = "LOW"
KEY_REPLACE = "REPLACE"

View File

@ -12,9 +12,14 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
DEFAULT_NAME,
DOMAIN,
KEY_BATTERY_STATUS,
KEY_CONNECTED,
KEY_CURRENT_STATUS,
KEY_FLOW_DETECTED,
KEY_ID,
KEY_LOW,
KEY_NAME,
KEY_REPLACE,
KEY_REPORTED_STATE,
KEY_STATE,
)
@ -70,17 +75,29 @@ class RachioHoseTimerEntity(CoordinatorEntity[RachioUpdateCoordinator]):
manufacturer=DEFAULT_NAME,
configuration_url="https://app.rach.io",
)
self._update_attr()
@property
def reported_state(self) -> dict[str, Any]:
"""Return the reported state."""
return self.coordinator.data[self.id][KEY_STATE][KEY_REPORTED_STATE]
@property
def available(self) -> bool:
"""Return if the entity is available."""
return (
super().available
and self.coordinator.data[self.id][KEY_STATE][KEY_REPORTED_STATE][
KEY_CONNECTED
]
)
return super().available and self.reported_state[KEY_CONNECTED]
@property
def battery(self) -> bool:
"""Return the battery status."""
return self.reported_state[KEY_BATTERY_STATUS] in [KEY_LOW, KEY_REPLACE]
@property
def no_flow_detected(self) -> bool:
"""Return true if valve is on and flow is not detected."""
if status := self.reported_state.get(KEY_CURRENT_STATUS):
# Since this is a problem indicator we need the opposite of the API state
return not status.get(KEY_FLOW_DETECTED, True)
return False
@abstractmethod
def _update_attr(self) -> None:

View File

@ -31,6 +31,9 @@
"binary_sensor": {
"rain": {
"name": "Rain"
},
"flow": {
"name": "Flow"
}
},
"calendar": {

View File

@ -37,9 +37,7 @@ from .const import (
KEY_ON,
KEY_RAIN_DELAY,
KEY_RAIN_DELAY_END,
KEY_REPORTED_STATE,
KEY_SCHEDULE_ID,
KEY_STATE,
KEY_SUBTYPE,
KEY_SUMMARY,
KEY_TYPE,
@ -548,6 +546,7 @@ class RachioValve(RachioHoseTimerEntity, SwitchEntity):
self._person = person
self._base = base
self._attr_unique_id = f"{self.id}-valve"
self._update_attr()
def turn_on(self, **kwargs: Any) -> None:
"""Turn on this valve."""
@ -575,7 +574,5 @@ class RachioValve(RachioHoseTimerEntity, SwitchEntity):
@callback
def _update_attr(self) -> None:
"""Handle updated coordinator data."""
data = self.coordinator.data[self.id]
self._static_attrs = data[KEY_STATE][KEY_REPORTED_STATE]
self._static_attrs = self.reported_state
self._attr_is_on = KEY_CURRENT_STATUS in self._static_attrs