From 0c12af347eaa744595200fb2d95f0fac4d0bc07a Mon Sep 17 00:00:00 2001 From: Oliver Acevedo <acevedo.oliver@gmail.com> Date: Fri, 25 Sep 2020 10:55:10 -0500 Subject: [PATCH] Add Omnilogic integration (#40474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Scaffold * Added the en translation * Modified the name * Basic functionality for config flow. * Pulled in enough to validate config flow works. * Update manifest.json * initial data polling (water and air temp sensors) * Adding sensors, debugging update function * polling updates working * support for new data format from library * Updated entity_id, friendly name, conversion for ppm, attributes for hayward display units, MSPSystemID and component systemID * Fixed errors for PR * clean up * Add login exc, check if configured, test login. * Remove debug print. * Black formatting, ran isort, update requirements. * Updated w isort. fix flake8 failures. * Fix flake8 errors * Fixed self.attrs to remove invalid self._ values - small change * Missed on small change - fixing attributes * Updated naming, updated unit of measure, updated icon, bumped omnilog… * Updated to fix flake8 issues in __init__.py and config_flow.py * Updated test_config_flow.py to pass, updated config_flow.py to correct errors in test * Remove comments in preparation for PR * update .covezragerc * Formatting fix * Rewrote sensors to dynamically add all BOWs, pumps, clorinators. Still to do - add CSAD sensors. * Rewrote sensors to dynamically add all BOWs, pumps, clorinators. Still to do - add CSAD sensors. * Added CSAD sensors for pools that have them. * Added CSAD sensors for pools that have them. * Fixed CSAD to not create if blank or don't exist, removed broad except usage to pass linting. * Updated entity naming convention. Fixed linting issues. * Added device association to the back yard / omnilogic system * Removed .0 from ppm values when returning imperial values for salt sensor * Updated to return state = None for water temp when pump is off, handled Chlorinator operatingMode = 2, and added PlatformNotReady check * Corrected exception from Omnilogic library * Bumped omnilogic to 0.3.7. Added alarm sensor/data to sensors. Handle pump off condition for ph and orp sensors. * Bumped omnilogic to 0.3.7. Added alarm sensor/data to sensors. Handle pump off condition for ph and orp sensors. * Bumped omnilogic to 0.3.7. Added alarm sensor/data to sensors. Handle pump off condition for ph and orp sensors. * Removed nested_lookup dependency, bumped omnilogic.py to 0.3.8. * Fixed lint error * Added logging for sensor creation. * Fixed linting errors with logging. * Fixed explicit chaining of raised error. Fixed issue with alarm sensor. * Fixed manifest.json based on feedback. * Fixed self.attrs, should_poll, CoordinatorEntity, SCAN_INTERVAL from comments in PR. * Addressed unique_id, moved data update coordinator, addressed minor other issues from testing * Created main OmniLogic entity for common items, reworked DataUpdateCoordinator to it's own class. * Addressed config_schema not used in __init__.py * Fixed linting issues. * Addressed several comments, still todo - separate sensor classes. * Split the Omnilogic Sensors into separate logical classes for simpler logic. * Fixed snake case lint error for AddAlarms (to add_alarms) * Addressed config_flow issues from comments. * Changed addressed ConfigNotReady issue from comments. * Updated strings.json and generated corrected en.json with translations. * Updated en.json to standard generated file. * Added config_flow tests and updated issue with config_flow on cannot_connect * Added test case for incomplete information entered. * Compressed logic in the sensor classes to reduce duplication. * Updated strings.json for polling_interval, added generic exception handling on config flow. * Removed omnilogic from the .coveragerc omit file. * Updated test_config_flow to follow recommended pattern. * Excluded sensor.py from test coverage tests. * Corected minor issues in test_config_flow from comments * Fixed linting issues on last commits * Fixed linting issues. * Corrected issue when temp state is not available from Omnilogic * Added omnililogic_common.py from .coveragerc to bypass test coverage check. * Return false on Login Exception, handle OmniLogicException in config_flow and in tests. * Handle all exceptions and in config_flow and tests, clarified test naming. * Broke out test cases per comments. * Regenerated en.json file. * Addressed changes from comments in PR. * Added session and bumped API to 0.4.0, addressed other comments from PR. * Addressed entitydata (missed earlier). * Fixed pylint issue * Added test case for options flow in test_config_flow.py * Removed super() and used self when calling methods in current class. * Addressed comments in PR. * Addressed comments in PR. * Updated translations file. * Rewrote data coordinator to output dict for easy searching. * Updated chlorinator unit when chlorinator is on/off only * Scaffold * Added the en translation * Modified the name * Basic functionality for config flow. * Pulled in enough to validate config flow works. * Update manifest.json * initial data polling (water and air temp sensors) * Adding sensors, debugging update function * polling updates working * support for new data format from library * Updated entity_id, friendly name, conversion for ppm, attributes for hayward display units, MSPSystemID and component systemID * Fixed errors for PR * clean up * Add login exc, check if configured, test login. * Remove debug print. * Black formatting, ran isort, update requirements. * Updated w isort. fix flake8 failures. * Fix flake8 errors * Fixed self.attrs to remove invalid self._ values - small change * Missed on small change - fixing attributes * Updated naming, updated unit of measure, updated icon, bumped omnilog… * Updated to fix flake8 issues in __init__.py and config_flow.py * Updated test_config_flow.py to pass, updated config_flow.py to correct errors in test * Remove comments in preparation for PR * update .covezragerc * Formatting fix * Rewrote sensors to dynamically add all BOWs, pumps, clorinators. Still to do - add CSAD sensors. * Rewrote sensors to dynamically add all BOWs, pumps, clorinators. Still to do - add CSAD sensors. * Added CSAD sensors for pools that have them. * Added CSAD sensors for pools that have them. * Fixed CSAD to not create if blank or don't exist, removed broad except usage to pass linting. * Updated entity naming convention. Fixed linting issues. * Added device association to the back yard / omnilogic system * Removed .0 from ppm values when returning imperial values for salt sensor * Updated to return state = None for water temp when pump is off, handled Chlorinator operatingMode = 2, and added PlatformNotReady check * Corrected exception from Omnilogic library * Bumped omnilogic to 0.3.7. Added alarm sensor/data to sensors. Handle pump off condition for ph and orp sensors. * Bumped omnilogic to 0.3.7. Added alarm sensor/data to sensors. Handle pump off condition for ph and orp sensors. * Bumped omnilogic to 0.3.7. Added alarm sensor/data to sensors. Handle pump off condition for ph and orp sensors. * Removed nested_lookup dependency, bumped omnilogic.py to 0.3.8. * Fixed lint error * Added logging for sensor creation. * Fixed linting errors with logging. * Fixed explicit chaining of raised error. Fixed issue with alarm sensor. * Fixed manifest.json based on feedback. * Fixed self.attrs, should_poll, CoordinatorEntity, SCAN_INTERVAL from comments in PR. * Addressed unique_id, moved data update coordinator, addressed minor other issues from testing * Created main OmniLogic entity for common items, reworked DataUpdateCoordinator to it's own class. * Addressed config_schema not used in __init__.py * Fixed linting issues. * Addressed several comments, still todo - separate sensor classes. * Split the Omnilogic Sensors into separate logical classes for simpler logic. * Fixed snake case lint error for AddAlarms (to add_alarms) * Addressed config_flow issues from comments. * Changed addressed ConfigNotReady issue from comments. * Updated strings.json and generated corrected en.json with translations. * Updated en.json to standard generated file. * Added config_flow tests and updated issue with config_flow on cannot_connect * Added test case for incomplete information entered. * Compressed logic in the sensor classes to reduce duplication. * Updated strings.json for polling_interval, added generic exception handling on config flow. * Removed omnilogic from the .coveragerc omit file. * Updated test_config_flow to follow recommended pattern. * Excluded sensor.py from test coverage tests. * Corected minor issues in test_config_flow from comments * Fixed linting issues on last commits * Fixed linting issues. * Corrected issue when temp state is not available from Omnilogic * Added omnililogic_common.py from .coveragerc to bypass test coverage check. * Return false on Login Exception, handle OmniLogicException in config_flow and in tests. * Handle all exceptions and in config_flow and tests, clarified test naming. * Broke out test cases per comments. * Regenerated en.json file. * Addressed changes from comments in PR. * Added session and bumped API to 0.4.0, addressed other comments from PR. * Addressed entitydata (missed earlier). * Fixed pylint issue * Added test case for options flow in test_config_flow.py * Removed super() and used self when calling methods in current class. * Addressed comments in PR. * Addressed comments in PR. * Updated translations file. * Rewrote data coordinator to output dict for easy searching. * Updated chlorinator unit when chlorinator is on/off only * Fixed ORP method not being @property, fixed unique_id potential issue. Does not address comments from PR. * Rewrote coordinator for updated dict structure, rewrote sensors to parse new data structure. * Added alarms as attributes on all entities which support alarm reporting. * Updated SENSOR_TYPES to sensor_types to adhere to snake case in pylint. * Addressed PR comments. * Update homeassistant/components/omnilogic/sensor.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update homeassistant/components/omnilogic/sensor.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update homeassistant/components/omnilogic/sensor.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update homeassistant/components/omnilogic/sensor.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update homeassistant/components/omnilogic/sensor.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update homeassistant/components/omnilogic/sensor.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update homeassistant/components/omnilogic/sensor.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update homeassistant/components/omnilogic/sensor.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update homeassistant/components/omnilogic/sensor.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update homeassistant/components/omnilogic/sensor.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update homeassistant/components/omnilogic/sensor.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update homeassistant/components/omnilogic/sensor.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update homeassistant/components/omnilogic/sensor.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update homeassistant/components/omnilogic/sensor.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update homeassistant/components/omnilogic/sensor.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update homeassistant/components/omnilogic/sensor.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Removed binary sensor conditions (alarms, on/off sensor types) and added ability for multiple guard conditions * Update homeassistant/components/omnilogic/sensor.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Updated per comments in PR for Pump Type and removal of force_update(). * Update homeassistant/components/omnilogic/sensor.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update homeassistant/components/omnilogic/common.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Correctly asserting conditions for the login exception case. * Update .coveragerc Co-authored-by: Martin Hjelmare <marhje52@gmail.com> Co-authored-by: Mike Hershberger <mike.hershberger@gmail.com> Co-authored-by: Chad <54695185+chadlyy@users.noreply.github.com> Co-authored-by: Tim Empringham <tim.empringham@live.ca> Co-authored-by: djtimca <60706061+djtimca@users.noreply.github.com> Co-authored-by: Martin Hjelmare <marhje52@gmail.com> --- .coveragerc | 3 + CODEOWNERS | 1 + .../components/omnilogic/__init__.py | 90 +++++ homeassistant/components/omnilogic/common.py | 157 ++++++++ .../components/omnilogic/config_flow.py | 95 +++++ homeassistant/components/omnilogic/const.py | 29 ++ .../components/omnilogic/manifest.json | 8 + homeassistant/components/omnilogic/sensor.py | 356 ++++++++++++++++++ .../components/omnilogic/strings.json | 30 ++ .../components/omnilogic/translations/en.json | 30 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/omnilogic/__init__.py | 1 + .../components/omnilogic/test_config_flow.py | 147 ++++++++ 15 files changed, 954 insertions(+) create mode 100644 homeassistant/components/omnilogic/__init__.py create mode 100644 homeassistant/components/omnilogic/common.py create mode 100644 homeassistant/components/omnilogic/config_flow.py create mode 100644 homeassistant/components/omnilogic/const.py create mode 100644 homeassistant/components/omnilogic/manifest.json create mode 100644 homeassistant/components/omnilogic/sensor.py create mode 100644 homeassistant/components/omnilogic/strings.json create mode 100644 homeassistant/components/omnilogic/translations/en.json create mode 100644 tests/components/omnilogic/__init__.py create mode 100644 tests/components/omnilogic/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index a5ff56b8d04..6d4c8a66762 100644 --- a/.coveragerc +++ b/.coveragerc @@ -601,6 +601,9 @@ omit = homeassistant/components/oasa_telematics/sensor.py homeassistant/components/ohmconnect/sensor.py homeassistant/components/ombi/* + homeassistant/components/omnilogic/__init__.py + homeassistant/components/omnilogic/common.py + homeassistant/components/omnilogic/sensor.py homeassistant/components/onewire/sensor.py homeassistant/components/onkyo/media_player.py homeassistant/components/onvif/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index 0f07edf16ef..05c3dcf5087 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -301,6 +301,7 @@ homeassistant/components/nzbget/* @chriscla homeassistant/components/obihai/* @dshokouhi homeassistant/components/ohmconnect/* @robbiet480 homeassistant/components/ombi/* @larssont +homeassistant/components/omnilogic/* @oliver84 @djtimca @gentoosu homeassistant/components/onboarding/* @home-assistant/core homeassistant/components/onewire/* @garbled1 homeassistant/components/onvif/* @hunterjm diff --git a/homeassistant/components/omnilogic/__init__.py b/homeassistant/components/omnilogic/__init__.py new file mode 100644 index 00000000000..ff4dd93a0e1 --- /dev/null +++ b/homeassistant/components/omnilogic/__init__.py @@ -0,0 +1,90 @@ +"""The Omnilogic integration.""" +import asyncio +import logging + +from omnilogic import LoginException, OmniLogic, OmniLogicException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client + +from .common import OmniLogicUpdateCoordinator +from .const import CONF_SCAN_INTERVAL, COORDINATOR, DOMAIN, OMNI_API + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["sensor"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Omnilogic component.""" + hass.data.setdefault(DOMAIN, {}) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Omnilogic from a config entry.""" + + conf = entry.data + username = conf[CONF_USERNAME] + password = conf[CONF_PASSWORD] + + polling_interval = 6 + if CONF_SCAN_INTERVAL in conf: + polling_interval = conf[CONF_SCAN_INTERVAL] + + session = aiohttp_client.async_get_clientsession(hass) + + api = OmniLogic(username, password, session) + + try: + await api.connect() + await api.get_telemetry_data() + except LoginException as error: + _LOGGER.error("Login Failed: %s", error) + return False + except OmniLogicException as error: + _LOGGER.debug("OmniLogic API error: %s", error) + raise ConfigEntryNotReady from error + + coordinator = OmniLogicUpdateCoordinator( + hass=hass, + api=api, + name="Omnilogic", + polling_interval=polling_interval, + ) + await coordinator.async_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + hass.data[DOMAIN][entry.entry_id] = { + COORDINATOR: coordinator, + OMNI_API: api, + } + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + 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].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/omnilogic/common.py b/homeassistant/components/omnilogic/common.py new file mode 100644 index 00000000000..791d81b6757 --- /dev/null +++ b/homeassistant/components/omnilogic/common.py @@ -0,0 +1,157 @@ +"""Common classes and elements for Omnilogic Integration.""" + +from datetime import timedelta +import logging + +from omnilogic import OmniLogicException + +from homeassistant.const import ATTR_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from .const import ( + ALL_ITEM_KINDS, + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +class OmniLogicUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching update data from single endpoint.""" + + def __init__( + self, + hass: HomeAssistant, + api: str, + name: str, + polling_interval: int, + ): + """Initialize the global Omnilogic data updater.""" + self.api = api + + super().__init__( + hass=hass, + logger=_LOGGER, + name=name, + update_interval=timedelta(seconds=polling_interval), + ) + + async def _async_update_data(self): + """Fetch data from OmniLogic.""" + try: + data = await self.api.get_telemetry_data() + + except OmniLogicException as error: + raise UpdateFailed(f"Error updating from OmniLogic: {error}") from error + + parsed_data = {} + + def get_item_data(item, item_kind, current_id, data): + """Get data per kind of Omnilogic API item.""" + if isinstance(item, list): + for single_item in item: + data = get_item_data(single_item, item_kind, current_id, data) + + if "systemId" in item: + system_id = item["systemId"] + current_id = current_id + (item_kind, system_id) + data[current_id] = item + + for kind in ALL_ITEM_KINDS: + if kind in item: + data = get_item_data(item[kind], kind, current_id, data) + + return data + + parsed_data = get_item_data(data, "Backyard", (), parsed_data) + + return parsed_data + + +class OmniLogicEntity(CoordinatorEntity): + """Defines the base OmniLogic entity.""" + + def __init__( + self, + coordinator: OmniLogicUpdateCoordinator, + kind: str, + name: str, + item_id: tuple, + icon: str, + ): + """Initialize the OmniLogic Entity.""" + super().__init__(coordinator) + + bow_id = None + entity_data = coordinator.data[item_id] + + backyard_id = item_id[:2] + if len(item_id) == 6: + bow_id = item_id[:4] + + msp_system_id = coordinator.data[backyard_id]["systemId"] + entity_friendly_name = f"{coordinator.data[backyard_id]['BackyardName']} " + unique_id = f"{msp_system_id}" + + if bow_id is not None: + unique_id = f"{unique_id}_{coordinator.data[bow_id]['systemId']}" + entity_friendly_name = ( + f"{entity_friendly_name}{coordinator.data[bow_id]['Name']} " + ) + + unique_id = f"{unique_id}_{coordinator.data[item_id]['systemId']}_{kind}" + + if entity_data.get("Name") is not None: + entity_friendly_name = f"{entity_friendly_name} {entity_data['Name']}" + + entity_friendly_name = f"{entity_friendly_name} {name}" + + unique_id = unique_id.replace(" ", "_") + + self._kind = kind + self._name = entity_friendly_name + self._unique_id = unique_id + self._item_id = item_id + self._icon = icon + self._attrs = {} + self._msp_system_id = msp_system_id + self._backyard_name = coordinator.data[backyard_id]["BackyardName"] + + @property + def unique_id(self) -> str: + """Return a unique, Home Assistant friendly identifier for this entity.""" + return self._unique_id + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def icon(self): + """Return the icon for the entity.""" + return self._icon + + @property + def device_state_attributes(self): + """Return the attributes.""" + return self._attrs + + @property + def device_info(self): + """Define the device as back yard/MSP System.""" + + return { + ATTR_IDENTIFIERS: {(DOMAIN, self._msp_system_id)}, + ATTR_NAME: self._backyard_name, + ATTR_MANUFACTURER: "Hayward", + ATTR_MODEL: "OmniLogic", + } diff --git a/homeassistant/components/omnilogic/config_flow.py b/homeassistant/components/omnilogic/config_flow.py new file mode 100644 index 00000000000..641ec5a8d94 --- /dev/null +++ b/homeassistant/components/omnilogic/config_flow.py @@ -0,0 +1,95 @@ +"""Config flow for Omnilogic integration.""" +import logging + +from omnilogic import LoginException, OmniLogic, OmniLogicException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client + +from .const import CONF_SCAN_INTERVAL, DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Omnilogic.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + + config_entry = self.hass.config_entries.async_entries(DOMAIN) + if config_entry: + return self.async_abort(reason="single_instance_allowed") + + errors = {} + + if user_input is not None: + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + + session = aiohttp_client.async_get_clientsession(self.hass) + omni = OmniLogic(username, password, session) + + try: + await omni.connect() + except LoginException: + errors["base"] = "invalid_auth" + except OmniLogicException: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(user_input["username"]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title="Omnilogic", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle Omnilogic client options.""" + + def __init__(self, config_entry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage options.""" + + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_SCAN_INTERVAL, + default=6, + ): int, + } + ), + ) diff --git a/homeassistant/components/omnilogic/const.py b/homeassistant/components/omnilogic/const.py new file mode 100644 index 00000000000..a57ef2b062a --- /dev/null +++ b/homeassistant/components/omnilogic/const.py @@ -0,0 +1,29 @@ +"""Constants for the Omnilogic integration.""" + +DOMAIN = "omnilogic" +CONF_SCAN_INTERVAL = "polling_interval" +COORDINATOR = "coordinator" +OMNI_API = "omni_api" +ATTR_IDENTIFIERS = "identifiers" +ATTR_MANUFACTURER = "manufacturer" +ATTR_MODEL = "model" + +PUMP_TYPES = { + "FMT_VARIABLE_SPEED_PUMP": "VARIABLE", + "FMT_SINGLE_SPEED": "SINGLE", + "FMT_DUAL_SPEED": "DUAL", + "PMP_VARIABLE_SPEED_PUMP": "VARIABLE", + "PMP_SINGLE_SPEED": "SINGLE", + "PMP_DUAL_SPEED": "DUAL", +} + +ALL_ITEM_KINDS = { + "BOWS", + "Filter", + "Heater", + "Chlorinator", + "CSAD", + "Lights", + "Relays", + "Pumps", +} diff --git a/homeassistant/components/omnilogic/manifest.json b/homeassistant/components/omnilogic/manifest.json new file mode 100644 index 00000000000..468b48d620a --- /dev/null +++ b/homeassistant/components/omnilogic/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "omnilogic", + "name": "Hayward Omnilogic", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/omnilogic", + "requirements": ["omnilogic==0.4.0"], + "codeowners": ["@oliver84","@djtimca","@gentoosu"] +} diff --git a/homeassistant/components/omnilogic/sensor.py b/homeassistant/components/omnilogic/sensor.py new file mode 100644 index 00000000000..f4bb0f45d5e --- /dev/null +++ b/homeassistant/components/omnilogic/sensor.py @@ -0,0 +1,356 @@ +"""Definition and setup of the Omnilogic Sensors for Home Assistant.""" + +import logging + +from homeassistant.components.sensor import DEVICE_CLASS_TEMPERATURE +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + MASS_GRAMS, + PERCENTAGE, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + VOLUME_LITERS, +) + +from .common import OmniLogicEntity, OmniLogicUpdateCoordinator +from .const import COORDINATOR, DOMAIN, PUMP_TYPES + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the sensor platform.""" + + coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] + entities = [] + + for item_id, item in coordinator.data.items(): + id_len = len(item_id) + item_kind = item_id[-2] + entity_settings = SENSOR_TYPES.get((id_len, item_kind)) + + if not entity_settings: + continue + + for entity_setting in entity_settings: + for state_key, entity_class in entity_setting["entity_classes"].items(): + if state_key not in item: + continue + + guard = False + for guard_condition in entity_setting["guard_condition"]: + if guard_condition and all( + item.get(guard_key) == guard_value + for guard_key, guard_value in guard_condition.items() + ): + guard = True + + if guard: + continue + + entity = entity_class( + coordinator=coordinator, + state_key=state_key, + name=entity_setting["name"], + kind=entity_setting["kind"], + item_id=item_id, + device_class=entity_setting["device_class"], + icon=entity_setting["icon"], + unit=entity_setting["unit"], + ) + + entities.append(entity) + + async_add_entities(entities) + + +class OmnilogicSensor(OmniLogicEntity): + """Defines an Omnilogic sensor entity.""" + + def __init__( + self, + coordinator: OmniLogicUpdateCoordinator, + kind: str, + name: str, + device_class: str, + icon: str, + unit: str, + item_id: tuple, + state_key: str, + ): + """Initialize Entities.""" + super().__init__( + coordinator=coordinator, + kind=kind, + name=name, + item_id=item_id, + icon=icon, + ) + + backyard_id = item_id[:2] + unit_type = coordinator.data[backyard_id].get("Unit-of-Measurement") + + self._unit_type = unit_type + self._device_class = device_class + self._unit = unit + self._state_key = state_key + + @property + def device_class(self): + """Return the device class of the entity.""" + return self._device_class + + @property + def unit_of_measurement(self): + """Return the right unit of measure.""" + return self._unit + + +class OmniLogicTemperatureSensor(OmnilogicSensor): + """Define an OmniLogic Temperature (Air/Water) Sensor.""" + + @property + def state(self): + """Return the state for the temperature sensor.""" + sensor_data = self.coordinator.data[self._item_id][self._state_key] + + hayward_state = sensor_data + hayward_unit_of_measure = TEMP_FAHRENHEIT + state = sensor_data + + if self._unit_type == "Metric": + hayward_state = round((hayward_state - 32) * 5 / 9, 1) + hayward_unit_of_measure = TEMP_CELSIUS + + if int(sensor_data) == -1: + hayward_state = None + state = None + + self._attrs["hayward_temperature"] = hayward_state + self._attrs["hayward_unit_of_measure"] = hayward_unit_of_measure + + self._unit = TEMP_FAHRENHEIT + + return state + + +class OmniLogicPumpSpeedSensor(OmnilogicSensor): + """Define an OmniLogic Pump Speed Sensor.""" + + @property + def state(self): + """Return the state for the pump speed sensor.""" + + pump_type = PUMP_TYPES[self.coordinator.data[self._item_id]["Filter-Type"]] + pump_speed = self.coordinator.data[self._item_id][self._state_key] + + if pump_type == "VARIABLE": + self._unit = PERCENTAGE + state = pump_speed + elif pump_type == "DUAL": + if pump_speed == 0: + state = "off" + elif pump_speed == self.coordinator.data[self._item_id].get( + "Min-Pump-Speed" + ): + state = "low" + elif pump_speed == self.coordinator.data[self._item_id].get( + "Max-Pump-Speed" + ): + state = "high" + + self._attrs["pump_type"] = pump_type + + return state + + +class OmniLogicSaltLevelSensor(OmnilogicSensor): + """Define an OmniLogic Salt Level Sensor.""" + + @property + def state(self): + """Return the state for the salt level sensor.""" + + salt_return = self.coordinator.data[self._item_id][self._state_key] + unit_of_measurement = self._unit + + if self._unit_type == "Metric": + salt_return = round(salt_return / 1000, 2) + unit_of_measurement = f"{MASS_GRAMS}/{VOLUME_LITERS}" + + self._unit = unit_of_measurement + + return salt_return + + +class OmniLogicChlorinatorSensor(OmnilogicSensor): + """Define an OmniLogic Chlorinator Sensor.""" + + @property + def state(self): + """Return the state for the chlorinator sensor.""" + state = self.coordinator.data[self._item_id][self._state_key] + + return state + + +class OmniLogicPHSensor(OmnilogicSensor): + """Define an OmniLogic pH Sensor.""" + + @property + def state(self): + """Return the state for the pH sensor.""" + + ph_state = self.coordinator.data[self._item_id][self._state_key] + + if ph_state == 0: + ph_state = None + + return ph_state + + +class OmniLogicORPSensor(OmnilogicSensor): + """Define an OmniLogic ORP Sensor.""" + + def __init__( + self, + coordinator: OmniLogicUpdateCoordinator, + state_key: str, + name: str, + kind: str, + item_id: tuple, + device_class: str, + icon: str, + unit: str, + ): + """Initialize the sensor.""" + super().__init__( + coordinator=coordinator, + kind=kind, + name=name, + device_class=device_class, + icon=icon, + unit=unit, + item_id=item_id, + state_key=state_key, + ) + + @property + def state(self): + """Return the state for the ORP sensor.""" + + orp_state = self.coordinator.data[self._item_id][self._state_key] + + if orp_state == -1: + orp_state = None + + return orp_state + + +SENSOR_TYPES = { + (2, "Backyard"): [ + { + "entity_classes": {"airTemp": OmniLogicTemperatureSensor}, + "name": "Air Temperature", + "kind": "air_temperature", + "device_class": DEVICE_CLASS_TEMPERATURE, + "icon": None, + "unit": TEMP_FAHRENHEIT, + "guard_condition": [{}], + }, + ], + (4, "BOWS"): [ + { + "entity_classes": {"waterTemp": OmniLogicTemperatureSensor}, + "name": "Water Temperature", + "kind": "water_temperature", + "device_class": DEVICE_CLASS_TEMPERATURE, + "icon": None, + "unit": TEMP_FAHRENHEIT, + "guard_condition": [{}], + }, + ], + (6, "Filter"): [ + { + "entity_classes": {"filterSpeed": OmniLogicPumpSpeedSensor}, + "name": "Speed", + "kind": "filter_pump_speed", + "device_class": None, + "icon": "mdi:speedometer", + "unit": PERCENTAGE, + "guard_condition": [ + {"Type": "FMT_SINGLE_SPEED"}, + ], + }, + ], + (6, "Pumps"): [ + { + "entity_classes": {"pumpSpeed": OmniLogicPumpSpeedSensor}, + "name": "Pump Speed", + "kind": "pump_speed", + "device_class": None, + "icon": "mdi:speedometer", + "unit": PERCENTAGE, + "guard_condition": [ + {"Type": "PMP_SINGLE_SPEED"}, + ], + }, + ], + (6, "Chlorinator"): [ + { + "entity_classes": {"Timed-Percent": OmniLogicChlorinatorSensor}, + "name": "Setting", + "kind": "chlorinator", + "device_class": None, + "icon": "mdi:gauge", + "unit": PERCENTAGE, + "guard_condition": [ + { + "Shared-Type": "BOW_SHARED_EQUIPMENT", + "status": "0", + }, + { + "operatingMode": "2", + }, + ], + }, + { + "entity_classes": {"avgSaltLevel": OmniLogicSaltLevelSensor}, + "name": "Salt Level", + "kind": "salt_level", + "device_class": None, + "icon": "mdi:gauge", + "unit": CONCENTRATION_PARTS_PER_MILLION, + "guard_condition": [ + { + "Shared-Type": "BOW_SHARED_EQUIPMENT", + "status": "0", + }, + ], + }, + ], + (6, "CSAD"): [ + { + "entity_classes": {"ph": OmniLogicPHSensor}, + "name": "pH", + "kind": "csad_ph", + "device_class": None, + "icon": "mdi:gauge", + "unit": "pH", + "guard_condition": [ + {"ph": ""}, + ], + }, + { + "entity_classes": {"orp": OmniLogicORPSensor}, + "name": "ORP", + "kind": "csad_orp", + "device_class": None, + "icon": "mdi:gauge", + "unit": "mV", + "guard_condition": [ + {"orp": ""}, + ], + }, + ], +} diff --git a/homeassistant/components/omnilogic/strings.json b/homeassistant/components/omnilogic/strings.json new file mode 100644 index 00000000000..285bc29b802 --- /dev/null +++ b/homeassistant/components/omnilogic/strings.json @@ -0,0 +1,30 @@ +{ + "title": "Omnilogic", + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "polling_interval": "Polling interval (in seconds)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/omnilogic/translations/en.json b/homeassistant/components/omnilogic/translations/en.json new file mode 100644 index 00000000000..8dde40be8fc --- /dev/null +++ b/homeassistant/components/omnilogic/translations/en.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "polling_interval": "Polling interval (in seconds)" + } + } + } + }, + "title": "Omnilogic" +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index fae053ac1a1..55e6bf2eafe 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -127,6 +127,7 @@ FLOWS = [ "nut", "nws", "nzbget", + "omnilogic", "onvif", "opentherm_gw", "openuv", diff --git a/requirements_all.txt b/requirements_all.txt index 7f3c6f2c012..0032fa2fdc3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1012,6 +1012,9 @@ oauth2client==4.0.0 # homeassistant.components.oem oemthermostat==1.1 +# homeassistant.components.omnilogic +omnilogic==0.4.0 + # homeassistant.components.onkyo onkyo-eiscp==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fb596cf04e6..e0d14bd9ea2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -483,6 +483,9 @@ numpy==1.19.2 # homeassistant.components.google oauth2client==4.0.0 +# homeassistant.components.omnilogic +omnilogic==0.4.0 + # homeassistant.components.onvif onvif-zeep-async==0.5.0 diff --git a/tests/components/omnilogic/__init__.py b/tests/components/omnilogic/__init__.py new file mode 100644 index 00000000000..b7b8008abaa --- /dev/null +++ b/tests/components/omnilogic/__init__.py @@ -0,0 +1 @@ +"""Tests for the Omnilogic integration.""" diff --git a/tests/components/omnilogic/test_config_flow.py b/tests/components/omnilogic/test_config_flow.py new file mode 100644 index 00000000000..ef29ff9f674 --- /dev/null +++ b/tests/components/omnilogic/test_config_flow.py @@ -0,0 +1,147 @@ +"""Test the Omnilogic config flow.""" +from omnilogic import LoginException, OmniLogicException + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.omnilogic.const import DOMAIN + +from tests.async_mock import patch +from tests.common import MockConfigEntry + +DATA = {"username": "test-username", "password": "test-password"} + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.omnilogic.config_flow.OmniLogic.connect", + return_value=True, + ), patch( + "homeassistant.components.omnilogic.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.omnilogic.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + DATA, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "Omnilogic" + assert result2["data"] == DATA + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_already_configured(hass): + """Test config flow when Omnilogic component is already setup.""" + MockConfigEntry(domain="omnilogic", data=DATA).add_to_hass(hass) + + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "abort" + assert result["reason"] == "single_instance_allowed" + + +async def test_with_invalid_credentials(hass): + """Test with invalid credentials.""" + + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.omnilogic.OmniLogic.connect", + side_effect=LoginException, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + DATA, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """Test if invalid response or no connection returned from Hayward.""" + + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.omnilogic.OmniLogic.connect", + side_effect=OmniLogicException, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + DATA, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_with_unknown_error(hass): + """Test with unknown error response from Hayward.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.omnilogic.OmniLogic.connect", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + DATA, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {"base": "unknown"} + + +async def test_option_flow(hass): + """Test option flow.""" + entry = MockConfigEntry(domain=DOMAIN, data=DATA) + entry.add_to_hass(hass) + + assert not entry.options + + with patch( + "homeassistant.components.omnilogic.async_setup_entry", return_value=True + ): + result = await hass.config_entries.options.async_init( + entry.entry_id, + data=None, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"polling_interval": 9}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "" + assert result["data"]["polling_interval"] == 9 -- GitLab