From 369745c4cf161974384308817739dcf642bbf76b Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 26 May 2020 07:47:25 -0600 Subject: [PATCH] Add support for Elexa Guardian water valve controllers (#34627) * Add support for Elexa Guardian water valve controllers * Zeroconf + cleanup * Sensors and services * API registration * Service bug fixes * Fix bug in cleanup * Tests and coverage * Fix incorrect service description * Bump aioguardian * Bump aioguardian to 0.2.2 * Bump aioguardian to 0.2.3 * Proper entity inheritance * Give device a proper name * Code review --- .coveragerc | 4 + CODEOWNERS | 1 + homeassistant/components/guardian/__init__.py | 364 ++++++++++++++++++ .../components/guardian/binary_sensor.py | 62 +++ .../components/guardian/config_flow.py | 91 +++++ homeassistant/components/guardian/const.py | 25 ++ .../components/guardian/manifest.json | 18 + homeassistant/components/guardian/sensor.py | 73 ++++ .../components/guardian/services.yaml | 21 + .../components/guardian/strings.json | 22 ++ homeassistant/components/guardian/switch.py | 83 ++++ .../components/guardian/translations/en.json | 22 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/zeroconf.py | 3 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/guardian/__init__.py | 1 + tests/components/guardian/conftest.py | 17 + tests/components/guardian/test_config_flow.py | 124 ++++++ 19 files changed, 938 insertions(+) create mode 100644 homeassistant/components/guardian/__init__.py create mode 100644 homeassistant/components/guardian/binary_sensor.py create mode 100644 homeassistant/components/guardian/config_flow.py create mode 100644 homeassistant/components/guardian/const.py create mode 100644 homeassistant/components/guardian/manifest.json create mode 100644 homeassistant/components/guardian/sensor.py create mode 100644 homeassistant/components/guardian/services.yaml create mode 100644 homeassistant/components/guardian/strings.json create mode 100644 homeassistant/components/guardian/switch.py create mode 100644 homeassistant/components/guardian/translations/en.json create mode 100644 tests/components/guardian/__init__.py create mode 100644 tests/components/guardian/conftest.py create mode 100644 tests/components/guardian/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index dfab2c4035a..9f29ab80bb8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -299,6 +299,10 @@ omit = homeassistant/components/growatt_server/sensor.py homeassistant/components/gstreamer/media_player.py homeassistant/components/gtfs/sensor.py + homeassistant/components/guardian/__init__.py + homeassistant/components/guardian/binary_sensor.py + homeassistant/components/guardian/sensor.py + homeassistant/components/guardian/switch.py homeassistant/components/habitica/* homeassistant/components/hangouts/* homeassistant/components/hangouts/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index a0ccec81579..569d104d0bb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -161,6 +161,7 @@ homeassistant/components/griddy/* @bdraco homeassistant/components/group/* @home-assistant/core homeassistant/components/growatt_server/* @indykoning homeassistant/components/gtfs/* @robbiet480 +homeassistant/components/guardian/* @bachya homeassistant/components/harmony/* @ehendrix23 @bramkragten @bdraco homeassistant/components/hassio/* @home-assistant/hass-io homeassistant/components/heatmiser/* @andylockran diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py new file mode 100644 index 00000000000..8ccf69c7077 --- /dev/null +++ b/homeassistant/components/guardian/__init__.py @@ -0,0 +1,364 @@ +"""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) diff --git a/homeassistant/components/guardian/binary_sensor.py b/homeassistant/components/guardian/binary_sensor.py new file mode 100644 index 00000000000..f9d70d03d5d --- /dev/null +++ b/homeassistant/components/guardian/binary_sensor.py @@ -0,0 +1,62 @@ +"""Binary sensors for the Elexa Guardian integration.""" +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.core import callback + +from . import GuardianEntity +from .const import ( + DATA_CLIENT, + DATA_SENSOR_STATUS, + DATA_WIFI_STATUS, + DOMAIN, + SENSOR_KIND_AP_INFO, + SENSOR_KIND_LEAK_DETECTED, +) + +ATTR_CONNECTED_CLIENTS = "connected_clients" + +SENSORS = [ + (SENSOR_KIND_AP_INFO, "Onboard AP Enabled", "connectivity"), + (SENSOR_KIND_LEAK_DETECTED, "Leak Detected", "moisture"), +] + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Guardian switches based on a config entry.""" + guardian = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] + async_add_entities( + [ + GuardianBinarySensor(guardian, kind, name, device_class) + for kind, name, device_class in SENSORS + ], + True, + ) + + +class GuardianBinarySensor(GuardianEntity, BinarySensorEntity): + """Define a generic Guardian sensor.""" + + def __init__(self, guardian, kind, name, device_class): + """Initialize.""" + super().__init__(guardian, kind, name, device_class, None) + + self._is_on = True + + @property + def is_on(self): + """Return True if the binary sensor is on.""" + return self._is_on + + @callback + def _update_from_latest_data(self): + """Update the entity.""" + if self._kind == SENSOR_KIND_AP_INFO: + self._is_on = self._guardian.data[DATA_WIFI_STATUS]["ap_enabled"] + self._attrs.update( + { + ATTR_CONNECTED_CLIENTS: self._guardian.data[DATA_WIFI_STATUS][ + "ap_clients" + ] + } + ) + elif self._kind == SENSOR_KIND_LEAK_DETECTED: + self._is_on = self._guardian.data[DATA_SENSOR_STATUS]["wet"] diff --git a/homeassistant/components/guardian/config_flow.py b/homeassistant/components/guardian/config_flow.py new file mode 100644 index 00000000000..3a7558bb222 --- /dev/null +++ b/homeassistant/components/guardian/config_flow.py @@ -0,0 +1,91 @@ +"""Config flow for Elexa Guardian integration.""" +from aioguardian import Client +from aioguardian.errors import GuardianError +import voluptuous as vol + +from homeassistant import config_entries, core +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT + +from .const import CONF_UID, DOMAIN, LOGGER # pylint:disable=unused-import + +DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_IP_ADDRESS): str, vol.Required(CONF_PORT, default=7777): int} +) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + async with Client(data[CONF_IP_ADDRESS]) as client: + ping_data = await client.device.ping() + + return { + "title": f"Elexa Guardian ({data[CONF_IP_ADDRESS]})", + CONF_UID: ping_data["data"]["uid"], + } + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Elexa Guardian.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize.""" + self.discovery_info = {} + + async def async_step_user(self, user_input=None): + """Handle configuration via the UI.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors={} + ) + + await self.async_set_unique_id(user_input[CONF_IP_ADDRESS]) + self._abort_if_unique_id_configured() + + try: + info = await validate_input(self.hass, user_input) + except GuardianError as err: + LOGGER.error("Error while connecting to unit: %s", err) + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors={CONF_IP_ADDRESS: "cannot_connect"}, + ) + + return self.async_create_entry( + title=info["title"], data={CONF_UID: info["uid"], **user_input} + ) + + async def async_step_zeroconf(self, discovery_info=None): + """Handle the configuration via zeroconf.""" + if discovery_info is None: + return self.async_abort(reason="connection_error") + + ip_address = discovery_info["host"] + + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context[CONF_IP_ADDRESS] = ip_address + + if any( + ip_address == flow["context"][CONF_IP_ADDRESS] + for flow in self._async_in_progress() + ): + return self.async_abort(reason="already_in_progress") + + self.discovery_info = { + CONF_IP_ADDRESS: ip_address, + CONF_PORT: discovery_info["port"], + } + + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm(self, user_input=None): + """Finish the configuration via zeroconf.""" + if user_input is None: + return self.async_show_form(step_id="zeroconf_confirm") + return await self.async_step_user(self.discovery_info) diff --git a/homeassistant/components/guardian/const.py b/homeassistant/components/guardian/const.py new file mode 100644 index 00000000000..f1d60fd07da --- /dev/null +++ b/homeassistant/components/guardian/const.py @@ -0,0 +1,25 @@ +"""Constants for the Elexa Guardian integration.""" +import logging + +DOMAIN = "guardian" + +LOGGER = logging.getLogger(__package__) + +CONF_UID = "uid" + +DATA_CLIENT = "client" +DATA_DIAGNOSTICS = "diagnostics" +DATA_PAIR_DUMP = "pair_sensor" +DATA_PING = "ping" +DATA_SENSOR_STATUS = "sensor_status" +DATA_VALVE_STATUS = "valve_status" +DATA_WIFI_STATUS = "wifi_status" + +SENSOR_KIND_AP_INFO = "ap_enabled" +SENSOR_KIND_LEAK_DETECTED = "leak_detected" +SENSOR_KIND_TEMPERATURE = "temperature" +SENSOR_KIND_UPTIME = "uptime" + +SWITCH_KIND_VALVE = "valve" + +TOPIC_UPDATE = "guardian_update_{0}" diff --git a/homeassistant/components/guardian/manifest.json b/homeassistant/components/guardian/manifest.json new file mode 100644 index 00000000000..a3e2d9e66ee --- /dev/null +++ b/homeassistant/components/guardian/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "guardian", + "name": "Elexa Guardian", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/guardian", + "requirements": [ + "aioguardian==0.2.3" + ], + "ssdp": [], + "zeroconf": [ + "_api._udp.local." + ], + "homekit": {}, + "dependencies": [], + "codeowners": [ + "@bachya" + ] +} diff --git a/homeassistant/components/guardian/sensor.py b/homeassistant/components/guardian/sensor.py new file mode 100644 index 00000000000..4da200224cf --- /dev/null +++ b/homeassistant/components/guardian/sensor.py @@ -0,0 +1,73 @@ +"""Sensors for the Elexa Guardian integration.""" +from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_FAHRENHEIT, TIME_MINUTES +from homeassistant.core import callback + +from . import Guardian, GuardianEntity +from .const import ( + DATA_CLIENT, + DATA_DIAGNOSTICS, + DATA_SENSOR_STATUS, + DOMAIN, + SENSOR_KIND_TEMPERATURE, + SENSOR_KIND_UPTIME, +) + +SENSORS = [ + ( + SENSOR_KIND_TEMPERATURE, + "Temperature", + DEVICE_CLASS_TEMPERATURE, + None, + TEMP_FAHRENHEIT, + ), + (SENSOR_KIND_UPTIME, "Uptime", None, "mdi:timer", TIME_MINUTES), +] + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Guardian switches based on a config entry.""" + guardian = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] + async_add_entities( + [ + GuardianSensor(guardian, kind, name, device_class, icon, unit) + for kind, name, device_class, icon, unit in SENSORS + ], + True, + ) + + +class GuardianSensor(GuardianEntity): + """Define a generic Guardian sensor.""" + + def __init__( + self, + guardian: Guardian, + kind: str, + name: str, + device_class: str, + icon: str, + unit: str, + ): + """Initialize.""" + super().__init__(guardian, kind, name, device_class, icon) + + self._state = None + self._unit = unit + + @property + def state(self): + """Return the sensor state.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit + + @callback + def _update_from_latest_data(self): + """Update the entity.""" + if self._kind == SENSOR_KIND_TEMPERATURE: + self._state = self._guardian.data[DATA_SENSOR_STATUS]["temperature"] + elif self._kind == SENSOR_KIND_UPTIME: + self._state = self._guardian.data[DATA_DIAGNOSTICS]["uptime"] diff --git a/homeassistant/components/guardian/services.yaml b/homeassistant/components/guardian/services.yaml new file mode 100644 index 00000000000..42565448451 --- /dev/null +++ b/homeassistant/components/guardian/services.yaml @@ -0,0 +1,21 @@ +# Describes the format for available Elexa Guardians services +disable_ap: + description: Disable the device's onboard access point. +enable_ap: + description: Enable the device's onboard access point. +reboot: + description: Reboot the device. +reset_valve_diagnostics: + description: Fully (and irrecoverably) reset all valve diagnostics. +upgrade_firmware: + description: Upgrade the device firmware. + fields: + url: + description: (optional) The URL of the server hosting the firmware file. + example: https://repo.guardiancloud.services/gvc/fw + port: + description: (optional) The port on which the firmware file is served. + example: 443 + filename: + description: (optional) The firmware filename. + example: latest.bin diff --git a/homeassistant/components/guardian/strings.json b/homeassistant/components/guardian/strings.json new file mode 100644 index 00000000000..3f87d3260f4 --- /dev/null +++ b/homeassistant/components/guardian/strings.json @@ -0,0 +1,22 @@ +{ + "title": "Elexa Guardian", + "config": { + "step": { + "user": { + "description": "Configure a local Elexa Guardian device.", + "data": { + "ip_address": "IP Address", + "port": "Port" + } + }, + "zeroconf_confirm": { + "description": "Do you want to set up this Guardian device?" + } + }, + "abort": { + "already_configured": "This Guardian device has already been configured.", + "already_in_progress": "Guardian device configuration is already in process.", + "connection_error": "Failed to connect to the Guardian device." + } + } +} diff --git a/homeassistant/components/guardian/switch.py b/homeassistant/components/guardian/switch.py new file mode 100644 index 00000000000..9917482b5b6 --- /dev/null +++ b/homeassistant/components/guardian/switch.py @@ -0,0 +1,83 @@ +"""Switches for the Elexa Guardian integration.""" +from aioguardian.errors import GuardianError + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import callback + +from . import Guardian, GuardianEntity +from .const import DATA_CLIENT, DATA_VALVE_STATUS, DOMAIN, LOGGER, SWITCH_KIND_VALVE + +ATTR_AVG_CURRENT = "average_current" +ATTR_INST_CURRENT = "instantaneous_current" +ATTR_INST_CURRENT_DDT = "instantaneous_current_ddt" +ATTR_TRAVEL_COUNT = "travel_count" + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Guardian switches based on a config entry.""" + guardian = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] + async_add_entities([GuardianSwitch(guardian)], True) + + +class GuardianSwitch(GuardianEntity, SwitchEntity): + """Define a switch to open/close the Guardian valve.""" + + def __init__(self, guardian: Guardian): + """Initialize.""" + super().__init__(guardian, SWITCH_KIND_VALVE, "Valve", None, "mdi:water") + + self._is_on = True + + @property + def is_on(self): + """Return True if the valve is open.""" + return self._is_on + + @callback + def _update_from_latest_data(self): + """Update the entity.""" + self._is_on = self._guardian.data[DATA_VALVE_STATUS]["state"] in ( + "start_opening", + "opening", + "finish_opening", + "opened", + ) + + self._attrs.update( + { + ATTR_AVG_CURRENT: self._guardian.data[DATA_VALVE_STATUS][ + "average_current" + ], + ATTR_INST_CURRENT: self._guardian.data[DATA_VALVE_STATUS][ + "instantaneous_current" + ], + ATTR_INST_CURRENT_DDT: self._guardian.data[DATA_VALVE_STATUS][ + "instantaneous_current_ddt" + ], + ATTR_TRAVEL_COUNT: self._guardian.data[DATA_VALVE_STATUS][ + "travel_count" + ], + } + ) + + async def async_turn_off(self, **kwargs) -> None: + """Turn the valve off (closed).""" + try: + async with self._guardian.client: + await self._guardian.client.valve.valve_close() + except GuardianError as err: + LOGGER.error("Error while closing the valve: %s", err) + return + + self._is_on = False + + async def async_turn_on(self, **kwargs) -> None: + """Turn the valve on (open).""" + try: + async with self._guardian.client: + await self._guardian.client.valve.valve_open() + except GuardianError as err: + LOGGER.error("Error while opening the valve: %s", err) + return + + self._is_on = True diff --git a/homeassistant/components/guardian/translations/en.json b/homeassistant/components/guardian/translations/en.json new file mode 100644 index 00000000000..40a8a003c12 --- /dev/null +++ b/homeassistant/components/guardian/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "This Guardian device has already been configured.", + "already_in_progress": "Guardian device configuration is already in process.", + "connection_error": "Failed to connect to the Guardian device." + }, + "step": { + "user": { + "data": { + "ip_address": "IP Address", + "port": "Port" + }, + "description": "Configure a local Elexa Guardian device." + }, + "zeroconf_confirm": { + "description": "Do you want to set up this Guardian device?" + } + } + }, + "title": "Elexa Guardian" +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 468fb5c200c..1c5ae1a4d7b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -55,6 +55,7 @@ FLOWS = [ "gogogate2", "gpslogger", "griddy", + "guardian", "hangouts", "harmony", "heos", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 02f316c33bc..5982c23b3bd 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -6,6 +6,9 @@ To update, run python3 -m script.hassfest # fmt: off ZEROCONF = { + "_api._udp.local.": [ + "guardian" + ], "_axis-video._tcp.local.": [ "axis", "doorbird" diff --git a/requirements_all.txt b/requirements_all.txt index 52e6cd81895..bac28658479 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -171,6 +171,9 @@ aiofreepybox==0.0.8 # homeassistant.components.yi aioftp==0.12.0 +# homeassistant.components.guardian +aioguardian==0.2.3 + # homeassistant.components.harmony aioharmony==0.1.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cc16e1e5f39..7a722525be7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -78,6 +78,9 @@ aioesphomeapi==2.6.1 # homeassistant.components.freebox aiofreepybox==0.0.8 +# homeassistant.components.guardian +aioguardian==0.2.3 + # homeassistant.components.harmony aioharmony==0.1.13 diff --git a/tests/components/guardian/__init__.py b/tests/components/guardian/__init__.py new file mode 100644 index 00000000000..8bbb10defb4 --- /dev/null +++ b/tests/components/guardian/__init__.py @@ -0,0 +1 @@ +"""Tests for the Elexa Guardian integration.""" diff --git a/tests/components/guardian/conftest.py b/tests/components/guardian/conftest.py new file mode 100644 index 00000000000..40df9c3cdb1 --- /dev/null +++ b/tests/components/guardian/conftest.py @@ -0,0 +1,17 @@ +"""Define fixtures for Elexa Guardian tests.""" +from asynctest import patch +import pytest + + +@pytest.fixture() +def ping_client(): + """Define a patched client that returns a successful ping response.""" + with patch( + "homeassistant.components.guardian.async_setup_entry", return_value=True + ), patch("aioguardian.client.Client.connect"), patch( + "aioguardian.commands.device.Device.ping", + return_value={"command": 0, "status": "ok", "data": {"uid": "ABCDEF123456"}}, + ), patch( + "aioguardian.client.Client.disconnect" + ): + yield diff --git a/tests/components/guardian/test_config_flow.py b/tests/components/guardian/test_config_flow.py new file mode 100644 index 00000000000..1625e48f1ba --- /dev/null +++ b/tests/components/guardian/test_config_flow.py @@ -0,0 +1,124 @@ +"""Define tests for the Elexa Guardian config flow.""" +from aioguardian.errors import GuardianError +from asynctest import patch + +from homeassistant import data_entry_flow +from homeassistant.components.guardian import CONF_UID, DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT + +from tests.common import MockConfigEntry + + +async def test_duplicate_error(hass): + """Test that errors are shown when duplicate entries are added.""" + conf = {CONF_IP_ADDRESS: "192.168.1.100", CONF_PORT: 7777} + + MockConfigEntry(domain=DOMAIN, unique_id="192.168.1.100", data=conf).add_to_hass( + hass + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_connect_error(hass): + """Test that the config entry errors out if the device cannot connect.""" + conf = {CONF_IP_ADDRESS: "192.168.1.100", CONF_PORT: 7777} + + with patch( + "aioguardian.client.Client.connect", side_effect=GuardianError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_IP_ADDRESS: "cannot_connect"} + + +async def test_step_user(hass, ping_client): + """Test the user step.""" + conf = {CONF_IP_ADDRESS: "192.168.1.100", CONF_PORT: 7777} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Elexa Guardian (192.168.1.100)" + assert result["data"] == { + CONF_IP_ADDRESS: "192.168.1.100", + CONF_PORT: 7777, + CONF_UID: "ABCDEF123456", + } + + +async def test_step_zeroconf(hass, ping_client): + """Test the zeroconf step.""" + zeroconf_data = { + "host": "192.168.1.100", + "port": 7777, + "hostname": "GVC1-ABCD.local.", + "type": "_api._udp.local.", + "name": "Guardian Valve Controller API._api._udp.local.", + "properties": {"_raw": {}}, + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_data + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "zeroconf_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Elexa Guardian (192.168.1.100)" + assert result["data"] == { + CONF_IP_ADDRESS: "192.168.1.100", + CONF_PORT: 7777, + CONF_UID: "ABCDEF123456", + } + + +async def test_step_zeroconf_already_in_progress(hass): + """Test the zeroconf step aborting because it's already in progress.""" + zeroconf_data = { + "host": "192.168.1.100", + "port": 7777, + "hostname": "GVC1-ABCD.local.", + "type": "_api._udp.local.", + "name": "Guardian Valve Controller API._api._udp.local.", + "properties": {"_raw": {}}, + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_data + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "zeroconf_confirm" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_data + ) + assert result["type"] == "abort" + assert result["reason"] == "already_in_progress" + + +async def test_step_zeroconf_no_discovery_info(hass): + """Test the zeroconf step aborting because no discovery info came along.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "connection_error"