"""The Elexa Guardian integration.""" import asyncio from datetime import timedelta from aioguardian import Client from aioguardian.commands.device import ( DEFAULT_FIRMWARE_UPGRADE_FILENAME, DEFAULT_FIRMWARE_UPGRADE_PORT, DEFAULT_FIRMWARE_UPGRADE_URL, ) from aioguardian.errors import GuardianError import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_FILENAME, CONF_IP_ADDRESS, CONF_PORT, CONF_URL, ) from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.service import ( async_register_admin_service, verify_domain_control, ) from .const import ( CONF_UID, DATA_CLIENT, DATA_DIAGNOSTICS, DATA_PAIR_DUMP, DATA_PING, DATA_SENSOR_STATUS, DATA_VALVE_STATUS, DATA_WIFI_STATUS, DOMAIN, LOGGER, SENSOR_KIND_AP_INFO, SENSOR_KIND_LEAK_DETECTED, SENSOR_KIND_TEMPERATURE, SWITCH_KIND_VALVE, TOPIC_UPDATE, ) DATA_ENTITY_TYPE_MAP = { SENSOR_KIND_AP_INFO: DATA_WIFI_STATUS, SENSOR_KIND_LEAK_DETECTED: DATA_SENSOR_STATUS, SENSOR_KIND_TEMPERATURE: DATA_SENSOR_STATUS, SWITCH_KIND_VALVE: DATA_VALVE_STATUS, } DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) PLATFORMS = ["binary_sensor", "sensor", "switch"] SERVICE_UPGRADE_FIRMWARE_SCHEMA = vol.Schema( { vol.Optional(CONF_URL, default=DEFAULT_FIRMWARE_UPGRADE_URL): cv.url, vol.Optional(CONF_PORT, default=DEFAULT_FIRMWARE_UPGRADE_PORT): cv.port, vol.Optional( CONF_FILENAME, default=DEFAULT_FIRMWARE_UPGRADE_FILENAME ): cv.string, } ) @callback def async_get_api_category(entity_kind: str): """Get the API data category to which an entity belongs.""" return DATA_ENTITY_TYPE_MAP.get(entity_kind) async def async_setup(hass: HomeAssistant, config: dict): """Set up the Elexa Guardian component.""" hass.data[DOMAIN] = {DATA_CLIENT: {}} return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Set up Elexa Guardian from a config entry.""" _verify_domain_control = verify_domain_control(hass, DOMAIN) guardian = Guardian(hass, entry) await guardian.async_update() hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] = guardian for component in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) ) @_verify_domain_control async def disable_ap(call): """Disable the device's onboard access point.""" try: async with guardian.client: await guardian.client.device.wifi_disable_ap() except GuardianError as err: LOGGER.error("Error during service call: %s", err) return @_verify_domain_control async def enable_ap(call): """Enable the device's onboard access point.""" try: async with guardian.client: await guardian.client.device.wifi_enable_ap() except GuardianError as err: LOGGER.error("Error during service call: %s", err) return @_verify_domain_control async def reboot(call): """Reboot the device.""" try: async with guardian.client: await guardian.client.device.reboot() except GuardianError as err: LOGGER.error("Error during service call: %s", err) return @_verify_domain_control async def reset_valve_diagnostics(call): """Fully reset system motor diagnostics.""" try: async with guardian.client: await guardian.client.valve.valve_reset() except GuardianError as err: LOGGER.error("Error during service call: %s", err) return @_verify_domain_control async def upgrade_firmware(call): """Upgrade the device firmware.""" try: async with guardian.client: await guardian.client.device.upgrade_firmware( url=call.data[CONF_URL], port=call.data[CONF_PORT], filename=call.data[CONF_FILENAME], ) except GuardianError as err: LOGGER.error("Error during service call: %s", err) return for service, method, schema in [ ("disable_ap", disable_ap, None), ("enable_ap", enable_ap, None), ("reboot", reboot, None), ("reset_valve_diagnostics", reset_valve_diagnostics, None), ("upgrade_firmware", upgrade_firmware, SERVICE_UPGRADE_FIRMWARE_SCHEMA), ]: async_register_admin_service(hass, DOMAIN, service, method, schema=schema) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Unload a config entry.""" unload_ok = all( await asyncio.gather( *[ hass.config_entries.async_forward_entry_unload(entry, component) for component in PLATFORMS ] ) ) if unload_ok: hass.data[DOMAIN][DATA_CLIENT].pop(entry.entry_id) return unload_ok class Guardian: """Define a class to communicate with the Guardian device.""" def __init__(self, hass: HomeAssistant, entry: ConfigEntry): """Initialize.""" self._async_cancel_time_interval_listener = None self._hass = hass self.client = Client(entry.data[CONF_IP_ADDRESS]) self.data = {} self.uid = entry.data[CONF_UID] self._api_coros = { DATA_DIAGNOSTICS: self.client.device.diagnostics, DATA_PAIR_DUMP: self.client.sensor.pair_dump, DATA_PING: self.client.device.ping, DATA_SENSOR_STATUS: self.client.sensor.sensor_status, DATA_VALVE_STATUS: self.client.valve.valve_status, DATA_WIFI_STATUS: self.client.device.wifi_status, } self._api_category_count = { DATA_SENSOR_STATUS: 0, DATA_VALVE_STATUS: 0, DATA_WIFI_STATUS: 0, } self._api_lock = asyncio.Lock() async def _async_get_data_from_api(self, api_category: str): """Update and save data for a particular API category.""" if self._api_category_count.get(api_category) == 0: return try: result = await self._api_coros[api_category]() except GuardianError as err: LOGGER.error("Error while fetching %s data: %s", api_category, err) self.data[api_category] = {} else: self.data[api_category] = result["data"] async def _async_update_listener_action(self, _): """Define an async_track_time_interval action to update data.""" await self.async_update() @callback def async_deregister_api_interest(self, sensor_kind: str): """Decrement the number of entities with data needs from an API category.""" # If this deregistration should leave us with no registration at all, remove the # time interval: if sum(self._api_category_count.values()) == 0: if self._async_cancel_time_interval_listener: self._async_cancel_time_interval_listener() self._async_cancel_time_interval_listener = None return api_category = async_get_api_category(sensor_kind) if api_category: self._api_category_count[api_category] -= 1 async def async_register_api_interest(self, sensor_kind: str): """Increment the number of entities with data needs from an API category.""" # If this is the first registration we have, start a time interval: if not self._async_cancel_time_interval_listener: self._async_cancel_time_interval_listener = async_track_time_interval( self._hass, self._async_update_listener_action, DEFAULT_SCAN_INTERVAL, ) api_category = async_get_api_category(sensor_kind) if not api_category: return self._api_category_count[api_category] += 1 # If a sensor registers interest in a particular API call and the data doesn't # exist for it yet, make the API call and grab the data: async with self._api_lock: if api_category not in self.data: async with self.client: await self._async_get_data_from_api(api_category) async def async_update(self): """Get updated data from the device.""" async with self.client: tasks = [ self._async_get_data_from_api(api_category) for api_category in self._api_coros ] await asyncio.gather(*tasks) LOGGER.debug("Received new data: %s", self.data) async_dispatcher_send(self._hass, TOPIC_UPDATE.format(self.uid)) class GuardianEntity(Entity): """Define a base Guardian entity.""" def __init__( self, guardian: Guardian, kind: str, name: str, device_class: str, icon: str ): """Initialize.""" self._attrs = {ATTR_ATTRIBUTION: "Data provided by Elexa"} self._available = True self._device_class = device_class self._guardian = guardian self._icon = icon self._kind = kind self._name = name @property def available(self): """Return whether the entity is available.""" return bool(self._guardian.data[DATA_PING]) @property def device_class(self): """Return the device class.""" return self._device_class @property def device_info(self): """Return device registry information for this entity.""" return { "identifiers": {(DOMAIN, self._guardian.uid)}, "manufacturer": "Elexa", "model": self._guardian.data[DATA_DIAGNOSTICS]["firmware"], "name": f"Guardian {self._guardian.uid}", } @property def device_state_attributes(self): """Return the state attributes.""" return self._attrs @property def icon(self) -> str: """Return the icon.""" return self._icon @property def name(self): """Return the name of the entity.""" return f"Guardian {self._guardian.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._guardian.uid}_{self._kind}" @callback def _update_from_latest_data(self): """Update the entity.""" raise NotImplementedError async def async_added_to_hass(self): """Register callbacks.""" @callback def update(): """Update the state.""" self._update_from_latest_data() self.async_write_ha_state() self.async_on_remove( async_dispatcher_connect( self.hass, TOPIC_UPDATE.format(self._guardian.uid), update ) ) await self._guardian.async_register_api_interest(self._kind) self._update_from_latest_data() async def async_will_remove_from_hass(self) -> None: """Disconnect dispatcher listener when removed.""" self._guardian.async_deregister_api_interest(self._kind)