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

View File

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

View File

@ -12,9 +12,14 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ( from .const import (
DEFAULT_NAME, DEFAULT_NAME,
DOMAIN, DOMAIN,
KEY_BATTERY_STATUS,
KEY_CONNECTED, KEY_CONNECTED,
KEY_CURRENT_STATUS,
KEY_FLOW_DETECTED,
KEY_ID, KEY_ID,
KEY_LOW,
KEY_NAME, KEY_NAME,
KEY_REPLACE,
KEY_REPORTED_STATE, KEY_REPORTED_STATE,
KEY_STATE, KEY_STATE,
) )
@ -70,17 +75,29 @@ class RachioHoseTimerEntity(CoordinatorEntity[RachioUpdateCoordinator]):
manufacturer=DEFAULT_NAME, manufacturer=DEFAULT_NAME,
configuration_url="https://app.rach.io", 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 @property
def available(self) -> bool: def available(self) -> bool:
"""Return if the entity is available.""" """Return if the entity is available."""
return ( return super().available and self.reported_state[KEY_CONNECTED]
super().available
and self.coordinator.data[self.id][KEY_STATE][KEY_REPORTED_STATE][ @property
KEY_CONNECTED 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 @abstractmethod
def _update_attr(self) -> None: def _update_attr(self) -> None:

View File

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

View File

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