mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 08:47:57 +00:00
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:
parent
dc2fe66f29
commit
369745c4cf
@ -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
|
||||
|
@ -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
|
||||
|
364
homeassistant/components/guardian/__init__.py
Normal file
364
homeassistant/components/guardian/__init__.py
Normal 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)
|
62
homeassistant/components/guardian/binary_sensor.py
Normal file
62
homeassistant/components/guardian/binary_sensor.py
Normal 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"]
|
91
homeassistant/components/guardian/config_flow.py
Normal file
91
homeassistant/components/guardian/config_flow.py
Normal 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)
|
25
homeassistant/components/guardian/const.py
Normal file
25
homeassistant/components/guardian/const.py
Normal 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}"
|
18
homeassistant/components/guardian/manifest.json
Normal file
18
homeassistant/components/guardian/manifest.json
Normal 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"
|
||||
]
|
||||
}
|
73
homeassistant/components/guardian/sensor.py
Normal file
73
homeassistant/components/guardian/sensor.py
Normal 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"]
|
21
homeassistant/components/guardian/services.yaml
Normal file
21
homeassistant/components/guardian/services.yaml
Normal 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
|
22
homeassistant/components/guardian/strings.json
Normal file
22
homeassistant/components/guardian/strings.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
83
homeassistant/components/guardian/switch.py
Normal file
83
homeassistant/components/guardian/switch.py
Normal 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
|
22
homeassistant/components/guardian/translations/en.json
Normal file
22
homeassistant/components/guardian/translations/en.json
Normal 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"
|
||||
}
|
@ -55,6 +55,7 @@ FLOWS = [
|
||||
"gogogate2",
|
||||
"gpslogger",
|
||||
"griddy",
|
||||
"guardian",
|
||||
"hangouts",
|
||||
"harmony",
|
||||
"heos",
|
||||
|
@ -6,6 +6,9 @@ To update, run python3 -m script.hassfest
|
||||
# fmt: off
|
||||
|
||||
ZEROCONF = {
|
||||
"_api._udp.local.": [
|
||||
"guardian"
|
||||
],
|
||||
"_axis-video._tcp.local.": [
|
||||
"axis",
|
||||
"doorbird"
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
1
tests/components/guardian/__init__.py
Normal file
1
tests/components/guardian/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the Elexa Guardian integration."""
|
17
tests/components/guardian/conftest.py
Normal file
17
tests/components/guardian/conftest.py
Normal 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
|
124
tests/components/guardian/test_config_flow.py
Normal file
124
tests/components/guardian/test_config_flow.py
Normal 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"
|
Loading…
x
Reference in New Issue
Block a user