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
This commit is contained in:
Aaron Bach 2020-05-26 07:47:25 -06:00 committed by GitHub
parent dc2fe66f29
commit 369745c4cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 938 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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"]

View File

@ -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)

View File

@ -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}"

View File

@ -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"
]
}

View File

@ -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"]

View File

@ -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

View File

@ -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."
}
}
}

View File

@ -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

View File

@ -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"
}

View File

@ -55,6 +55,7 @@ FLOWS = [
"gogogate2",
"gpslogger",
"griddy",
"guardian",
"hangouts",
"harmony",
"heos",

View File

@ -6,6 +6,9 @@ To update, run python3 -m script.hassfest
# fmt: off
ZEROCONF = {
"_api._udp.local.": [
"guardian"
],
"_axis-video._tcp.local.": [
"axis",
"doorbird"

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the Elexa Guardian integration."""

View File

@ -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

View File

@ -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"