diff --git a/.coveragerc b/.coveragerc index d6bdfb9b09181e..58218da28cb9be 100644 --- a/.coveragerc +++ b/.coveragerc @@ -517,6 +517,7 @@ omit = homeassistant/components/lcn/sensor.py homeassistant/components/lcn/services.py homeassistant/components/lcn/switch.py + homeassistant/components/leviosa_shades/cover.py homeassistant/components/lg_netcast/media_player.py homeassistant/components/lg_soundbar/media_player.py homeassistant/components/life360/* diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index efcc0380748116..fbec0bfdf3d72b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -6,7 +6,7 @@ "postStartCommand": "script/bootstrap", "containerEnv": { "DEVCONTAINER": "1" }, "appPort": 8123, - "runArgs": ["-e", "GIT_EDITOR=code --wait"], + "runArgs": ["-e", "GIT_EDITOR=code --wait", "--network=host"], "extensions": [ "ms-python.vscode-pylance", "visualstudioexptteam.vscodeintellicode", diff --git a/CODEOWNERS b/CODEOWNERS index 263e5337c588b5..d1177b8efccd50 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -251,6 +251,7 @@ homeassistant/components/kulersky/* @emlove homeassistant/components/lametric/* @robbiet480 homeassistant/components/launch_library/* @ludeeus homeassistant/components/lcn/* @alengwenus +homeassistant/components/leviosa_shades/* @altersis homeassistant/components/life360/* @pnbruckner homeassistant/components/linux_battery/* @fabaff homeassistant/components/litejet/* @joncar diff --git a/homeassistant/components/leviosa_shades/__init__.py b/homeassistant/components/leviosa_shades/__init__.py new file mode 100644 index 00000000000000..138828ace58200 --- /dev/null +++ b/homeassistant/components/leviosa_shades/__init__.py @@ -0,0 +1,40 @@ +"""The Leviosa shades Zone integration.""" +import asyncio +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["cover"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Leviosa shades Zone component.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Leviosa shades Zone from a config entry.""" + + 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 + ] + ) + ) + + return unload_ok diff --git a/homeassistant/components/leviosa_shades/config_flow.py b/homeassistant/components/leviosa_shades/config_flow.py new file mode 100644 index 00000000000000..878d72809efd2e --- /dev/null +++ b/homeassistant/components/leviosa_shades/config_flow.py @@ -0,0 +1,183 @@ +"""Config flow for Leviosa shades Zone.""" +import logging + +from aioleviosa import LeviosaZoneHub, discover_leviosa_zones +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + BLIND_GROUPS, + DEVICE_FW_V, + DEVICE_MAC, + DOMAIN, + GROUP1_NAME, + GROUP2_NAME, + GROUP3_NAME, + GROUP4_NAME, + GROUP5_NAME, + GROUP6_NAME, + HUB_EXCEPTIONS, +) + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): str, + vol.Required(GROUP1_NAME): str, + vol.Optional(GROUP2_NAME): str, + vol.Optional(GROUP3_NAME): str, + vol.Optional(GROUP4_NAME): str, + vol.Optional(GROUP5_NAME): str, + vol.Optional(GROUP6_NAME): str, + } +) + + +async def validate_zone(hass: core.HomeAssistant, hub_address): + """Ensure the Leviosa Zone is up and running and get the FW version.""" + try: + _LOGGER.debug("Contacting Zone: %s", hub_address) + hub = LeviosaZoneHub( + hub_ip=hub_address, + hub_name="tempZone", + websession=async_get_clientsession(hass), + ) + await hub.getHubInfo() + _LOGGER.debug("Zone firmware v: %s", hub.fwVer) + except HUB_EXCEPTIONS as err: + raise CannotConnect from err + if hub.fwVer == "invalid": + raise CannotConnect + return hub.fwVer + + +@config_entries.HANDLERS.register(DOMAIN) +class LeviosaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Manages the interaction with user when a Leviosa Zone needs to be setup.""" + + # The schema version below will be used by Home Assistant to determine + # if a call to the migrate method is needed; this is not implemented + # as of March 2021 + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + GROUPS = [ + GROUP1_NAME, + GROUP2_NAME, + GROUP3_NAME, + GROUP4_NAME, + GROUP5_NAME, + GROUP6_NAME, + ] + + def __init__(self): + """Initialize the Motion Blinds flow.""" + + self._host = None + self._host_uid = None + self._devices = {} + + async def async_step_user(self, user_input=None): + """Perform discovery and present an input screen for each Zone discovered.""" + + _LOGGER.debug("Looking for Leviosa Zone HUBs") + self._devices = await discover_leviosa_zones() + _LOGGER.debug("Found %d Zones advertising on the network ", len(self._devices)) + devs_2b_removed = [] + for dev_key in self._devices.keys(): + if self._host_already_configured(self._devices[dev_key]): + devs_2b_removed.append(dev_key) + for dev in devs_2b_removed: + self._devices.pop(dev) + _LOGGER.debug("There are %d Zones can be included in Hass", len(self._devices)) + zones = list(self._devices.keys()) + if len(zones) == 1: + self._host = self._devices[zones[0]] + self._host_uid = zones[0] + return await self.async_step_connect() + if len(zones) > 1: + return await self.async_step_select() + + return self.async_abort(reason="no_new_devs") + + async def async_step_select(self, user_input=None): + """Handle multiple motion gateways found.""" + if user_input is not None: + self._host = user_input["select_ip"] + vals = list(self._devices.values()) + idx_of_ip = vals.index(self._host) + keys = list(self._devices.keys()) + self._host_uid = keys[idx_of_ip] + return await self.async_step_connect() + + select_schema = vol.Schema( + {vol.Required("select_ip"): vol.In(list(self._devices.values()))} + ) + _LOGGER.debug("Select Zone to include in Hass, %s choices", len(self._devices)) + return self.async_show_form(step_id="select", data_schema=select_schema) + + async def async_step_connect(self, user_input=None): + """Allow user to enter details for a Leviosa Zone.""" + errors = {} + if user_input is not None: + _LOGGER.debug( + "Connect step - validate and save [%s] @%s", + self._host_uid, + self._host, + ) + for i in user_input: + _LOGGER.debug("UI %s -> %s", i, user_input[i]) + + # if self._host_already_configured(self._host): + # return self.async_abort(reason="device_already_configured") + try: + fw_ver = await validate_zone(self.hass, self._host) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + if not errors: + _LOGGER.debug("Saving Integration data") + await self.async_set_unique_id(self._host_uid) + bgs = [] + bgs.append("All " + user_input[CONF_NAME]) + for group in self.GROUPS: # We'll create a list of valid groups + if user_input.get(group, "") != "": + bgs.append(user_input[group]) + else: + break + return self.async_create_entry( + title=user_input[CONF_NAME], + data={ + CONF_HOST: self._host, + DEVICE_FW_V: fw_ver, + DEVICE_MAC: self._host_uid[-12:], + BLIND_GROUPS: bgs, + }, + ) + + _LOGGER.debug("Connect step - display UI for %s", self._host) + return self.async_show_form( + step_id="connect", + data_schema=DATA_SCHEMA, + errors=errors, + description_placeholders={"ip_add": self._host}, + ) + + def _host_already_configured(self, host): + """See if we already have a hub with the host address configured.""" + _LOGGER.debug("Checking if HOST was already configured") + existing_hosts = { + entry.data.get(CONF_HOST) + for entry in self._async_current_entries() + if CONF_HOST in entry.data + } + return host in existing_hosts + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/leviosa_shades/const.py b/homeassistant/components/leviosa_shades/const.py new file mode 100644 index 00000000000000..63db2c2a5e5374 --- /dev/null +++ b/homeassistant/components/leviosa_shades/const.py @@ -0,0 +1,37 @@ +"""Constants for the Leviosa Motor Shades Zone integration.""" + +import asyncio + +from aiohttp.client_exceptions import ( + ServerConnectionError, + ServerDisconnectedError, + ServerTimeoutError, +) + +DOMAIN = "leviosa_shades" + +MANUFACTURER = "Leviosa Motor Shades LLC" +MODEL = "Zone Hub" +DEVICE_NAME = "device_name" +DEVICE_FW_V = "firmware" +DEVICE_MAC = "device_mac" + +BLIND_GROUPS = "blind_groups" +GROUP1_NAME = "grp1_name" +GROUP2_NAME = "grp2_name" +GROUP3_NAME = "grp3_name" +GROUP4_NAME = "grp4_name" +GROUP5_NAME = "grp5_name" +GROUP6_NAME = "grp6_name" + +SERVICE_NEXT_DOWN_POS = "next_down_pos" +SERVICE_NEXT_UP_POS = "next_up_pos" + +CANNOTCONNECT = "cannot_connect" + +HUB_EXCEPTIONS = ( + ServerDisconnectedError, + asyncio.TimeoutError, + ServerConnectionError, + ServerTimeoutError, +) diff --git a/homeassistant/components/leviosa_shades/cover.py b/homeassistant/components/leviosa_shades/cover.py new file mode 100644 index 00000000000000..dee9252ae7a0a4 --- /dev/null +++ b/homeassistant/components/leviosa_shades/cover.py @@ -0,0 +1,183 @@ +"""The Leviosa Shades Zone base entity.""" +import logging + +from aioleviosa import LeviosaShadeGroup as tShadeGroup, LeviosaZoneHub as tZoneHub +import voluptuous as vol + +from homeassistant.components.cover import ( + DEVICE_CLASS_SHADE, + SUPPORT_CLOSE, + SUPPORT_OPEN, + SUPPORT_STOP, + CoverEntity, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + BLIND_GROUPS, + DOMAIN, + MANUFACTURER, + MODEL, + SERVICE_NEXT_DOWN_POS, + SERVICE_NEXT_UP_POS, +) + +_LOGGER = logging.getLogger(__name__) + +# Estimated time it takes to complete a transition +# from one state to another +TRANSITION_COMPLETE_DURATION = 30 +PARALLEL_UPDATES = 1 + +COVER_NEXT_POS_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the Leviosa shade groups.""" + _LOGGER.debug( + "Setting up %s[%s]: %s", + entry.domain, + entry.title, + entry.entry_id, + ) + hub_name = entry.title + hub_mac = entry.data["device_mac"] + hub_ip = entry.data["host"] + blind_groups = entry.data[BLIND_GROUPS] + _LOGGER.debug("Groups to create: %s", blind_groups) + hub = tZoneHub( + hub_ip=hub_ip, hub_name=hub_name, websession=async_get_clientsession(hass) + ) + await hub.getHubInfo() # Check all is good + _LOGGER.debug("Hub object created, FW: %s", hub.fwVer) + entities = [] + for blind_group in blind_groups: + _LOGGER.debug("Adding blind_group: %s", blind_group) + new_group_obj = hub.AddGroup(blind_group) + entities.append( + LeviosaBlindGroup( + hass, hub_mac + "-" + str(new_group_obj.number), new_group_obj + ) + ) + async_add_entities(entities) + + _LOGGER.debug("Setting up Leviosa shade group services") + platform = entity_platform.current_platform.get() + platform.async_register_entity_service( + SERVICE_NEXT_DOWN_POS, + COVER_NEXT_POS_SCHEMA, + "next_down_pos", + ) + platform.async_register_entity_service( + SERVICE_NEXT_UP_POS, + COVER_NEXT_POS_SCHEMA, + "next_up_pos", + ) + + +class LeviosaBlindGroup(CoverEntity): + """Represents a Leviosa shade group entity.""" + + def __init__(self, hass, blind_group_id, blind_group_obj: tShadeGroup): + """Initialize the shade group.""" + self._blind_group_id = blind_group_id + self._blind_group_obj = blind_group_obj + self._hass = hass + _LOGGER.debug( + "Creating cover.%s, UID: %s", + self._blind_group_obj.name, + self._blind_group_id, + ) + + @property + def name(self): + """Name of the device.""" + return self._blind_group_obj.name + + @property + def unique_id(self): + """Return a unique ID for this device.""" + + return self._blind_group_id + + @property + def assumed_state(self): + """Indicate that we do not go to the device to know its state.""" + + return False + + @property + def current_cover_position(self): + """Indicate that we do not go to the device to know its state.""" + return self._blind_group_obj.position + + @property + def should_poll(self): + """Indicate that the device does not respond to polling.""" + + return True + + @property + def supported_features(self): + """Bitmap indicating which features this device supports.""" + + return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP + + @property + def device_class(self): + """Indicate we're managing a Roller blind motor group.""" + + return DEVICE_CLASS_SHADE + + @property + def device_info(self): + """Return the device_info of the device.""" + + device_info = { + "identifiers": {(DOMAIN, self._blind_group_obj.Hub.hub_ip)}, + "name": self._blind_group_obj.Hub.name, + "manufacturer": MANUFACTURER, + "model": MODEL, + "via_device": (DOMAIN, self._blind_group_obj.Hub.hub_ip), + } + + return device_info + + @property + def is_opening(self): + """Is the blind group opening?.""" + + return False + + @property + def is_closing(self): + """Is the blind closing?.""" + + return False + + @property + def is_closed(self): + """Is the blind group currently closed?.""" + return self._blind_group_obj.position == 0 + + async def async_close_cover(self, **kwargs): + """Close the cover.""" + await self._blind_group_obj.close() + + async def async_open_cover(self, **kwargs): + """Open the cover.""" + await self._blind_group_obj.open() + + async def async_stop_cover(self, **kwargs): + """Stop the cover.""" + await self._blind_group_obj.stop() + + async def next_down_pos(self): + """Move to the next position down.""" + await self._blind_group_obj.down() + + async def next_up_pos(self): + """Move to the next position down.""" + await self._blind_group_obj.up() diff --git a/homeassistant/components/leviosa_shades/manifest.json b/homeassistant/components/leviosa_shades/manifest.json new file mode 100644 index 00000000000000..360a36f9cbc92b --- /dev/null +++ b/homeassistant/components/leviosa_shades/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "leviosa_shades", + "name": "Leviosa Motor Shades", + "documentation": "https://www.home-assistant.io/integrations/leviosa_shades", + "requirements": [ + "async-upnp-client==0.14.13", + "aioleviosa==0.2.0" + ], + "codeowners": ["@altersis"], + "dependencies": ["cover"], + "config_flow": true +} diff --git a/homeassistant/components/leviosa_shades/services.yaml b/homeassistant/components/leviosa_shades/services.yaml new file mode 100644 index 00000000000000..32970f41661507 --- /dev/null +++ b/homeassistant/components/leviosa_shades/services.yaml @@ -0,0 +1,17 @@ +next_down_pos: + description: Moves a blind group down to the next defined intermediate position + fields: + entity_id: + description: Name(s) of entities to move down to the next position. + example: "cover.master" + default: "cover.master" + required: true + +next_up_pos: + description: Moves a blind group down to the next defined intermediate position + fields: + entity_id: + description: Name(s) of entities to move up to the next position. + example: "cover.master" + default: "cover.master" + required: true diff --git a/homeassistant/components/leviosa_shades/strings.json b/homeassistant/components/leviosa_shades/strings.json new file mode 100644 index 00000000000000..c9ca9cdb095090 --- /dev/null +++ b/homeassistant/components/leviosa_shades/strings.json @@ -0,0 +1,26 @@ +{ + "title": "Leviosa Motor Shades Zone", + "config": { + "step": { + "user": { + "title": "Connect to Leviosa Motor Shades Zone HUB", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "host": "[%key:common::config_flow::data::ip%]" + } + }, + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "no_new_devs": "No unconfigured Leviosa Zone HUBs found" + }, + "abort": { + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "no_new_devs": "No unconfigured Leviosa Zone HUBs found" + } + } +} diff --git a/homeassistant/components/leviosa_shades/translations/en.json b/homeassistant/components/leviosa_shades/translations/en.json new file mode 100644 index 00000000000000..247e63670083ca --- /dev/null +++ b/homeassistant/components/leviosa_shades/translations/en.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "no_devices_found": "No devices found on the network", + "single_instance_allowed": "Already configured. Only a single configuration possible.", + "device_already_configured": "This Leviosa Zone is already configured", + "no_new_devs": "No unconfigured Leviosa Zone HUBs found" + }, + "error": { + "cannot_connect": "The Leviosa Zone HUB does not appear to be online", + "unknown": "There was an unexpected error connecting to the Leviosa Zone HUB", + "no_new_devs": "No unconfigured Leviosa Zone HUBs found" + }, + "step": { + "select": { + "data": { + "select_ip": "IP Address" + }, + "description": "Run the setup again if you want to connect another Leviosa Zone", + "title": "Which Zone Hub would you like to configure?" + }, + "connect": { + "title": "Adding Leviosa Motor Shades Zone", + "description": "Details for Zone at: {ip_add}", + "data": { + "name": "Zone HUB Name", + "host": "Zone IP Address", + "grp1_name": "Group 1 name", + "grp2_name": "Group 2 name", + "grp3_name": "Group 3 name", + "grp4_name": "Group 4 name", + "grp5_name": "Group 5 name", + "grp6_name": "Group 6 name" + } + }, + "confirm": { + "description": "Ready to setup this device?" + } + } + }, + "title": "Leviosa Motor Shades Zone" +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a3bcef9047f26d..0cf2a0008e904b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -121,6 +121,7 @@ "kodi", "konnected", "kulersky", + "leviosa_shades", "life360", "lifx", "litejet", diff --git a/requirements_all.txt b/requirements_all.txt index d1e27d28076eb8..80667fca956175 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -187,6 +187,9 @@ aiokafka==0.6.0 # homeassistant.components.kef aiokef==0.2.16 +# homeassistant.components.leviosa_shades +aioleviosa==0.2.0 + # homeassistant.components.lifx aiolifx==0.6.9 @@ -284,6 +287,7 @@ asmog==0.0.6 asterisk_mbox==0.5.0 # homeassistant.components.dlna_dmr +# homeassistant.components.leviosa_shades # homeassistant.components.ssdp # homeassistant.components.upnp async-upnp-client==0.14.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ed005d34d03b34..d5fd70c6d81f0c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -115,6 +115,9 @@ aiohue==2.1.0 # homeassistant.components.apache_kafka aiokafka==0.6.0 +# homeassistant.components.leviosa_shades +aioleviosa==0.2.0 + # homeassistant.components.lutron_caseta aiolip==1.1.4 @@ -173,6 +176,7 @@ aprslib==0.6.46 arcam-fmj==0.5.3 # homeassistant.components.dlna_dmr +# homeassistant.components.leviosa_shades # homeassistant.components.ssdp # homeassistant.components.upnp async-upnp-client==0.14.13 diff --git a/tests/components/leviosa_shades/__init__.py b/tests/components/leviosa_shades/__init__.py new file mode 100644 index 00000000000000..b4ff037ff84d6e --- /dev/null +++ b/tests/components/leviosa_shades/__init__.py @@ -0,0 +1 @@ +"""Tests for the Leviosa Motor Shades Zone integration.""" diff --git a/tests/components/leviosa_shades/test_config_flow.py b/tests/components/leviosa_shades/test_config_flow.py new file mode 100644 index 00000000000000..e89f8d3239aa15 --- /dev/null +++ b/tests/components/leviosa_shades/test_config_flow.py @@ -0,0 +1,162 @@ +"""Test the Leviosa Motor Shades Zone config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.leviosa_shades.const import ( + BLIND_GROUPS, + DEVICE_FW_V, + DEVICE_MAC, + DOMAIN, + GROUP1_NAME, + GROUP2_NAME, + GROUP3_NAME, + GROUP4_NAME, +) +from homeassistant.const import CONF_HOST, CONF_NAME + +TEST_HOST1 = "1.2.3.4" +TEST_HOST2 = "5.6.7.8" +TEST_MAC1 = "40f5205b658c" +TEST_MAC2 = "40f5205b6687" + +TEST_ZONE_FW = "8.3" +TEST_ZONE_FW_ALT = "0.0.0" + +TEST_DISCOVERY_0 = {} +TEST_DISCOVERY_1 = {"uid:6bf25702-1d6a-4c7b-b949-40f5205b658c": TEST_HOST1} +TEST_DISCOVERY_2 = { + "uid:6bf25702-1d6a-4c7b-b949-40f5205b658c": TEST_HOST1, + "uid:6bf25702-1d6a-4c7b-b949-40f5205b6687": TEST_HOST2, +} + +TEST_USER_INPUT_1 = { + CONF_NAME: "Zone 1", + GROUP1_NAME: "Z1 Group 1", + GROUP2_NAME: "Z1 Group 2", + GROUP3_NAME: "Z1 Group 3", + GROUP4_NAME: "Z1 Group 4", +} +TEST_USER_INPUT_2 = { + CONF_NAME: "Zone 2", + GROUP1_NAME: "Z2 Group 1", + GROUP2_NAME: "Z2 Group 2", + GROUP3_NAME: "Z2 Group 3", + GROUP4_NAME: "Z2 Group 4", +} + + +@pytest.fixture(name="leviosa_shades_connect", autouse=True) +def leviosa_shades_connect_fixture(): + """Mock motion blinds connection and entry setup.""" + with patch( + "homeassistant.components.leviosa_shades.config_flow.discover_leviosa_zones", + return_value=TEST_DISCOVERY_1, + ), patch( + "homeassistant.components.leviosa_shades.async_setup_entry", return_value=True + ): + yield + + +async def test_config_flow_one_zone_success(hass): + """Successful flow initiated by the user, one Zone discovered.""" + with patch( + "homeassistant.components.leviosa_shades.config_flow.discover_leviosa_zones", + return_value=TEST_DISCOVERY_1, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "connect" + assert result["errors"] == {} + + with patch( + "homeassistant.components.leviosa_shades.config_flow.validate_zone", + return_value=TEST_ZONE_FW, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT_1 + ) + + assert result["type"] == "create_entry" + assert result["title"] == TEST_USER_INPUT_1[CONF_NAME] + assert result["data"] == { + CONF_HOST: TEST_HOST1, + DEVICE_FW_V: TEST_ZONE_FW, + DEVICE_MAC: TEST_MAC1, + BLIND_GROUPS: [ + "All Zone 1", + "Z1 Group 1", + "Z1 Group 2", + "Z1 Group 3", + "Z1 Group 4", + ], + } + + +async def test_config_flow_two_zone_success(hass): + """Successful flow initiated by the user, two Zones discovered, one selected.""" + with patch( + "homeassistant.components.leviosa_shades.config_flow.discover_leviosa_zones", + return_value=TEST_DISCOVERY_2, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "select" + assert result["data_schema"].schema["select_ip"].container == [ + TEST_HOST1, + TEST_HOST2, + ] + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"select_ip": TEST_HOST1}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "connect" + assert result["errors"] == {} + + with patch( + "homeassistant.components.leviosa_shades.config_flow.validate_zone", + return_value=TEST_ZONE_FW, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT_1 + ) + + assert result["type"] == "create_entry" + assert result["title"] == TEST_USER_INPUT_1[CONF_NAME] + assert result["data"] == { + CONF_HOST: TEST_HOST1, + DEVICE_FW_V: TEST_ZONE_FW, + DEVICE_MAC: TEST_MAC1, + BLIND_GROUPS: [ + "All Zone 1", + "Z1 Group 1", + "Z1 Group 2", + "Z1 Group 3", + "Z1 Group 4", + ], + } + + +async def test_config_flow_no_zone_abort(hass): + """Flow initiated by user, no Zones discovered.""" + with patch( + "homeassistant.components.leviosa_shades.config_flow.discover_leviosa_zones", + return_value=TEST_DISCOVERY_0, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "abort" + assert result["reason"] == "no_new_devs"