diff --git a/.coveragerc b/.coveragerc index a5ff56b8d0419bb8a6d58fe87a001bf83ac6eef2..6d4c8a667623d2d16b5105216c8c8b2edef4f7d1 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 0f07edf16ef02810a244fc179a2f42baf0241bb4..05c3dcf50874fc3156c6dd2c4efbb73ef9f0518b 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 0000000000000000000000000000000000000000..ff4dd93a0e1537c05e8943d3cf2874a9411e2488 --- /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 0000000000000000000000000000000000000000..791d81b6757a53c993bf3599baab63b3355129e8 --- /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 0000000000000000000000000000000000000000..641ec5a8d94bfd2dcb2de3e49c44713456e857c2 --- /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 0000000000000000000000000000000000000000..a57ef2b062a9281a8f899ffd850d3afd47e586ff --- /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 0000000000000000000000000000000000000000..468b48d620a9d6693369006ce78b662947737dbb --- /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 0000000000000000000000000000000000000000..f4bb0f45d5ed8c187ef120dc5b70e16cb6e6fc26 --- /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 0000000000000000000000000000000000000000..285bc29b802c9e783dea9dbded71e196d96856ef --- /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 0000000000000000000000000000000000000000..8dde40be8fc017494e959c9b5772127090cf44ac --- /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 fae053ac1a14ef2cdd480cc7adf2adf6ca011dd7..55e6bf2eafea7ee93dc206ab4fbedac30f4ac6c9 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 7f3c6f2c012f307064f40bc9c40517eabac11407..0032fa2fdc383370a830bd0817012bbad7a4a098 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 fb596cf04e6a3fb6ec82e6c8c9b5194842f777db..e0d14bd9ea21c966773bde4b6ab75a0ca113aeac 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 0000000000000000000000000000000000000000..b7b8008abaa5dcedafe77cb9fa69fc6038cbf059 --- /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 0000000000000000000000000000000000000000..ef29ff9f67439c031744533df738502f37baf15b --- /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