Add support for Elexa Guardian paired sensors (#37930)

* Add support for Guardian paired sensors

* More work

* OOP

* More OOP

* Binary sensors looking good

* Entities all in place

* Looking good

* Linting

* Code review

* Code review

* Flake

* Fix removal

* Code review

* Linting

* Don't use potentially confusing method name

* Use CoordinatorEntity

* Linting

* Pylint

* Code review

* Code review
This commit is contained in:
Aaron Bach 2020-10-12 21:41:57 -06:00 committed by GitHub
parent f7d3f3a1ed
commit bb98f7ed1b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 590 additions and 136 deletions

View file

@ -1,6 +1,5 @@
"""The Elexa Guardian integration.""" """The Elexa Guardian integration."""
import asyncio import asyncio
from datetime import timedelta
from typing import Dict from typing import Dict
from aioguardian import Client from aioguardian import Client
@ -8,10 +7,15 @@ from aioguardian import Client
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ATTRIBUTION, CONF_IP_ADDRESS, CONF_PORT from homeassistant.const import ATTR_ATTRIBUTION, CONF_IP_ADDRESS, CONF_PORT
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import Entity from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from .const import ( from .const import (
API_SENSOR_PAIR_DUMP,
API_SENSOR_PAIRED_SENSOR_STATUS,
API_SYSTEM_DIAGNOSTICS, API_SYSTEM_DIAGNOSTICS,
API_SYSTEM_ONBOARD_SENSOR_STATUS, API_SYSTEM_ONBOARD_SENSOR_STATUS,
API_VALVE_STATUS, API_VALVE_STATUS,
@ -19,18 +23,28 @@ from .const import (
CONF_UID, CONF_UID,
DATA_CLIENT, DATA_CLIENT,
DATA_COORDINATOR, DATA_COORDINATOR,
DATA_PAIRED_SENSOR_MANAGER,
DATA_UNSUB_DISPATCHER_CONNECT,
DOMAIN, DOMAIN,
LOGGER,
SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED,
) )
from .util import GuardianDataUpdateCoordinator from .util import GuardianDataUpdateCoordinator
DEFAULT_UPDATE_INTERVAL = timedelta(seconds=30) DATA_LAST_SENSOR_PAIR_DUMP = "last_sensor_pair_dump"
PLATFORMS = ["binary_sensor", "sensor", "switch"] PLATFORMS = ["binary_sensor", "sensor", "switch"]
async def async_setup(hass: HomeAssistant, config: dict) -> bool: async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Set up the Elexa Guardian component.""" """Set up the Elexa Guardian component."""
hass.data[DOMAIN] = {DATA_CLIENT: {}, DATA_COORDINATOR: {}} hass.data[DOMAIN] = {
DATA_CLIENT: {},
DATA_COORDINATOR: {},
DATA_LAST_SENSOR_PAIR_DUMP: {},
DATA_PAIRED_SENSOR_MANAGER: {},
DATA_UNSUB_DISPATCHER_CONNECT: {},
}
return True return True
@ -39,20 +53,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
client = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] = Client( client = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] = Client(
entry.data[CONF_IP_ADDRESS], port=entry.data[CONF_PORT] entry.data[CONF_IP_ADDRESS], port=entry.data[CONF_PORT]
) )
hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] = {} hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] = {
API_SENSOR_PAIRED_SENSOR_STATUS: {}
}
hass.data[DOMAIN][DATA_UNSUB_DISPATCHER_CONNECT][entry.entry_id] = []
# The valve controller's UDP-based API can't handle concurrent requests very well, # The valve controller's UDP-based API can't handle concurrent requests very well,
# so we use a lock to ensure that only one API request is reaching it at a time: # so we use a lock to ensure that only one API request is reaching it at a time:
api_lock = asyncio.Lock() api_lock = asyncio.Lock()
initial_fetch_tasks = []
# Set up DataUpdateCoordinators for the valve controller:
init_valve_controller_tasks = []
for api, api_coro in [ for api, api_coro in [
(API_SENSOR_PAIR_DUMP, client.sensor.pair_dump),
(API_SYSTEM_DIAGNOSTICS, client.system.diagnostics), (API_SYSTEM_DIAGNOSTICS, client.system.diagnostics),
(API_SYSTEM_ONBOARD_SENSOR_STATUS, client.system.onboard_sensor_status), (API_SYSTEM_ONBOARD_SENSOR_STATUS, client.system.onboard_sensor_status),
(API_VALVE_STATUS, client.valve.status), (API_VALVE_STATUS, client.valve.status),
(API_WIFI_STATUS, client.wifi.status), (API_WIFI_STATUS, client.wifi.status),
]: ]:
hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][ coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][
api api
] = GuardianDataUpdateCoordinator( ] = GuardianDataUpdateCoordinator(
hass, hass,
@ -62,12 +81,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
api_lock=api_lock, api_lock=api_lock,
valve_controller_uid=entry.data[CONF_UID], valve_controller_uid=entry.data[CONF_UID],
) )
initial_fetch_tasks.append( init_valve_controller_tasks.append(coordinator.async_refresh())
hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][api].async_refresh()
await asyncio.gather(*init_valve_controller_tasks)
# Set up an object to evaluate each batch of paired sensor UIDs and add/remove
# devices as appropriate:
paired_sensor_manager = hass.data[DOMAIN][DATA_PAIRED_SENSOR_MANAGER][
entry.entry_id
] = PairedSensorManager(hass, entry, client, api_lock)
await paired_sensor_manager.async_process_latest_paired_sensor_uids()
@callback
def async_process_paired_sensor_uids():
"""Define a callback for when new paired sensor data is received."""
hass.async_create_task(
paired_sensor_manager.async_process_latest_paired_sensor_uids()
) )
await asyncio.gather(*initial_fetch_tasks) hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][
API_SENSOR_PAIR_DUMP
].async_add_listener(async_process_paired_sensor_uids)
# Set up all of the Guardian entity platforms:
for component in PLATFORMS: for component in PLATFORMS:
hass.async_create_task( hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component) hass.config_entries.async_forward_entry_setup(entry, component)
@ -89,33 +125,115 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_ok: if unload_ok:
hass.data[DOMAIN][DATA_CLIENT].pop(entry.entry_id) hass.data[DOMAIN][DATA_CLIENT].pop(entry.entry_id)
hass.data[DOMAIN][DATA_COORDINATOR].pop(entry.entry_id) hass.data[DOMAIN][DATA_COORDINATOR].pop(entry.entry_id)
hass.data[DOMAIN][DATA_LAST_SENSOR_PAIR_DUMP].pop(entry.entry_id)
for unsub in hass.data[DOMAIN][DATA_UNSUB_DISPATCHER_CONNECT][entry.entry_id]:
unsub()
hass.data[DOMAIN][DATA_UNSUB_DISPATCHER_CONNECT].pop(entry.entry_id)
return unload_ok return unload_ok
class GuardianEntity(Entity): class PairedSensorManager:
"""Define a base Guardian entity.""" """Define an object that manages the addition/removal of paired sensors."""
def __init__( def __init__(
self, self,
hass: HomeAssistant,
entry: ConfigEntry, entry: ConfigEntry,
client: Client, client: Client,
coordinators: Dict[str, DataUpdateCoordinator], api_lock: asyncio.Lock,
kind: str, ) -> None:
name: str, """Initialize."""
device_class: str, self._api_lock = api_lock
icon: str, self._client = client
self._entry = entry
self._hass = hass
self._listeners = []
self._paired_uids = set()
async def async_pair_sensor(self, uid: str) -> None:
"""Add a new paired sensor coordinator."""
LOGGER.info("Adding paired sensor: %s", uid)
self._paired_uids.add(uid)
coordinator = self._hass.data[DOMAIN][DATA_COORDINATOR][self._entry.entry_id][
API_SENSOR_PAIRED_SENSOR_STATUS
][uid] = GuardianDataUpdateCoordinator(
self._hass,
client=self._client,
api_name=f"{API_SENSOR_PAIRED_SENSOR_STATUS}_{uid}",
api_coro=lambda: self._client.sensor.paired_sensor_status(uid),
api_lock=self._api_lock,
valve_controller_uid=self._entry.data[CONF_UID],
)
await coordinator.async_request_refresh()
async_dispatcher_send(
self._hass,
SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED.format(self._entry.data[CONF_UID]),
uid,
)
async def async_process_latest_paired_sensor_uids(self) -> None:
"""Process a list of new UIDs."""
try:
uids = set(
self._hass.data[DOMAIN][DATA_COORDINATOR][self._entry.entry_id][
API_SENSOR_PAIR_DUMP
].data["paired_uids"]
)
except KeyError:
# Sometimes the paired_uids key can fail to exist; the user can't do anything
# about it, so in this case, we quietly abort and return:
return
if uids == self._paired_uids:
return
old = self._paired_uids
new = self._paired_uids = set(uids)
tasks = [self.async_pair_sensor(uid) for uid in new.difference(old)]
tasks += [self.async_unpair_sensor(uid) for uid in old.difference(new)]
if tasks:
await asyncio.gather(*tasks)
async def async_unpair_sensor(self, uid: str) -> None:
"""Remove a paired sensor coordinator."""
LOGGER.info("Removing paired sensor: %s", uid)
# Clear out objects related to this paired sensor:
self._paired_uids.remove(uid)
self._hass.data[DOMAIN][DATA_COORDINATOR][self._entry.entry_id][
API_SENSOR_PAIRED_SENSOR_STATUS
].pop(uid)
# Remove the paired sensor device from the device registry (which will
# clean up entities and the entity registry):
dev_reg = await self._hass.helpers.device_registry.async_get_registry()
device = dev_reg.async_get_or_create(
config_entry_id=self._entry.entry_id, identifiers={(DOMAIN, uid)}
)
dev_reg.async_remove_device(device.id)
class GuardianEntity(CoordinatorEntity):
"""Define a base Guardian entity."""
def __init__( # pylint: disable=super-init-not-called
self, entry: ConfigEntry, kind: str, name: str, device_class: str, icon: str
) -> None: ) -> None:
"""Initialize.""" """Initialize."""
self._attrs = {ATTR_ATTRIBUTION: "Data provided by Elexa"} self._attrs = {ATTR_ATTRIBUTION: "Data provided by Elexa"}
self._available = True self._available = True
self._client = client self._entry = entry
self._coordinators = coordinators
self._device_class = device_class self._device_class = device_class
self._device_info = {"manufacturer": "Elexa"}
self._icon = icon self._icon = icon
self._kind = kind self._kind = kind
self._name = name self._name = name
self._valve_controller_uid = entry.data[CONF_UID]
@property @property
def device_class(self) -> str: def device_class(self) -> str:
@ -125,12 +243,7 @@ class GuardianEntity(Entity):
@property @property
def device_info(self) -> dict: def device_info(self) -> dict:
"""Return device registry information for this entity.""" """Return device registry information for this entity."""
return { return self._device_info
"identifiers": {(DOMAIN, self._valve_controller_uid)},
"manufacturer": "Elexa",
"model": self._coordinators[API_SYSTEM_DIAGNOSTICS].data["firmware"],
"name": f"Guardian {self._valve_controller_uid}",
}
@property @property
def device_state_attributes(self) -> dict: def device_state_attributes(self) -> dict:
@ -142,28 +255,6 @@ class GuardianEntity(Entity):
"""Return the icon.""" """Return the icon."""
return self._icon return self._icon
@property
def name(self) -> str:
"""Return the name of the entity."""
return f"Guardian {self._valve_controller_uid}: {self._name}"
@property
def should_poll(self) -> bool:
"""Return True if entity has to be polled for state."""
return False
@property
def unique_id(self):
"""Return the unique ID of the entity."""
return f"{self._valve_controller_uid}_{self._kind}"
async def _async_internal_added_to_hass(self):
"""Perform additional, internal tasks when the entity is about to be added.
This should be extended by Guardian platforms.
"""
raise NotImplementedError
@callback @callback
def _async_update_from_latest_data(self): def _async_update_from_latest_data(self):
"""Update the entity. """Update the entity.
@ -173,19 +264,122 @@ class GuardianEntity(Entity):
raise NotImplementedError raise NotImplementedError
@callback @callback
def async_add_coordinator_update_listener(self, api: str) -> None: def _async_update_state_callback(self):
"""Add a listener to a DataUpdateCoordinator based on the API referenced.""" """Update the entity's state."""
self._async_update_from_latest_data()
self.async_write_ha_state()
@callback
def async_update():
"""Update the entity's state."""
self._async_update_from_latest_data()
self.async_write_ha_state()
self.async_on_remove(self._coordinators[api].async_add_listener(async_update)) class PairedSensorEntity(GuardianEntity):
"""Define a Guardian paired sensor entity."""
def __init__(
self,
entry: ConfigEntry,
coordinator: DataUpdateCoordinator,
kind: str,
name: str,
device_class: str,
icon: str,
) -> None:
"""Initialize."""
super().__init__(entry, kind, name, device_class, icon)
self.coordinator = coordinator
self._paired_sensor_uid = coordinator.data["uid"]
self._device_info["identifiers"] = {(DOMAIN, self._paired_sensor_uid)}
self._device_info["name"] = f"Guardian Paired Sensor {self._paired_sensor_uid}"
self._device_info["via_device"] = (DOMAIN, self._entry.data[CONF_UID])
@property
def name(self) -> str:
"""Return the name of the entity."""
return f"Guardian Paired Sensor {self._paired_sensor_uid}: {self._name}"
@property
def unique_id(self):
"""Return the unique ID of the entity."""
return f"{self._paired_sensor_uid}_{self._kind}"
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Perform tasks when the entity is added.""" """Perform tasks when the entity is added."""
await self._async_internal_added_to_hass() await super().async_added_to_hass()
self._async_update_from_latest_data()
class ValveControllerEntity(GuardianEntity):
"""Define a Guardian valve controller entity."""
def __init__(
self,
entry: ConfigEntry,
coordinators: Dict[str, DataUpdateCoordinator],
kind: str,
name: str,
device_class: str,
icon: str,
) -> None:
"""Initialize."""
super().__init__(entry, kind, name, device_class, icon)
self.coordinators = coordinators
self._device_info["identifiers"] = {(DOMAIN, self._entry.data[CONF_UID])}
self._device_info[
"name"
] = f"Guardian Valve Controller {self._entry.data[CONF_UID]}"
self._device_info["model"] = self.coordinators[API_SYSTEM_DIAGNOSTICS].data[
"firmware"
]
@property
def availabile(self) -> bool:
"""Return if entity is available."""
return any(coordinator.last_update_success for coordinator in self.coordinators)
@property
def name(self) -> str:
"""Return the name of the entity."""
return f"Guardian {self._entry.data[CONF_UID]}: {self._name}"
@property
def unique_id(self):
"""Return the unique ID of the entity."""
return f"{self._entry.data[CONF_UID]}_{self._kind}"
async def _async_continue_entity_setup(self):
"""Perform additional, internal tasks when the entity is about to be added.
This should be extended by Guardian platforms.
"""
raise NotImplementedError
@callback
def async_add_coordinator_update_listener(self, api: str) -> None:
"""Add a listener to a DataUpdateCoordinator based on the API referenced."""
self.async_on_remove(
self.coordinators[api].async_add_listener(self._async_update_state_callback)
)
async def async_added_to_hass(self) -> None:
"""Perform tasks when the entity is added."""
await self._async_continue_entity_setup()
self.async_add_coordinator_update_listener(API_SYSTEM_DIAGNOSTICS) self.async_add_coordinator_update_listener(API_SYSTEM_DIAGNOSTICS)
self._async_update_from_latest_data() self._async_update_from_latest_data()
async def async_update(self) -> None:
"""Update the entity.
Only used by the generic entity update service.
"""
# Ignore manual update requests if the entity is disabled
if not self.enabled:
return
refresh_tasks = [
coordinator.async_request_refresh() for coordinator in self.coordinators
]
await asyncio.gather(*refresh_tasks)

View file

@ -1,70 +1,167 @@
"""Binary sensors for the Elexa Guardian integration.""" """Binary sensors for the Elexa Guardian integration."""
from typing import Callable, Dict from typing import Callable, Dict, Optional
from aioguardian import Client
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
DEVICE_CLASS_CONNECTIVITY, DEVICE_CLASS_CONNECTIVITY,
DEVICE_CLASS_MOISTURE, DEVICE_CLASS_MOISTURE,
DEVICE_CLASS_MOVING,
BinarySensorEntity, BinarySensorEntity,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from . import GuardianEntity from . import PairedSensorEntity, ValveControllerEntity
from .const import ( from .const import (
API_SENSOR_PAIRED_SENSOR_STATUS,
API_SYSTEM_ONBOARD_SENSOR_STATUS, API_SYSTEM_ONBOARD_SENSOR_STATUS,
API_WIFI_STATUS, API_WIFI_STATUS,
DATA_CLIENT, CONF_UID,
DATA_COORDINATOR, DATA_COORDINATOR,
DATA_UNSUB_DISPATCHER_CONNECT,
DOMAIN, DOMAIN,
SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED,
) )
ATTR_CONNECTED_CLIENTS = "connected_clients" ATTR_CONNECTED_CLIENTS = "connected_clients"
SENSOR_KIND_AP_INFO = "ap_enabled" SENSOR_KIND_AP_INFO = "ap_enabled"
SENSOR_KIND_LEAK_DETECTED = "leak_detected" SENSOR_KIND_LEAK_DETECTED = "leak_detected"
SENSORS = [ SENSOR_KIND_MOVED = "moved"
(SENSOR_KIND_AP_INFO, "Onboard AP Enabled", DEVICE_CLASS_CONNECTIVITY),
(SENSOR_KIND_LEAK_DETECTED, "Leak Detected", DEVICE_CLASS_MOISTURE), SENSOR_ATTRS_MAP = {
] SENSOR_KIND_AP_INFO: ("Onboard AP Enabled", DEVICE_CLASS_CONNECTIVITY),
SENSOR_KIND_LEAK_DETECTED: ("Leak Detected", DEVICE_CLASS_MOISTURE),
SENSOR_KIND_MOVED: ("Recently Moved", DEVICE_CLASS_MOVING),
}
PAIRED_SENSOR_SENSORS = [SENSOR_KIND_LEAK_DETECTED, SENSOR_KIND_MOVED]
VALVE_CONTROLLER_SENSORS = [SENSOR_KIND_AP_INFO, SENSOR_KIND_LEAK_DETECTED]
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable
) -> None: ) -> None:
"""Set up Guardian switches based on a config entry.""" """Set up Guardian switches based on a config entry."""
async_add_entities(
[ async def add_new_paired_sensor(uid: str) -> None:
GuardianBinarySensor( """Add a new paired sensor."""
coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][
API_SENSOR_PAIRED_SENSOR_STATUS
][uid]
entities = []
for kind in PAIRED_SENSOR_SENSORS:
name, device_class = SENSOR_ATTRS_MAP[kind]
entities.append(
PairedSensorBinarySensor(
entry,
coordinator,
kind,
name,
device_class,
None,
)
)
async_add_entities(entities)
# Handle adding paired sensors after HASS startup:
hass.data[DOMAIN][DATA_UNSUB_DISPATCHER_CONNECT][entry.entry_id].append(
async_dispatcher_connect(
hass,
SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED.format(entry.data[CONF_UID]),
add_new_paired_sensor,
)
)
sensors = []
# Add all valve controller-specific binary sensors:
for kind in VALVE_CONTROLLER_SENSORS:
name, device_class = SENSOR_ATTRS_MAP[kind]
sensors.append(
ValveControllerBinarySensor(
entry, entry,
hass.data[DOMAIN][DATA_CLIENT][entry.entry_id],
hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id], hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id],
kind, kind,
name, name,
device_class, device_class,
None,
) )
for kind, name, device_class in SENSORS )
],
True, # Add all paired sensor-specific binary sensors:
) for coordinator in hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][
API_SENSOR_PAIRED_SENSOR_STATUS
].values():
for kind in PAIRED_SENSOR_SENSORS:
name, device_class = SENSOR_ATTRS_MAP[kind]
sensors.append(
PairedSensorBinarySensor(
entry,
coordinator,
kind,
name,
device_class,
None,
)
)
async_add_entities(sensors)
class GuardianBinarySensor(GuardianEntity, BinarySensorEntity): class PairedSensorBinarySensor(PairedSensorEntity, BinarySensorEntity):
"""Define a generic Guardian sensor.""" """Define a binary sensor related to a Guardian valve controller."""
def __init__(
self,
entry: ConfigEntry,
coordinator: DataUpdateCoordinator,
kind: str,
name: str,
device_class: Optional[str],
icon: Optional[str],
) -> None:
"""Initialize."""
super().__init__(entry, coordinator, kind, name, device_class, icon)
self._is_on = True
@property
def available(self) -> bool:
"""Return whether the entity is available."""
return self.coordinator.last_update_success
@property
def is_on(self) -> bool:
"""Return True if the binary sensor is on."""
return self._is_on
@callback
def _async_update_from_latest_data(self) -> None:
"""Update the entity."""
if self._kind == SENSOR_KIND_LEAK_DETECTED:
self._is_on = self.coordinator.data["wet"]
elif self._kind == SENSOR_KIND_MOVED:
self._is_on = self.coordinator.data["moved"]
class ValveControllerBinarySensor(ValveControllerEntity, BinarySensorEntity):
"""Define a binary sensor related to a Guardian valve controller."""
def __init__( def __init__(
self, self,
entry: ConfigEntry, entry: ConfigEntry,
client: Client,
coordinators: Dict[str, DataUpdateCoordinator], coordinators: Dict[str, DataUpdateCoordinator],
kind: str, kind: str,
name: str, name: str,
device_class: str, device_class: Optional[str],
icon: Optional[str],
) -> None: ) -> None:
"""Initialize.""" """Initialize."""
super().__init__(entry, client, coordinators, kind, name, device_class, None) super().__init__(entry, coordinators, kind, name, device_class, icon)
self._is_on = True self._is_on = True
@ -72,9 +169,9 @@ class GuardianBinarySensor(GuardianEntity, BinarySensorEntity):
def available(self) -> bool: def available(self) -> bool:
"""Return whether the entity is available.""" """Return whether the entity is available."""
if self._kind == SENSOR_KIND_AP_INFO: if self._kind == SENSOR_KIND_AP_INFO:
return self._coordinators[API_WIFI_STATUS].last_update_success return self.coordinators[API_WIFI_STATUS].last_update_success
if self._kind == SENSOR_KIND_LEAK_DETECTED: if self._kind == SENSOR_KIND_LEAK_DETECTED:
return self._coordinators[ return self.coordinators[
API_SYSTEM_ONBOARD_SENSOR_STATUS API_SYSTEM_ONBOARD_SENSOR_STATUS
].last_update_success ].last_update_success
return False return False
@ -84,7 +181,8 @@ class GuardianBinarySensor(GuardianEntity, BinarySensorEntity):
"""Return True if the binary sensor is on.""" """Return True if the binary sensor is on."""
return self._is_on return self._is_on
async def _async_internal_added_to_hass(self) -> None: async def _async_continue_entity_setup(self) -> None:
"""Add an API listener."""
if self._kind == SENSOR_KIND_AP_INFO: if self._kind == SENSOR_KIND_AP_INFO:
self.async_add_coordinator_update_listener(API_WIFI_STATUS) self.async_add_coordinator_update_listener(API_WIFI_STATUS)
elif self._kind == SENSOR_KIND_LEAK_DETECTED: elif self._kind == SENSOR_KIND_LEAK_DETECTED:
@ -94,15 +192,15 @@ class GuardianBinarySensor(GuardianEntity, BinarySensorEntity):
def _async_update_from_latest_data(self) -> None: def _async_update_from_latest_data(self) -> None:
"""Update the entity.""" """Update the entity."""
if self._kind == SENSOR_KIND_AP_INFO: if self._kind == SENSOR_KIND_AP_INFO:
self._is_on = self._coordinators[API_WIFI_STATUS].data["station_connected"] self._is_on = self.coordinators[API_WIFI_STATUS].data["station_connected"]
self._attrs.update( self._attrs.update(
{ {
ATTR_CONNECTED_CLIENTS: self._coordinators[API_WIFI_STATUS].data[ ATTR_CONNECTED_CLIENTS: self.coordinators[API_WIFI_STATUS].data.get(
"ap_clients" "ap_clients"
] )
} }
) )
elif self._kind == SENSOR_KIND_LEAK_DETECTED: elif self._kind == SENSOR_KIND_LEAK_DETECTED:
self._is_on = self._coordinators[API_SYSTEM_ONBOARD_SENSOR_STATUS].data[ self._is_on = self.coordinators[API_SYSTEM_ONBOARD_SENSOR_STATUS].data[
"wet" "wet"
] ]

View file

@ -5,6 +5,8 @@ DOMAIN = "guardian"
LOGGER = logging.getLogger(__package__) LOGGER = logging.getLogger(__package__)
API_SENSOR_PAIRED_SENSOR_STATUS = "sensor_paired_sensor_status"
API_SENSOR_PAIR_DUMP = "sensor_pair_dump"
API_SYSTEM_DIAGNOSTICS = "system_diagnostics" API_SYSTEM_DIAGNOSTICS = "system_diagnostics"
API_SYSTEM_ONBOARD_SENSOR_STATUS = "system_onboard_sensor_status" API_SYSTEM_ONBOARD_SENSOR_STATUS = "system_onboard_sensor_status"
API_VALVE_STATUS = "valve_status" API_VALVE_STATUS = "valve_status"
@ -14,3 +16,7 @@ CONF_UID = "uid"
DATA_CLIENT = "client" DATA_CLIENT = "client"
DATA_COORDINATOR = "coordinator" DATA_COORDINATOR = "coordinator"
DATA_PAIRED_SENSOR_MANAGER = "paired_sensor_manager"
DATA_UNSUB_DISPATCHER_CONNECT = "unsub_dispatcher_connect"
SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED = "guardian_paired_sensor_coordinator_added_{0}"

View file

@ -1,45 +1,88 @@
"""Sensors for the Elexa Guardian integration.""" """Sensors for the Elexa Guardian integration."""
from typing import Callable, Dict from typing import Callable, Dict, Optional
from aioguardian import Client
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_FAHRENHEIT, TIME_MINUTES from homeassistant.const import (
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_TEMPERATURE,
PERCENTAGE,
TEMP_FAHRENHEIT,
TIME_MINUTES,
)
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from . import GuardianEntity from . import PairedSensorEntity, ValveControllerEntity
from .const import ( from .const import (
API_SENSOR_PAIRED_SENSOR_STATUS,
API_SYSTEM_DIAGNOSTICS, API_SYSTEM_DIAGNOSTICS,
API_SYSTEM_ONBOARD_SENSOR_STATUS, API_SYSTEM_ONBOARD_SENSOR_STATUS,
DATA_CLIENT, CONF_UID,
DATA_COORDINATOR, DATA_COORDINATOR,
DATA_UNSUB_DISPATCHER_CONNECT,
DOMAIN, DOMAIN,
SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED,
) )
SENSOR_KIND_BATTERY = "battery"
SENSOR_KIND_TEMPERATURE = "temperature" SENSOR_KIND_TEMPERATURE = "temperature"
SENSOR_KIND_UPTIME = "uptime" SENSOR_KIND_UPTIME = "uptime"
SENSORS = [
( SENSOR_ATTRS_MAP = {
SENSOR_KIND_TEMPERATURE, SENSOR_KIND_BATTERY: ("Battery", DEVICE_CLASS_BATTERY, None, PERCENTAGE),
SENSOR_KIND_TEMPERATURE: (
"Temperature", "Temperature",
DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TEMPERATURE,
None, None,
TEMP_FAHRENHEIT, TEMP_FAHRENHEIT,
), ),
(SENSOR_KIND_UPTIME, "Uptime", None, "mdi:timer-outline", TIME_MINUTES), SENSOR_KIND_UPTIME: ("Uptime", None, "mdi:timer", TIME_MINUTES),
] }
PAIRED_SENSOR_SENSORS = [SENSOR_KIND_BATTERY, SENSOR_KIND_TEMPERATURE]
VALVE_CONTROLLER_SENSORS = [SENSOR_KIND_TEMPERATURE, SENSOR_KIND_UPTIME]
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable
) -> None: ) -> None:
"""Set up Guardian switches based on a config entry.""" """Set up Guardian switches based on a config entry."""
async_add_entities(
[ async def add_new_paired_sensor(uid: str) -> None:
GuardianSensor( """Add a new paired sensor."""
coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][
API_SENSOR_PAIRED_SENSOR_STATUS
][uid]
entities = []
for kind in PAIRED_SENSOR_SENSORS:
name, device_class, icon, unit = SENSOR_ATTRS_MAP[kind]
entities.append(
PairedSensorSensor(
entry, coordinator, kind, name, device_class, icon, unit
)
)
async_add_entities(entities, True)
# Handle adding paired sensors after HASS startup:
hass.data[DOMAIN][DATA_UNSUB_DISPATCHER_CONNECT][entry.entry_id].append(
async_dispatcher_connect(
hass,
SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED.format(entry.data[CONF_UID]),
add_new_paired_sensor,
)
)
sensors = []
# Add all valve controller-specific binary sensors:
for kind in VALVE_CONTROLLER_SENSORS:
name, device_class, icon, unit = SENSOR_ATTRS_MAP[kind]
sensors.append(
ValveControllerSensor(
entry, entry,
hass.data[DOMAIN][DATA_CLIENT][entry.entry_id],
hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id], hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id],
kind, kind,
name, name,
@ -47,28 +90,81 @@ async def async_setup_entry(
icon, icon,
unit, unit,
) )
for kind, name, device_class, icon, unit in SENSORS )
],
True, # Add all paired sensor-specific binary sensors:
) for coordinator in hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][
API_SENSOR_PAIRED_SENSOR_STATUS
].values():
for kind in PAIRED_SENSOR_SENSORS:
name, device_class, icon, unit = SENSOR_ATTRS_MAP[kind]
sensors.append(
PairedSensorSensor(
entry, coordinator, kind, name, device_class, icon, unit
)
)
async_add_entities(sensors)
class GuardianSensor(GuardianEntity): class PairedSensorSensor(PairedSensorEntity):
"""Define a binary sensor related to a Guardian valve controller."""
def __init__(
self,
entry: ConfigEntry,
coordinator: DataUpdateCoordinator,
kind: str,
name: str,
device_class: Optional[str],
icon: Optional[str],
unit: Optional[str],
) -> None:
"""Initialize."""
super().__init__(entry, coordinator, kind, name, device_class, icon)
self._state = None
self._unit = unit
@property
def available(self) -> bool:
"""Return whether the entity is available."""
return self.coordinator.last_update_success
@property
def state(self) -> str:
"""Return the sensor state."""
return self._state
@property
def unit_of_measurement(self) -> str:
"""Return the unit of measurement of this entity, if any."""
return self._unit
@callback
def _async_update_from_latest_data(self) -> None:
"""Update the entity."""
if self._kind == SENSOR_KIND_BATTERY:
self._state = self.coordinator.data["battery"]
elif self._kind == SENSOR_KIND_TEMPERATURE:
self._state = self.coordinator.data["temperature"]
class ValveControllerSensor(ValveControllerEntity):
"""Define a generic Guardian sensor.""" """Define a generic Guardian sensor."""
def __init__( def __init__(
self, self,
entry: ConfigEntry, entry: ConfigEntry,
client: Client,
coordinators: Dict[str, DataUpdateCoordinator], coordinators: Dict[str, DataUpdateCoordinator],
kind: str, kind: str,
name: str, name: str,
device_class: str, device_class: Optional[str],
icon: str, icon: Optional[str],
unit: str, unit: Optional[str],
) -> None: ) -> None:
"""Initialize.""" """Initialize."""
super().__init__(entry, client, coordinators, kind, name, device_class, icon) super().__init__(entry, coordinators, kind, name, device_class, icon)
self._state = None self._state = None
self._unit = unit self._unit = unit
@ -77,11 +173,11 @@ class GuardianSensor(GuardianEntity):
def available(self) -> bool: def available(self) -> bool:
"""Return whether the entity is available.""" """Return whether the entity is available."""
if self._kind == SENSOR_KIND_TEMPERATURE: if self._kind == SENSOR_KIND_TEMPERATURE:
return self._coordinators[ return self.coordinators[
API_SYSTEM_ONBOARD_SENSOR_STATUS API_SYSTEM_ONBOARD_SENSOR_STATUS
].last_update_success ].last_update_success
if self._kind == SENSOR_KIND_UPTIME: if self._kind == SENSOR_KIND_UPTIME:
return self._coordinators[API_SYSTEM_DIAGNOSTICS].last_update_success return self.coordinators[API_SYSTEM_DIAGNOSTICS].last_update_success
return False return False
@property @property
@ -94,7 +190,7 @@ class GuardianSensor(GuardianEntity):
"""Return the unit of measurement of this entity, if any.""" """Return the unit of measurement of this entity, if any."""
return self._unit return self._unit
async def _async_internal_added_to_hass(self) -> None: async def _async_continue_entity_setup(self) -> None:
"""Register API interest (and related tasks) when the entity is added.""" """Register API interest (and related tasks) when the entity is added."""
if self._kind == SENSOR_KIND_TEMPERATURE: if self._kind == SENSOR_KIND_TEMPERATURE:
self.async_add_coordinator_update_listener(API_SYSTEM_ONBOARD_SENSOR_STATUS) self.async_add_coordinator_update_listener(API_SYSTEM_ONBOARD_SENSOR_STATUS)
@ -103,8 +199,8 @@ class GuardianSensor(GuardianEntity):
def _async_update_from_latest_data(self) -> None: def _async_update_from_latest_data(self) -> None:
"""Update the entity.""" """Update the entity."""
if self._kind == SENSOR_KIND_TEMPERATURE: if self._kind == SENSOR_KIND_TEMPERATURE:
self._state = self._coordinators[API_SYSTEM_ONBOARD_SENSOR_STATUS].data[ self._state = self.coordinators[API_SYSTEM_ONBOARD_SENSOR_STATUS].data[
"temperature" "temperature"
] ]
elif self._kind == SENSOR_KIND_UPTIME: elif self._kind == SENSOR_KIND_UPTIME:
self._state = self._coordinators[API_SYSTEM_DIAGNOSTICS].data["uptime"] self._state = self.coordinators[API_SYSTEM_DIAGNOSTICS].data["uptime"]

View file

@ -11,6 +11,15 @@ enable_ap:
entity_id: entity_id:
description: The Guardian valve controller to affect. description: The Guardian valve controller to affect.
example: switch.guardian_abcde_valve example: switch.guardian_abcde_valve
pair_sensor:
description: Add a new paired sensor to the valve controller.
fields:
entity_id:
description: The Guardian valve controller to affect.
example: switch.guardian_abcde_valve
uid:
description: The UID of the paired sensor
example: 5410EC688BCF
reboot: reboot:
description: Reboot the device. description: Reboot the device.
fields: fields:
@ -23,6 +32,15 @@ reset_valve_diagnostics:
entity_id: entity_id:
description: The Guardian valve controller to affect. description: The Guardian valve controller to affect.
example: switch.guardian_abcde_valve example: switch.guardian_abcde_valve
unpair_sensor:
description: Remove a paired sensor from the valve controller.
fields:
entity_id:
description: The Guardian valve controller to affect.
example: switch.guardian_abcde_valve
uid:
description: The UID of the paired sensor
example: 5410EC688BCF
upgrade_firmware: upgrade_firmware:
description: Upgrade the device firmware. description: Upgrade the device firmware.
fields: fields:

View file

@ -12,8 +12,16 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from . import GuardianEntity from . import ValveControllerEntity
from .const import API_VALVE_STATUS, DATA_CLIENT, DATA_COORDINATOR, DOMAIN, LOGGER from .const import (
API_VALVE_STATUS,
CONF_UID,
DATA_CLIENT,
DATA_COORDINATOR,
DATA_PAIRED_SENSOR_MANAGER,
DOMAIN,
LOGGER,
)
ATTR_AVG_CURRENT = "average_current" ATTR_AVG_CURRENT = "average_current"
ATTR_INST_CURRENT = "instantaneous_current" ATTR_INST_CURRENT = "instantaneous_current"
@ -22,8 +30,10 @@ ATTR_TRAVEL_COUNT = "travel_count"
SERVICE_DISABLE_AP = "disable_ap" SERVICE_DISABLE_AP = "disable_ap"
SERVICE_ENABLE_AP = "enable_ap" SERVICE_ENABLE_AP = "enable_ap"
SERVICE_PAIR_SENSOR = "pair_sensor"
SERVICE_REBOOT = "reboot" SERVICE_REBOOT = "reboot"
SERVICE_RESET_VALVE_DIAGNOSTICS = "reset_valve_diagnostics" SERVICE_RESET_VALVE_DIAGNOSTICS = "reset_valve_diagnostics"
SERVICE_UNPAIR_SENSOR = "unpair_sensor"
SERVICE_UPGRADE_FIRMWARE = "upgrade_firmware" SERVICE_UPGRADE_FIRMWARE = "upgrade_firmware"
@ -36,6 +46,7 @@ async def async_setup_entry(
for service_name, schema, method in [ for service_name, schema, method in [
(SERVICE_DISABLE_AP, {}, "async_disable_ap"), (SERVICE_DISABLE_AP, {}, "async_disable_ap"),
(SERVICE_ENABLE_AP, {}, "async_enable_ap"), (SERVICE_ENABLE_AP, {}, "async_enable_ap"),
(SERVICE_PAIR_SENSOR, {vol.Required(CONF_UID): cv.string}, "async_pair_sensor"),
(SERVICE_REBOOT, {}, "async_reboot"), (SERVICE_REBOOT, {}, "async_reboot"),
(SERVICE_RESET_VALVE_DIAGNOSTICS, {}, "async_reset_valve_diagnostics"), (SERVICE_RESET_VALVE_DIAGNOSTICS, {}, "async_reset_valve_diagnostics"),
( (
@ -47,22 +58,26 @@ async def async_setup_entry(
}, },
"async_upgrade_firmware", "async_upgrade_firmware",
), ),
(
SERVICE_UNPAIR_SENSOR,
{vol.Required(CONF_UID): cv.string},
"async_unpair_sensor",
),
]: ]:
platform.async_register_entity_service(service_name, schema, method) platform.async_register_entity_service(service_name, schema, method)
async_add_entities( async_add_entities(
[ [
GuardianSwitch( ValveControllerSwitch(
entry, entry,
hass.data[DOMAIN][DATA_CLIENT][entry.entry_id], hass.data[DOMAIN][DATA_CLIENT][entry.entry_id],
hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id], hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id],
) )
], ]
True,
) )
class GuardianSwitch(GuardianEntity, SwitchEntity): class ValveControllerSwitch(ValveControllerEntity, SwitchEntity):
"""Define a switch to open/close the Guardian valve.""" """Define a switch to open/close the Guardian valve."""
def __init__( def __init__(
@ -73,29 +88,30 @@ class GuardianSwitch(GuardianEntity, SwitchEntity):
): ):
"""Initialize.""" """Initialize."""
super().__init__( super().__init__(
entry, client, coordinators, "valve", "Valve", None, "mdi:water" entry, coordinators, "valve", "Valve Controller", None, "mdi:water"
) )
self._client = client
self._is_on = True self._is_on = True
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return whether the entity is available.""" """Return whether the entity is available."""
return self._coordinators[API_VALVE_STATUS].last_update_success return self.coordinators[API_VALVE_STATUS].last_update_success
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return True if the valve is open.""" """Return True if the valve is open."""
return self._is_on return self._is_on
async def _async_internal_added_to_hass(self): async def _async_continue_entity_setup(self):
"""Register API interest (and related tasks) when the entity is added.""" """Register API interest (and related tasks) when the entity is added."""
self.async_add_coordinator_update_listener(API_VALVE_STATUS) self.async_add_coordinator_update_listener(API_VALVE_STATUS)
@callback @callback
def _async_update_from_latest_data(self) -> None: def _async_update_from_latest_data(self) -> None:
"""Update the entity.""" """Update the entity."""
self._is_on = self._coordinators[API_VALVE_STATUS].data["state"] in ( self._is_on = self.coordinators[API_VALVE_STATUS].data["state"] in (
"start_opening", "start_opening",
"opening", "opening",
"finish_opening", "finish_opening",
@ -104,16 +120,16 @@ class GuardianSwitch(GuardianEntity, SwitchEntity):
self._attrs.update( self._attrs.update(
{ {
ATTR_AVG_CURRENT: self._coordinators[API_VALVE_STATUS].data[ ATTR_AVG_CURRENT: self.coordinators[API_VALVE_STATUS].data[
"average_current" "average_current"
], ],
ATTR_INST_CURRENT: self._coordinators[API_VALVE_STATUS].data[ ATTR_INST_CURRENT: self.coordinators[API_VALVE_STATUS].data[
"instantaneous_current" "instantaneous_current"
], ],
ATTR_INST_CURRENT_DDT: self._coordinators[API_VALVE_STATUS].data[ ATTR_INST_CURRENT_DDT: self.coordinators[API_VALVE_STATUS].data[
"instantaneous_current_ddt" "instantaneous_current_ddt"
], ],
ATTR_TRAVEL_COUNT: self._coordinators[API_VALVE_STATUS].data[ ATTR_TRAVEL_COUNT: self.coordinators[API_VALVE_STATUS].data[
"travel_count" "travel_count"
], ],
} }
@ -125,7 +141,7 @@ class GuardianSwitch(GuardianEntity, SwitchEntity):
async with self._client: async with self._client:
await self._client.wifi.disable_ap() await self._client.wifi.disable_ap()
except GuardianError as err: except GuardianError as err:
LOGGER.error("Error during service call: %s", err) LOGGER.error("Error while disabling valve controller AP: %s", err)
async def async_enable_ap(self): async def async_enable_ap(self):
"""Enable the device's onboard access point.""" """Enable the device's onboard access point."""
@ -133,7 +149,20 @@ class GuardianSwitch(GuardianEntity, SwitchEntity):
async with self._client: async with self._client:
await self._client.wifi.enable_ap() await self._client.wifi.enable_ap()
except GuardianError as err: except GuardianError as err:
LOGGER.error("Error during service call: %s", err) LOGGER.error("Error while enabling valve controller AP: %s", err)
async def async_pair_sensor(self, *, uid):
"""Add a new paired sensor."""
try:
async with self._client:
await self._client.sensor.pair_sensor(uid)
except GuardianError as err:
LOGGER.error("Error while adding paired sensor: %s", err)
return
await self.hass.data[DOMAIN][DATA_PAIRED_SENSOR_MANAGER][
self._entry.entry_id
].async_pair_sensor(uid)
async def async_reboot(self): async def async_reboot(self):
"""Reboot the device.""" """Reboot the device."""
@ -141,7 +170,7 @@ class GuardianSwitch(GuardianEntity, SwitchEntity):
async with self._client: async with self._client:
await self._client.system.reboot() await self._client.system.reboot()
except GuardianError as err: except GuardianError as err:
LOGGER.error("Error during service call: %s", err) LOGGER.error("Error while rebooting valve controller: %s", err)
async def async_reset_valve_diagnostics(self): async def async_reset_valve_diagnostics(self):
"""Fully reset system motor diagnostics.""" """Fully reset system motor diagnostics."""
@ -149,7 +178,20 @@ class GuardianSwitch(GuardianEntity, SwitchEntity):
async with self._client: async with self._client:
await self._client.valve.reset() await self._client.valve.reset()
except GuardianError as err: except GuardianError as err:
LOGGER.error("Error during service call: %s", err) LOGGER.error("Error while resetting valve diagnostics: %s", err)
async def async_unpair_sensor(self, *, uid):
"""Add a new paired sensor."""
try:
async with self._client:
await self._client.sensor.unpair_sensor(uid)
except GuardianError as err:
LOGGER.error("Error while removing paired sensor: %s", err)
return
await self.hass.data[DOMAIN][DATA_PAIRED_SENSOR_MANAGER][
self._entry.entry_id
].async_unpair_sensor(uid)
async def async_upgrade_firmware(self, *, url, port, filename): async def async_upgrade_firmware(self, *, url, port, filename):
"""Upgrade the device firmware.""" """Upgrade the device firmware."""
@ -161,7 +203,7 @@ class GuardianSwitch(GuardianEntity, SwitchEntity):
filename=filename, filename=filename,
) )
except GuardianError as err: except GuardianError as err:
LOGGER.error("Error during service call: %s", err) LOGGER.error("Error while upgrading firmware: %s", err)
async def async_turn_off(self, **kwargs) -> None: async def async_turn_off(self, **kwargs) -> None:
"""Turn the valve off (closed).""" """Turn the valve off (closed)."""