diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index 03796415d65..29c7a9b455c 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -1,6 +1,5 @@ """The Elexa Guardian integration.""" import asyncio -from datetime import timedelta from typing import Dict from aioguardian import Client @@ -8,10 +7,15 @@ from aioguardian import Client from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION, CONF_IP_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from .const import ( + API_SENSOR_PAIR_DUMP, + API_SENSOR_PAIRED_SENSOR_STATUS, API_SYSTEM_DIAGNOSTICS, API_SYSTEM_ONBOARD_SENSOR_STATUS, API_VALVE_STATUS, @@ -19,18 +23,28 @@ from .const import ( CONF_UID, DATA_CLIENT, DATA_COORDINATOR, + DATA_PAIRED_SENSOR_MANAGER, + DATA_UNSUB_DISPATCHER_CONNECT, DOMAIN, + LOGGER, + SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, ) from .util import GuardianDataUpdateCoordinator -DEFAULT_UPDATE_INTERVAL = timedelta(seconds=30) +DATA_LAST_SENSOR_PAIR_DUMP = "last_sensor_pair_dump" PLATFORMS = ["binary_sensor", "sensor", "switch"] async def async_setup(hass: HomeAssistant, config: dict) -> bool: """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 @@ -39,20 +53,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] = Client( 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, # so we use a lock to ensure that only one API request is reaching it at a time: api_lock = asyncio.Lock() - initial_fetch_tasks = [] + # Set up DataUpdateCoordinators for the valve controller: + init_valve_controller_tasks = [] for api, api_coro in [ + (API_SENSOR_PAIR_DUMP, client.sensor.pair_dump), (API_SYSTEM_DIAGNOSTICS, client.system.diagnostics), (API_SYSTEM_ONBOARD_SENSOR_STATUS, client.system.onboard_sensor_status), (API_VALVE_STATUS, client.valve.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 ] = GuardianDataUpdateCoordinator( hass, @@ -62,12 +81,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api_lock=api_lock, valve_controller_uid=entry.data[CONF_UID], ) - initial_fetch_tasks.append( - hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][api].async_refresh() + init_valve_controller_tasks.append(coordinator.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: hass.async_create_task( 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: hass.data[DOMAIN][DATA_CLIENT].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 -class GuardianEntity(Entity): - """Define a base Guardian entity.""" +class PairedSensorManager: + """Define an object that manages the addition/removal of paired sensors.""" def __init__( self, + hass: HomeAssistant, entry: ConfigEntry, client: Client, - coordinators: Dict[str, DataUpdateCoordinator], - kind: str, - name: str, - device_class: str, - icon: str, + api_lock: asyncio.Lock, + ) -> None: + """Initialize.""" + self._api_lock = api_lock + 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: """Initialize.""" self._attrs = {ATTR_ATTRIBUTION: "Data provided by Elexa"} self._available = True - self._client = client - self._coordinators = coordinators + self._entry = entry self._device_class = device_class + self._device_info = {"manufacturer": "Elexa"} self._icon = icon self._kind = kind self._name = name - self._valve_controller_uid = entry.data[CONF_UID] @property def device_class(self) -> str: @@ -125,12 +243,7 @@ class GuardianEntity(Entity): @property def device_info(self) -> dict: """Return device registry information for this entity.""" - return { - "identifiers": {(DOMAIN, self._valve_controller_uid)}, - "manufacturer": "Elexa", - "model": self._coordinators[API_SYSTEM_DIAGNOSTICS].data["firmware"], - "name": f"Guardian {self._valve_controller_uid}", - } + return self._device_info @property def device_state_attributes(self) -> dict: @@ -142,28 +255,6 @@ class GuardianEntity(Entity): """Return the 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 def _async_update_from_latest_data(self): """Update the entity. @@ -173,19 +264,122 @@ class GuardianEntity(Entity): raise NotImplementedError @callback - def async_add_coordinator_update_listener(self, api: str) -> None: - """Add a listener to a DataUpdateCoordinator based on the API referenced.""" + def _async_update_state_callback(self): + """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: """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_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) diff --git a/homeassistant/components/guardian/binary_sensor.py b/homeassistant/components/guardian/binary_sensor.py index 7942dba361e..1abd92cd198 100644 --- a/homeassistant/components/guardian/binary_sensor.py +++ b/homeassistant/components/guardian/binary_sensor.py @@ -1,70 +1,167 @@ """Binary sensors for the Elexa Guardian integration.""" -from typing import Callable, Dict - -from aioguardian import Client +from typing import Callable, Dict, Optional from homeassistant.components.binary_sensor import ( DEVICE_CLASS_CONNECTIVITY, DEVICE_CLASS_MOISTURE, + DEVICE_CLASS_MOVING, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import GuardianEntity +from . import PairedSensorEntity, ValveControllerEntity from .const import ( + API_SENSOR_PAIRED_SENSOR_STATUS, API_SYSTEM_ONBOARD_SENSOR_STATUS, API_WIFI_STATUS, - DATA_CLIENT, + CONF_UID, DATA_COORDINATOR, + DATA_UNSUB_DISPATCHER_CONNECT, DOMAIN, + SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, ) ATTR_CONNECTED_CLIENTS = "connected_clients" SENSOR_KIND_AP_INFO = "ap_enabled" SENSOR_KIND_LEAK_DETECTED = "leak_detected" -SENSORS = [ - (SENSOR_KIND_AP_INFO, "Onboard AP Enabled", DEVICE_CLASS_CONNECTIVITY), - (SENSOR_KIND_LEAK_DETECTED, "Leak Detected", DEVICE_CLASS_MOISTURE), -] +SENSOR_KIND_MOVED = "moved" + +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( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable ) -> None: """Set up Guardian switches based on a config entry.""" - async_add_entities( - [ - GuardianBinarySensor( + + async def add_new_paired_sensor(uid: str) -> None: + """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, - hass.data[DOMAIN][DATA_CLIENT][entry.entry_id], hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id], kind, name, 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): - """Define a generic Guardian sensor.""" +class PairedSensorBinarySensor(PairedSensorEntity, BinarySensorEntity): + """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__( self, entry: ConfigEntry, - client: Client, coordinators: Dict[str, DataUpdateCoordinator], kind: str, name: str, - device_class: str, + device_class: Optional[str], + icon: Optional[str], ) -> None: """Initialize.""" - super().__init__(entry, client, coordinators, kind, name, device_class, None) + super().__init__(entry, coordinators, kind, name, device_class, icon) self._is_on = True @@ -72,9 +169,9 @@ class GuardianBinarySensor(GuardianEntity, BinarySensorEntity): def available(self) -> bool: """Return whether the entity is available.""" 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: - return self._coordinators[ + return self.coordinators[ API_SYSTEM_ONBOARD_SENSOR_STATUS ].last_update_success return False @@ -84,7 +181,8 @@ class GuardianBinarySensor(GuardianEntity, BinarySensorEntity): """Return True if the binary sensor 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: self.async_add_coordinator_update_listener(API_WIFI_STATUS) elif self._kind == SENSOR_KIND_LEAK_DETECTED: @@ -94,15 +192,15 @@ class GuardianBinarySensor(GuardianEntity, BinarySensorEntity): def _async_update_from_latest_data(self) -> None: """Update the entity.""" 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( { - ATTR_CONNECTED_CLIENTS: self._coordinators[API_WIFI_STATUS].data[ + ATTR_CONNECTED_CLIENTS: self.coordinators[API_WIFI_STATUS].data.get( "ap_clients" - ] + ) } ) 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" ] diff --git a/homeassistant/components/guardian/const.py b/homeassistant/components/guardian/const.py index 321a46a3ffc..750a8c407ca 100644 --- a/homeassistant/components/guardian/const.py +++ b/homeassistant/components/guardian/const.py @@ -5,6 +5,8 @@ DOMAIN = "guardian" 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_ONBOARD_SENSOR_STATUS = "system_onboard_sensor_status" API_VALVE_STATUS = "valve_status" @@ -14,3 +16,7 @@ CONF_UID = "uid" DATA_CLIENT = "client" 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}" diff --git a/homeassistant/components/guardian/sensor.py b/homeassistant/components/guardian/sensor.py index eadfd2b946f..520875823f5 100644 --- a/homeassistant/components/guardian/sensor.py +++ b/homeassistant/components/guardian/sensor.py @@ -1,45 +1,88 @@ """Sensors for the Elexa Guardian integration.""" -from typing import Callable, Dict - -from aioguardian import Client +from typing import Callable, Dict, Optional 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.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import GuardianEntity +from . import PairedSensorEntity, ValveControllerEntity from .const import ( + API_SENSOR_PAIRED_SENSOR_STATUS, API_SYSTEM_DIAGNOSTICS, API_SYSTEM_ONBOARD_SENSOR_STATUS, - DATA_CLIENT, + CONF_UID, DATA_COORDINATOR, + DATA_UNSUB_DISPATCHER_CONNECT, DOMAIN, + SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, ) +SENSOR_KIND_BATTERY = "battery" SENSOR_KIND_TEMPERATURE = "temperature" SENSOR_KIND_UPTIME = "uptime" -SENSORS = [ - ( - SENSOR_KIND_TEMPERATURE, + +SENSOR_ATTRS_MAP = { + SENSOR_KIND_BATTERY: ("Battery", DEVICE_CLASS_BATTERY, None, PERCENTAGE), + SENSOR_KIND_TEMPERATURE: ( "Temperature", DEVICE_CLASS_TEMPERATURE, None, 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( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable ) -> None: """Set up Guardian switches based on a config entry.""" - async_add_entities( - [ - GuardianSensor( + + async def add_new_paired_sensor(uid: str) -> None: + """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, - hass.data[DOMAIN][DATA_CLIENT][entry.entry_id], hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id], kind, name, @@ -47,28 +90,81 @@ async def async_setup_entry( icon, 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.""" def __init__( self, entry: ConfigEntry, - client: Client, coordinators: Dict[str, DataUpdateCoordinator], kind: str, name: str, - device_class: str, - icon: str, - unit: str, + device_class: Optional[str], + icon: Optional[str], + unit: Optional[str], ) -> None: """Initialize.""" - super().__init__(entry, client, coordinators, kind, name, device_class, icon) + super().__init__(entry, coordinators, kind, name, device_class, icon) self._state = None self._unit = unit @@ -77,11 +173,11 @@ class GuardianSensor(GuardianEntity): def available(self) -> bool: """Return whether the entity is available.""" if self._kind == SENSOR_KIND_TEMPERATURE: - return self._coordinators[ + return self.coordinators[ API_SYSTEM_ONBOARD_SENSOR_STATUS ].last_update_success 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 @property @@ -94,7 +190,7 @@ class GuardianSensor(GuardianEntity): """Return the unit of measurement of this entity, if any.""" 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.""" if self._kind == SENSOR_KIND_TEMPERATURE: 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: """Update the entity.""" 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" ] elif self._kind == SENSOR_KIND_UPTIME: - self._state = self._coordinators[API_SYSTEM_DIAGNOSTICS].data["uptime"] + self._state = self.coordinators[API_SYSTEM_DIAGNOSTICS].data["uptime"] diff --git a/homeassistant/components/guardian/services.yaml b/homeassistant/components/guardian/services.yaml index 6e20e2aca9d..dc78503eb12 100644 --- a/homeassistant/components/guardian/services.yaml +++ b/homeassistant/components/guardian/services.yaml @@ -11,6 +11,15 @@ enable_ap: entity_id: description: The Guardian valve controller to affect. 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: description: Reboot the device. fields: @@ -23,6 +32,15 @@ reset_valve_diagnostics: entity_id: description: The Guardian valve controller to affect. 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: description: Upgrade the device firmware. fields: diff --git a/homeassistant/components/guardian/switch.py b/homeassistant/components/guardian/switch.py index 8aebd078e04..20a38ea5ce7 100644 --- a/homeassistant/components/guardian/switch.py +++ b/homeassistant/components/guardian/switch.py @@ -12,8 +12,16 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import GuardianEntity -from .const import API_VALVE_STATUS, DATA_CLIENT, DATA_COORDINATOR, DOMAIN, LOGGER +from . import ValveControllerEntity +from .const import ( + API_VALVE_STATUS, + CONF_UID, + DATA_CLIENT, + DATA_COORDINATOR, + DATA_PAIRED_SENSOR_MANAGER, + DOMAIN, + LOGGER, +) ATTR_AVG_CURRENT = "average_current" ATTR_INST_CURRENT = "instantaneous_current" @@ -22,8 +30,10 @@ ATTR_TRAVEL_COUNT = "travel_count" SERVICE_DISABLE_AP = "disable_ap" SERVICE_ENABLE_AP = "enable_ap" +SERVICE_PAIR_SENSOR = "pair_sensor" SERVICE_REBOOT = "reboot" SERVICE_RESET_VALVE_DIAGNOSTICS = "reset_valve_diagnostics" +SERVICE_UNPAIR_SENSOR = "unpair_sensor" SERVICE_UPGRADE_FIRMWARE = "upgrade_firmware" @@ -36,6 +46,7 @@ async def async_setup_entry( for service_name, schema, method in [ (SERVICE_DISABLE_AP, {}, "async_disable_ap"), (SERVICE_ENABLE_AP, {}, "async_enable_ap"), + (SERVICE_PAIR_SENSOR, {vol.Required(CONF_UID): cv.string}, "async_pair_sensor"), (SERVICE_REBOOT, {}, "async_reboot"), (SERVICE_RESET_VALVE_DIAGNOSTICS, {}, "async_reset_valve_diagnostics"), ( @@ -47,22 +58,26 @@ async def async_setup_entry( }, "async_upgrade_firmware", ), + ( + SERVICE_UNPAIR_SENSOR, + {vol.Required(CONF_UID): cv.string}, + "async_unpair_sensor", + ), ]: platform.async_register_entity_service(service_name, schema, method) async_add_entities( [ - GuardianSwitch( + ValveControllerSwitch( entry, hass.data[DOMAIN][DATA_CLIENT][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.""" def __init__( @@ -73,29 +88,30 @@ class GuardianSwitch(GuardianEntity, SwitchEntity): ): """Initialize.""" 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 @property def available(self) -> bool: """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 def is_on(self) -> bool: """Return True if the valve is open.""" 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.""" self.async_add_coordinator_update_listener(API_VALVE_STATUS) @callback def _async_update_from_latest_data(self) -> None: """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", "opening", "finish_opening", @@ -104,16 +120,16 @@ class GuardianSwitch(GuardianEntity, SwitchEntity): self._attrs.update( { - ATTR_AVG_CURRENT: self._coordinators[API_VALVE_STATUS].data[ + ATTR_AVG_CURRENT: self.coordinators[API_VALVE_STATUS].data[ "average_current" ], - ATTR_INST_CURRENT: self._coordinators[API_VALVE_STATUS].data[ + ATTR_INST_CURRENT: self.coordinators[API_VALVE_STATUS].data[ "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" ], - ATTR_TRAVEL_COUNT: self._coordinators[API_VALVE_STATUS].data[ + ATTR_TRAVEL_COUNT: self.coordinators[API_VALVE_STATUS].data[ "travel_count" ], } @@ -125,7 +141,7 @@ class GuardianSwitch(GuardianEntity, SwitchEntity): async with self._client: await self._client.wifi.disable_ap() 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): """Enable the device's onboard access point.""" @@ -133,7 +149,20 @@ class GuardianSwitch(GuardianEntity, SwitchEntity): async with self._client: await self._client.wifi.enable_ap() 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): """Reboot the device.""" @@ -141,7 +170,7 @@ class GuardianSwitch(GuardianEntity, SwitchEntity): async with self._client: await self._client.system.reboot() 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): """Fully reset system motor diagnostics.""" @@ -149,7 +178,20 @@ class GuardianSwitch(GuardianEntity, SwitchEntity): async with self._client: await self._client.valve.reset() 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): """Upgrade the device firmware.""" @@ -161,7 +203,7 @@ class GuardianSwitch(GuardianEntity, SwitchEntity): filename=filename, ) 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: """Turn the valve off (closed)."""