From c164507952e3400d0aecf020921173955a0b2c62 Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Thu, 12 Dec 2024 20:18:19 +0100 Subject: [PATCH] Add new integration slide_local (#132632) Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + homeassistant/brands/slide.json | 5 + .../components/slide_local/__init__.py | 33 ++ .../components/slide_local/config_flow.py | 183 +++++++++ homeassistant/components/slide_local/const.py | 13 + .../components/slide_local/coordinator.py | 112 ++++++ homeassistant/components/slide_local/cover.py | 113 ++++++ .../components/slide_local/entity.py | 29 ++ .../components/slide_local/manifest.json | 17 + .../components/slide_local/quality_scale.yaml | 66 ++++ .../components/slide_local/strings.json | 35 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 17 +- homeassistant/generated/zeroconf.py | 4 + requirements_all.txt | 1 + requirements_test_all.txt | 4 + tests/components/slide_local/__init__.py | 21 + tests/components/slide_local/conftest.py | 63 +++ tests/components/slide_local/const.py | 8 + .../slide_local/fixtures/slide_1.json | 11 + .../slide_local/test_config_flow.py | 373 ++++++++++++++++++ 21 files changed, 1108 insertions(+), 3 deletions(-) create mode 100644 homeassistant/brands/slide.json create mode 100644 homeassistant/components/slide_local/__init__.py create mode 100644 homeassistant/components/slide_local/config_flow.py create mode 100644 homeassistant/components/slide_local/const.py create mode 100644 homeassistant/components/slide_local/coordinator.py create mode 100644 homeassistant/components/slide_local/cover.py create mode 100644 homeassistant/components/slide_local/entity.py create mode 100644 homeassistant/components/slide_local/manifest.json create mode 100644 homeassistant/components/slide_local/quality_scale.yaml create mode 100644 homeassistant/components/slide_local/strings.json create mode 100644 tests/components/slide_local/__init__.py create mode 100644 tests/components/slide_local/conftest.py create mode 100644 tests/components/slide_local/const.py create mode 100644 tests/components/slide_local/fixtures/slide_1.json create mode 100644 tests/components/slide_local/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 03b0e7b893b..6c11f57da83 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1359,6 +1359,8 @@ build.json @home-assistant/supervisor /homeassistant/components/sleepiq/ @mfugate1 @kbickar /tests/components/sleepiq/ @mfugate1 @kbickar /homeassistant/components/slide/ @ualex73 +/homeassistant/components/slide_local/ @dontinelli +/tests/components/slide_local/ @dontinelli /homeassistant/components/slimproto/ @marcelveldt /tests/components/slimproto/ @marcelveldt /homeassistant/components/sma/ @kellerza @rklomp diff --git a/homeassistant/brands/slide.json b/homeassistant/brands/slide.json new file mode 100644 index 00000000000..808a54affc3 --- /dev/null +++ b/homeassistant/brands/slide.json @@ -0,0 +1,5 @@ +{ + "domain": "slide", + "name": "Slide", + "integrations": ["slide", "slide_local"] +} diff --git a/homeassistant/components/slide_local/__init__.py b/homeassistant/components/slide_local/__init__.py new file mode 100644 index 00000000000..878830fe513 --- /dev/null +++ b/homeassistant/components/slide_local/__init__.py @@ -0,0 +1,33 @@ +"""Component for the Slide local API.""" + +from __future__ import annotations + +from goslideapi.goslideapi import GoSlideLocal as SlideLocalApi + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import SlideCoordinator + +PLATFORMS = [Platform.COVER] +type SlideConfigEntry = ConfigEntry[SlideLocalApi] + + +async def async_setup_entry(hass: HomeAssistant, entry: SlideConfigEntry) -> bool: + """Set up the slide_local integration.""" + + coordinator = SlideCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: SlideConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/slide_local/config_flow.py b/homeassistant/components/slide_local/config_flow.py new file mode 100644 index 00000000000..bc5033e972b --- /dev/null +++ b/homeassistant/components/slide_local/config_flow.py @@ -0,0 +1,183 @@ +"""Config flow for slide_local integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from goslideapi.goslideapi import ( + AuthenticationFailed, + ClientConnectionError, + ClientTimeoutError, + DigestAuthCalcError, + GoSlideLocal as SlideLocalApi, +) +import voluptuous as vol + +from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_MAC, CONF_PASSWORD +from homeassistant.helpers.device_registry import format_mac + +from .const import CONF_INVERT_POSITION, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class SlideConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for slide_local.""" + + _mac: str = "" + _host: str = "" + _api_version: int | None = None + + VERSION = 1 + MINOR_VERSION = 1 + + async def async_test_connection( + self, user_input: dict[str, str | int] + ) -> dict[str, str]: + """Reusable Auth Helper.""" + slide = SlideLocalApi() + + # first test, if API version 2 is working + await slide.slide_add( + user_input[CONF_HOST], + user_input.get(CONF_PASSWORD, ""), + 2, + ) + + try: + result = await slide.slide_info(user_input[CONF_HOST]) + except (ClientConnectionError, ClientTimeoutError): + return {"base": "cannot_connect"} + except (AuthenticationFailed, DigestAuthCalcError): + return {"base": "invalid_auth"} + except Exception: # noqa: BLE001 + _LOGGER.exception("Exception occurred during connection test") + return {"base": "unknown"} + + if result is not None: + self._api_version = 2 + self._mac = format_mac(result["mac"]) + return {} + + # API version 2 is not working, try API version 1 instead + await slide.slide_del(user_input[CONF_HOST]) + await slide.slide_add( + user_input[CONF_HOST], + user_input.get(CONF_PASSWORD, ""), + 1, + ) + + try: + result = await slide.slide_info(user_input[CONF_HOST]) + except (ClientConnectionError, ClientTimeoutError): + return {"base": "cannot_connect"} + except (AuthenticationFailed, DigestAuthCalcError): + return {"base": "invalid_auth"} + except Exception: # noqa: BLE001 + _LOGGER.exception("Exception occurred during connection test") + return {"base": "unknown"} + + if result is None: + # API version 1 isn't working either + return {"base": "unknown"} + + self._api_version = 1 + self._mac = format_mac(result["mac"]) + + return {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step.""" + errors = {} + if user_input is not None: + if not (errors := await self.async_test_connection(user_input)): + await self.async_set_unique_id(self._mac) + self._abort_if_unique_id_configured() + user_input |= { + CONF_MAC: self._mac, + CONF_API_VERSION: self._api_version, + } + + return self.async_create_entry( + title=user_input[CONF_HOST], + data=user_input, + options={CONF_INVERT_POSITION: False}, + ) + + if user_input is not None and user_input.get(CONF_HOST) is not None: + self._host = user_input[CONF_HOST] + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_PASSWORD): str, + } + ), + {CONF_HOST: self._host}, + ), + errors=errors, + ) + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + + # id is in the format 'slide_000000000000' + self._mac = format_mac(str(discovery_info.properties.get("id"))[6:]) + + await self.async_set_unique_id(self._mac) + + self._abort_if_unique_id_configured( + {CONF_HOST: discovery_info.host}, reload_on_update=True + ) + + errors = {} + if errors := await self.async_test_connection( + { + CONF_HOST: self._host, + } + ): + return self.async_abort( + reason="discovery_connection_failed", + description_placeholders={ + "error": errors["base"], + }, + ) + + self._host = discovery_info.host + + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + + if user_input is not None: + user_input |= { + CONF_HOST: self._host, + CONF_API_VERSION: 2, + CONF_MAC: format_mac(self._mac), + } + return self.async_create_entry( + title=user_input[CONF_HOST], + data=user_input, + options={CONF_INVERT_POSITION: False}, + ) + + self._set_confirm_only() + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={ + "host": self._host, + }, + ) diff --git a/homeassistant/components/slide_local/const.py b/homeassistant/components/slide_local/const.py new file mode 100644 index 00000000000..9dc6d4ac925 --- /dev/null +++ b/homeassistant/components/slide_local/const.py @@ -0,0 +1,13 @@ +"""Define constants for the Slide component.""" + +API_LOCAL = "api_local" +ATTR_TOUCHGO = "touchgo" +CONF_INVERT_POSITION = "invert_position" +CONF_VERIFY_SSL = "verify_ssl" +DOMAIN = "slide_local" +SLIDES = "slides" +SLIDES_LOCAL = "slides_local" +DEFAULT_OFFSET = 0.15 +DEFAULT_RETRY = 120 +SERVICE_CALIBRATE = "calibrate" +SERVICE_TOUCHGO = "touchgo" diff --git a/homeassistant/components/slide_local/coordinator.py b/homeassistant/components/slide_local/coordinator.py new file mode 100644 index 00000000000..c7542a4b813 --- /dev/null +++ b/homeassistant/components/slide_local/coordinator.py @@ -0,0 +1,112 @@ +"""DataUpdateCoordinator for slide_local integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import TYPE_CHECKING, Any + +from goslideapi.goslideapi import ( + AuthenticationFailed, + ClientConnectionError, + ClientTimeoutError, + DigestAuthCalcError, + GoSlideLocal as SlideLocalApi, +) + +from homeassistant.const import ( + CONF_API_VERSION, + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_OFFSET, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +if TYPE_CHECKING: + from . import SlideConfigEntry + + +class SlideCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Get and update the latest data.""" + + def __init__(self, hass: HomeAssistant, entry: SlideConfigEntry) -> None: + """Initialize the data object.""" + super().__init__( + hass, _LOGGER, name="Slide", update_interval=timedelta(seconds=15) + ) + self.slide = SlideLocalApi() + self.api_version = entry.data[CONF_API_VERSION] + self.mac = entry.data[CONF_MAC] + self.host = entry.data[CONF_HOST] + self.password = entry.data[CONF_PASSWORD] + + async def _async_setup(self) -> None: + """Do initialization logic for Slide coordinator.""" + _LOGGER.debug("Initializing Slide coordinator") + + await self.slide.slide_add( + self.host, + self.password, + self.api_version, + ) + + _LOGGER.debug("Slide coordinator initialized") + + async def _async_update_data(self) -> dict[str, Any]: + """Update the data from the Slide device.""" + _LOGGER.debug("Start data update") + + try: + data = await self.slide.slide_info(self.host) + except ( + ClientConnectionError, + AuthenticationFailed, + ClientTimeoutError, + DigestAuthCalcError, + ) as ex: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + ) from ex + + if data is None: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + ) + + if "pos" in data: + if self.data is None: + oldpos = None + else: + oldpos = self.data.get("pos") + + data["pos"] = max(0, min(1, data["pos"])) + + if oldpos is None or oldpos == data["pos"]: + data["state"] = ( + STATE_CLOSED if data["pos"] > (1 - DEFAULT_OFFSET) else STATE_OPEN + ) + elif oldpos < data["pos"]: + data["state"] = ( + STATE_CLOSED + if data["pos"] >= (1 - DEFAULT_OFFSET) + else STATE_CLOSING + ) + else: + data["state"] = ( + STATE_OPEN if data["pos"] <= DEFAULT_OFFSET else STATE_OPENING + ) + + _LOGGER.debug("Data successfully updated: %s", data) + + return data diff --git a/homeassistant/components/slide_local/cover.py b/homeassistant/components/slide_local/cover.py new file mode 100644 index 00000000000..1bf026746c6 --- /dev/null +++ b/homeassistant/components/slide_local/cover.py @@ -0,0 +1,113 @@ +"""Support for Slide covers.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.cover import ATTR_POSITION, CoverDeviceClass, CoverEntity +from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPENING +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SlideConfigEntry +from .const import CONF_INVERT_POSITION, DEFAULT_OFFSET +from .coordinator import SlideCoordinator +from .entity import SlideEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SlideConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up cover(s) for Slide platform.""" + + coordinator = entry.runtime_data + + async_add_entities( + [ + SlideCoverLocal( + coordinator, + entry, + ) + ] + ) + + +class SlideCoverLocal(SlideEntity, CoverEntity): + """Representation of a Slide Local API cover.""" + + _attr_assumed_state = True + _attr_device_class = CoverDeviceClass.CURTAIN + + def __init__( + self, + coordinator: SlideCoordinator, + entry: SlideConfigEntry, + ) -> None: + """Initialize the cover.""" + super().__init__(coordinator) + + self._attr_name = None + self._invert = entry.options[CONF_INVERT_POSITION] + self._attr_unique_id = coordinator.data["mac"] + + @property + def is_opening(self) -> bool: + """Return if the cover is opening or not.""" + return self.coordinator.data["state"] == STATE_OPENING + + @property + def is_closing(self) -> bool: + """Return if the cover is closing or not.""" + return self.coordinator.data["state"] == STATE_CLOSING + + @property + def is_closed(self) -> bool: + """Return None if status is unknown, True if closed, else False.""" + return self.coordinator.data["state"] == STATE_CLOSED + + @property + def current_cover_position(self) -> int | None: + """Return the current position of cover shutter.""" + pos = self.coordinator.data["pos"] + if pos is not None: + if (1 - pos) <= DEFAULT_OFFSET or pos <= DEFAULT_OFFSET: + pos = round(pos) + if not self._invert: + pos = 1 - pos + pos = int(pos * 100) + return pos + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + self.coordinator.data["state"] = STATE_OPENING + await self.coordinator.slide.slide_open(self.coordinator.host) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + self.coordinator.data["state"] = STATE_CLOSING + await self.coordinator.slide.slide_close(self.coordinator.host) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + await self.coordinator.slide.slide_stop(self.coordinator.host) + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + position = kwargs[ATTR_POSITION] / 100 + if not self._invert: + position = 1 - position + + if self.coordinator.data["pos"] is not None: + if position > self.coordinator.data["pos"]: + self.coordinator.data["state"] = STATE_CLOSING + else: + self.coordinator.data["state"] = STATE_OPENING + + await self.coordinator.slide.slide_set_position(self.coordinator.host, position) diff --git a/homeassistant/components/slide_local/entity.py b/homeassistant/components/slide_local/entity.py new file mode 100644 index 00000000000..c1dbc101e6f --- /dev/null +++ b/homeassistant/components/slide_local/entity.py @@ -0,0 +1,29 @@ +"""Entities for slide_local integration.""" + +from homeassistant.const import CONF_MAC +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import SlideCoordinator + + +class SlideEntity(CoordinatorEntity[SlideCoordinator]): + """Base class of a Slide local API cover.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: SlideCoordinator, + ) -> None: + """Initialize the Slide device.""" + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + manufacturer="Innovation in Motion", + connections={(CONF_MAC, coordinator.data["mac"])}, + name=coordinator.data["device_name"], + sw_version=coordinator.api_version, + serial_number=coordinator.data["mac"], + configuration_url=f"http://{coordinator.host}", + ) diff --git a/homeassistant/components/slide_local/manifest.json b/homeassistant/components/slide_local/manifest.json new file mode 100644 index 00000000000..42c74b2c308 --- /dev/null +++ b/homeassistant/components/slide_local/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "slide_local", + "name": "Slide Local", + "codeowners": ["@dontinelli"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/slide_local", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["goslide-api==0.7.0"], + "zeroconf": [ + { + "type": "_http._tcp.local.", + "name": "slide*" + } + ] +} diff --git a/homeassistant/components/slide_local/quality_scale.yaml b/homeassistant/components/slide_local/quality_scale.yaml new file mode 100644 index 00000000000..048a428f236 --- /dev/null +++ b/homeassistant/components/slide_local/quality_scale.yaml @@ -0,0 +1,66 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: + status: exempt + comment: No explicit event subscriptions. + dependency-transparency: done + action-setup: done + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: done + brands: done + + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: + status: exempt + comment: No custom action. + reauthentication-flow: todo + parallel-updates: done + test-coverage: todo + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: todo + + # Gold + entity-translations: todo + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: done + stale-devices: todo + diagnostics: todo + exception-translations: done + icon-translations: todo + reconfiguration-flow: todo + dynamic-devices: todo + discovery-update-info: todo + repair-issues: todo + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: + status: exempt + comment: | + This integration doesn't have known issues that could be resolved by the user. + docs-examples: done + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/slide_local/strings.json b/homeassistant/components/slide_local/strings.json new file mode 100644 index 00000000000..38090c7e62d --- /dev/null +++ b/homeassistant/components/slide_local/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "step": { + "user": { + "description": "Provide information to connect to the Slide device", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your local Slide", + "password": "The device code of your Slide (inside of the Slide or in the box, length is 8 characters). If your Slide runs firmware version 2 this is optional, as it is not used by the local API." + } + }, + "zeroconf_confirm": { + "title": "Confirm setup for Slide", + "description": "Do you want to setup {host}?" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "discovery_connection_failed": "The setup of the discovered device failed with the following error: {error}. Please try to set it up manually." + }, + "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%]" + } + }, + "exceptions": { + "update_error": { + "message": "Error while updating data from the API." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a3858fd176f..b074ff714f6 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -545,6 +545,7 @@ FLOWS = { "skybell", "slack", "sleepiq", + "slide_local", "slimproto", "sma", "smappee", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 5128578b606..fcd974534af 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5660,9 +5660,20 @@ }, "slide": { "name": "Slide", - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_polling" + "integrations": { + "slide": { + "integration_type": "hub", + "config_flow": false, + "iot_class": "cloud_polling", + "name": "Slide" + }, + "slide_local": { + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling", + "name": "Slide Local" + } + } }, "slimproto": { "name": "SlimProto (Squeezebox players)", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 9bfff93cc2f..b04e6ad6f52 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -562,6 +562,10 @@ ZEROCONF = { "domain": "shelly", "name": "shelly*", }, + { + "domain": "slide_local", + "name": "slide*", + }, { "domain": "synology_dsm", "properties": { diff --git a/requirements_all.txt b/requirements_all.txt index c361ffec5a8..4ee02e13695 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1028,6 +1028,7 @@ google-photos-library-api==0.12.1 googlemaps==2.5.1 # homeassistant.components.slide +# homeassistant.components.slide_local goslide-api==0.7.0 # homeassistant.components.tailwind diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1c918cb2f1c..f7faaa3ae0d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -877,6 +877,10 @@ google-photos-library-api==0.12.1 # homeassistant.components.google_travel_time googlemaps==2.5.1 +# homeassistant.components.slide +# homeassistant.components.slide_local +goslide-api==0.7.0 + # homeassistant.components.tailwind gotailwind==0.3.0 diff --git a/tests/components/slide_local/__init__.py b/tests/components/slide_local/__init__.py new file mode 100644 index 00000000000..cd7bd6cb6d1 --- /dev/null +++ b/tests/components/slide_local/__init__.py @@ -0,0 +1,21 @@ +"""Tests for the slide_local integration.""" + +from unittest.mock import patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_platform( + hass: HomeAssistant, config_entry: MockConfigEntry, platforms: list[Platform] +) -> MockConfigEntry: + """Set up the slide local integration.""" + config_entry.add_to_hass(hass) + + with patch("homeassistant.components.slide_local.PLATFORMS", platforms): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/slide_local/conftest.py b/tests/components/slide_local/conftest.py new file mode 100644 index 00000000000..0d70d1989e7 --- /dev/null +++ b/tests/components/slide_local/conftest.py @@ -0,0 +1,63 @@ +"""Test fixtures for Slide local.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.slide_local.const import CONF_INVERT_POSITION, DOMAIN +from homeassistant.const import CONF_API_VERSION, CONF_HOST + +from .const import HOST, SLIDE_INFO_DATA + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="slide", + data={ + CONF_HOST: HOST, + CONF_API_VERSION: 2, + }, + options={ + CONF_INVERT_POSITION: False, + }, + minor_version=1, + unique_id="12:34:56:78:90:ab", + entry_id="ce5f5431554d101905d31797e1232da8", + ) + + +@pytest.fixture +def mock_slide_api(): + """Build a fixture for the SlideLocalApi that connects successfully and returns one device.""" + + mock_slide_local_api = AsyncMock() + mock_slide_local_api.slide_info.return_value = SLIDE_INFO_DATA + + with ( + patch( + "homeassistant.components.slide_local.SlideLocalApi", + autospec=True, + return_value=mock_slide_local_api, + ), + patch( + "homeassistant.components.slide_local.config_flow.SlideLocalApi", + autospec=True, + return_value=mock_slide_local_api, + ), + ): + yield mock_slide_local_api + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.slide_local.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/slide_local/const.py b/tests/components/slide_local/const.py new file mode 100644 index 00000000000..edf45753407 --- /dev/null +++ b/tests/components/slide_local/const.py @@ -0,0 +1,8 @@ +"""Common const used across tests for slide_local.""" + +from homeassistant.components.slide_local.const import DOMAIN + +from tests.common import load_json_object_fixture + +HOST = "127.0.0.2" +SLIDE_INFO_DATA = load_json_object_fixture("slide_1.json", DOMAIN) diff --git a/tests/components/slide_local/fixtures/slide_1.json b/tests/components/slide_local/fixtures/slide_1.json new file mode 100644 index 00000000000..e8c3c85a324 --- /dev/null +++ b/tests/components/slide_local/fixtures/slide_1.json @@ -0,0 +1,11 @@ +{ + "slide_id": "slide_300000000000", + "mac": "300000000000", + "board_rev": 1, + "device_name": "slide bedroom", + "zone_name": "bedroom", + "curtain_type": 0, + "calib_time": 10239, + "pos": 0.0, + "touch_go": true +} diff --git a/tests/components/slide_local/test_config_flow.py b/tests/components/slide_local/test_config_flow.py new file mode 100644 index 00000000000..35aa99a90d7 --- /dev/null +++ b/tests/components/slide_local/test_config_flow.py @@ -0,0 +1,373 @@ +"""Test the slide_local config flow.""" + +from ipaddress import ip_address +from unittest.mock import AsyncMock + +from goslideapi.goslideapi import ( + AuthenticationFailed, + ClientConnectionError, + ClientTimeoutError, + DigestAuthCalcError, +) +import pytest + +from homeassistant.components.slide_local.const import CONF_INVERT_POSITION, DOMAIN +from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import HOST, SLIDE_INFO_DATA + +from tests.common import MockConfigEntry + +MOCK_ZEROCONF_DATA = ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.2"), + ip_addresses=[ip_address("127.0.0.2")], + hostname="Slide-1234567890AB.local.", + name="Slide-1234567890AB._http._tcp.local.", + port=80, + properties={ + "id": "slide-1234567890AB", + "arch": "esp32", + "app": "slide", + "fw_version": "2.0.0-1683059251", + "fw_id": "20230502-202745", + }, + type="mock_type", +) + + +async def test_user( + hass: HomeAssistant, mock_slide_api: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == HOST + assert result2["data"][CONF_HOST] == HOST + assert result2["data"][CONF_PASSWORD] == "pwd" + assert result2["data"][CONF_API_VERSION] == 2 + assert result2["result"].unique_id == "30:00:00:00:00:00" + assert not result2["options"][CONF_INVERT_POSITION] + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_api_1( + hass: HomeAssistant, + mock_slide_api: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_slide_api.slide_info.side_effect = [None, SLIDE_INFO_DATA] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == HOST + assert result2["data"][CONF_HOST] == HOST + assert result2["data"][CONF_PASSWORD] == "pwd" + assert result2["data"][CONF_API_VERSION] == 1 + assert result2["result"].unique_id == "30:00:00:00:00:00" + assert not result2["options"][CONF_INVERT_POSITION] + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_api_error( + hass: HomeAssistant, + mock_slide_api: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_slide_api.slide_info.side_effect = [None, None] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == "unknown" + + mock_slide_api.slide_info.side_effect = [None, SLIDE_INFO_DATA] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == HOST + assert result2["data"][CONF_HOST] == HOST + assert result2["data"][CONF_PASSWORD] == "pwd" + assert result2["data"][CONF_API_VERSION] == 1 + assert result2["result"].unique_id == "30:00:00:00:00:00" + assert not result2["options"][CONF_INVERT_POSITION] + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (ClientConnectionError, "cannot_connect"), + (ClientTimeoutError, "cannot_connect"), + (AuthenticationFailed, "invalid_auth"), + (DigestAuthCalcError, "invalid_auth"), + (Exception, "unknown"), + ], +) +async def test_api_1_exceptions( + hass: HomeAssistant, + exception: Exception, + error: str, + mock_slide_api: AsyncMock, +) -> None: + """Test we can handle Form exceptions for api 1.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_slide_api.slide_info.side_effect = [None, exception] + + # tests with connection error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == error + + # tests with all provided + mock_slide_api.slide_info.side_effect = [None, SLIDE_INFO_DATA] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (ClientConnectionError, "cannot_connect"), + (ClientTimeoutError, "cannot_connect"), + (AuthenticationFailed, "invalid_auth"), + (DigestAuthCalcError, "invalid_auth"), + (Exception, "unknown"), + ], +) +async def test_api_2_exceptions( + hass: HomeAssistant, + exception: Exception, + error: str, + mock_slide_api: AsyncMock, +) -> None: + """Test we can handle Form exceptions for api 2.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_slide_api.slide_info.side_effect = exception + + # tests with connection error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == error + + # tests with all provided + mock_slide_api.slide_info.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_abort_if_already_setup( + hass: HomeAssistant, + mock_slide_api: AsyncMock, +) -> None: + """Test we abort if the device is already setup.""" + + MockConfigEntry(domain=DOMAIN, unique_id="30:00:00:00:00:00").add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf( + hass: HomeAssistant, mock_slide_api: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test starting a flow from discovery.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=MOCK_ZEROCONF_DATA + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "127.0.0.2" + assert result["data"][CONF_HOST] == "127.0.0.2" + assert not result["options"][CONF_INVERT_POSITION] + assert result["result"].unique_id == "12:34:56:78:90:ab" + + +async def test_zeroconf_duplicate_entry( + hass: HomeAssistant, mock_slide_api: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test starting a flow from discovery.""" + + MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: HOST}, unique_id="12:34:56:78:90:ab" + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=MOCK_ZEROCONF_DATA + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].data[CONF_HOST] == HOST + + +async def test_zeroconf_update_duplicate_entry( + hass: HomeAssistant, mock_slide_api: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test updating an existing entry from discovery.""" + + MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.3"}, unique_id="12:34:56:78:90:ab" + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=MOCK_ZEROCONF_DATA + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].data[CONF_HOST] == HOST + + +@pytest.mark.parametrize( + ("exception"), + [ + (ClientConnectionError), + (ClientTimeoutError), + (AuthenticationFailed), + (DigestAuthCalcError), + (Exception), + ], +) +async def test_zeroconf_connection_error( + hass: HomeAssistant, + exception: Exception, + mock_slide_api: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test starting a flow from discovery.""" + + MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "slide_host"}, unique_id="12:34:56:78:90:cd" + ).add_to_hass(hass) + + mock_slide_api.slide_info.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=MOCK_ZEROCONF_DATA + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "discovery_connection_failed"