New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add support for Elexa Guardian water valve controllers #34627
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
3d0e489
Add support for Elexa Guardian water valve controllers
bachya 62b4c6d
Zeroconf + cleanup
bachya ab89252
Sensors and services
bachya 81e7627
API registration
bachya a3c38d5
Service bug fixes
bachya 1ba069d
Fix bug in cleanup
bachya df29bec
Tests and coverage
bachya ff94e4e
Fix incorrect service description
bachya 799486d
Bump aioguardian
bachya 9000583
Bump aioguardian to 0.2.2
bachya 32739fb
Bump aioguardian to 0.2.3
bachya 40431ea
Proper entity inheritance
bachya 06341f3
Give device a proper name
bachya 9cc4180
Code review
bachya File filter
Filter by extension
Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Validating CODEOWNERS rules …
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why are we not using our data update coordinator helper? It looks like this class is reimplementing many things from our coordinator.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@MartinHjelmare It makes use of multiple API calls and chooses which API calls to use based on how many entities have registered interest in them. I didn't want to create multiple data coordinators when I could create one object that handles everything.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it would have been a lot easier to use one coordinator per API call. This is complicated logic that we don't want to duplicate in each integration.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I’m not sure I agree it’s easier. Let’s take the convo offline.