diff --git a/.strict-typing b/.strict-typing index b2f27fafbbc..3c18a1988f3 100644 --- a/.strict-typing +++ b/.strict-typing @@ -202,6 +202,7 @@ homeassistant.components.ld2410_ble.* homeassistant.components.lidarr.* homeassistant.components.lifx.* homeassistant.components.light.* +homeassistant.components.linear_garage_door.* homeassistant.components.litejet.* homeassistant.components.litterrobot.* homeassistant.components.local_ip.* diff --git a/CODEOWNERS b/CODEOWNERS index 358f2725144..fe45e9c2fca 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -700,6 +700,8 @@ build.json @home-assistant/supervisor /tests/components/life360/ @pnbruckner /homeassistant/components/light/ @home-assistant/core /tests/components/light/ @home-assistant/core +/homeassistant/components/linear_garage_door/ @IceBotYT +/tests/components/linear_garage_door/ @IceBotYT /homeassistant/components/linux_battery/ @fabaff /homeassistant/components/litejet/ @joncar /tests/components/litejet/ @joncar diff --git a/homeassistant/components/linear_garage_door/__init__.py b/homeassistant/components/linear_garage_door/__init__.py new file mode 100644 index 00000000000..d168da511e0 --- /dev/null +++ b/homeassistant/components/linear_garage_door/__init__.py @@ -0,0 +1,32 @@ +"""The Linear Garage Door integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import LinearUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.COVER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Linear Garage Door from a config entry.""" + + coordinator = LinearUpdateCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/linear_garage_door/config_flow.py b/homeassistant/components/linear_garage_door/config_flow.py new file mode 100644 index 00000000000..6bca49adb4c --- /dev/null +++ b/homeassistant/components/linear_garage_door/config_flow.py @@ -0,0 +1,166 @@ +"""Config flow for Linear Garage Door integration.""" +from __future__ import annotations + +from collections.abc import Collection, Mapping, Sequence +import logging +from typing import Any +import uuid + +from linear_garage_door import Linear +from linear_garage_door.errors import InvalidLoginError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, +} + + +async def validate_input( + hass: HomeAssistant, + data: dict[str, str], +) -> dict[str, Sequence[Collection[str]]]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + + hub = Linear() + + device_id = str(uuid.uuid4()) + try: + await hub.login( + data["email"], + data["password"], + device_id=device_id, + client_session=async_get_clientsession(hass), + ) + + sites = await hub.get_sites() + except InvalidLoginError as err: + raise InvalidAuth from err + finally: + await hub.close() + + info = { + "email": data["email"], + "password": data["password"], + "sites": sites, + "device_id": device_id, + } + + return info + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Linear Garage Door.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self.data: dict[str, Sequence[Collection[str]]] = {} + self._reauth_entry: config_entries.ConfigEntry | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + data_schema = STEP_USER_DATA_SCHEMA + + data_schema = vol.Schema(data_schema) + + if user_input is None: + return self.async_show_form(step_id="user", data_schema=data_schema) + + errors = {} + + try: + info = await validate_input(self.hass, user_input) + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self.data = info + + # Check if we are reauthenticating + if self._reauth_entry is not None: + self.hass.config_entries.async_update_entry( + self._reauth_entry, + data=self._reauth_entry.data + | {"email": self.data["email"], "password": self.data["password"]}, + ) + await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return await self.async_step_site() + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) + + async def async_step_site( + self, + user_input: dict[str, Any] | None = None, + ) -> FlowResult: + """Handle the site step.""" + + if isinstance(self.data["sites"], list): + sites: list[dict[str, str]] = self.data["sites"] + + if not user_input: + return self.async_show_form( + step_id="site", + data_schema=vol.Schema( + { + vol.Required("site"): vol.In( + {site["id"]: site["name"] for site in sites} + ) + } + ), + ) + + site_id = user_input["site"] + + site_name = next(site["name"] for site in sites if site["id"] == site_id) + + await self.async_set_unique_id(site_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=site_name, + data={ + "site_id": site_id, + "email": self.data["email"], + "password": self.data["password"], + "device_id": self.data["device_id"], + }, + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Reauth in case of a password change or other error.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_user() + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class InvalidDeviceID(HomeAssistantError): + """Error to indicate there is invalid device ID.""" diff --git a/homeassistant/components/linear_garage_door/const.py b/homeassistant/components/linear_garage_door/const.py new file mode 100644 index 00000000000..7b3625c7c67 --- /dev/null +++ b/homeassistant/components/linear_garage_door/const.py @@ -0,0 +1,3 @@ +"""Constants for the Linear Garage Door integration.""" + +DOMAIN = "linear_garage_door" diff --git a/homeassistant/components/linear_garage_door/coordinator.py b/homeassistant/components/linear_garage_door/coordinator.py new file mode 100644 index 00000000000..5a17d5a39e4 --- /dev/null +++ b/homeassistant/components/linear_garage_door/coordinator.py @@ -0,0 +1,81 @@ +"""DataUpdateCoordinator for Linear.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +from linear_garage_door import Linear +from linear_garage_door.errors import InvalidLoginError, ResponseError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class LinearUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """DataUpdateCoordinator for Linear.""" + + _email: str + _password: str + _device_id: str + _site_id: str + _devices: list[dict[str, list[str] | str]] | None + _linear: Linear + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + ) -> None: + """Initialize DataUpdateCoordinator for Linear.""" + self._email = entry.data["email"] + self._password = entry.data["password"] + self._device_id = entry.data["device_id"] + self._site_id = entry.data["site_id"] + self._devices = None + + super().__init__( + hass, + _LOGGER, + name="Linear Garage Door", + update_interval=timedelta(seconds=60), + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Get the data for Linear.""" + + linear = Linear() + + try: + await linear.login( + email=self._email, + password=self._password, + device_id=self._device_id, + ) + except InvalidLoginError as err: + if ( + str(err) + == "Login error: Login provided is invalid, please check the email and password" + ): + raise ConfigEntryAuthFailed from err + raise ConfigEntryNotReady from err + except ResponseError as err: + raise ConfigEntryNotReady from err + + if not self._devices: + self._devices = await linear.get_devices(self._site_id) + + data = {} + + for device in self._devices: + device_id = str(device["id"]) + state = await linear.get_device_state(device_id) + data[device_id] = {"name": device["name"], "subdevices": state} + + await linear.close() + + return data diff --git a/homeassistant/components/linear_garage_door/cover.py b/homeassistant/components/linear_garage_door/cover.py new file mode 100644 index 00000000000..3474e9d3acb --- /dev/null +++ b/homeassistant/components/linear_garage_door/cover.py @@ -0,0 +1,149 @@ +"""Cover entity for Linear Garage Doors.""" + +from datetime import timedelta +from typing import Any + +from linear_garage_door import Linear + +from homeassistant.components.cover import ( + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import LinearUpdateCoordinator + +SUPPORTED_SUBDEVICES = ["GDO"] +PARALLEL_UPDATES = 1 +SCAN_INTERVAL = timedelta(seconds=10) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Linear Garage Door cover.""" + coordinator: LinearUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + data = coordinator.data + + device_list: list[LinearCoverEntity] = [] + + for device_id in data: + device_list.extend( + LinearCoverEntity( + device_id=device_id, + device_name=data[device_id]["name"], + subdevice=subdev, + config_entry=config_entry, + coordinator=coordinator, + ) + for subdev in data[device_id]["subdevices"] + if subdev in SUPPORTED_SUBDEVICES + ) + async_add_entities(device_list) + + +class LinearCoverEntity(CoordinatorEntity[LinearUpdateCoordinator], CoverEntity): + """Representation of a Linear cover.""" + + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + def __init__( + self, + device_id: str, + device_name: str, + subdevice: str, + config_entry: ConfigEntry, + coordinator: LinearUpdateCoordinator, + ) -> None: + """Init with device ID and name.""" + super().__init__(coordinator) + + self._attr_has_entity_name = True + self._attr_name = None + self._device_id = device_id + self._device_name = device_name + self._subdevice = subdevice + self._attr_device_class = CoverDeviceClass.GARAGE + self._attr_unique_id = f"{device_id}-{subdevice}" + self._config_entry = config_entry + + def _get_data(self, data_property: str) -> str: + """Get a property of the subdevice.""" + return str( + self.coordinator.data[self._device_id]["subdevices"][self._subdevice].get( + data_property + ) + ) + + @property + def device_info(self) -> DeviceInfo: + """Return device info of a garage door.""" + return DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + name=self._device_name, + manufacturer="Linear", + model="Garage Door Opener", + ) + + @property + def is_closed(self) -> bool: + """Return if cover is closed.""" + return bool(self._get_data("Open_B") == "false") + + @property + def is_opened(self) -> bool: + """Return if cover is open.""" + return bool(self._get_data("Open_B") == "true") + + @property + def is_opening(self) -> bool: + """Return if cover is opening.""" + return bool(self._get_data("Opening_P") == "0") + + @property + def is_closing(self) -> bool: + """Return if cover is closing.""" + return bool(self._get_data("Opening_P") == "100") + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the garage door.""" + if self.is_closed: + return + + linear = Linear() + + await linear.login( + email=self._config_entry.data["email"], + password=self._config_entry.data["password"], + device_id=self._config_entry.data["device_id"], + client_session=async_get_clientsession(self.hass), + ) + + await linear.operate_device(self._device_id, self._subdevice, "Close") + await linear.close() + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the garage door.""" + if self.is_opened: + return + + linear = Linear() + + await linear.login( + email=self._config_entry.data["email"], + password=self._config_entry.data["password"], + device_id=self._config_entry.data["device_id"], + client_session=async_get_clientsession(self.hass), + ) + + await linear.operate_device(self._device_id, self._subdevice, "Open") + await linear.close() diff --git a/homeassistant/components/linear_garage_door/diagnostics.py b/homeassistant/components/linear_garage_door/diagnostics.py new file mode 100644 index 00000000000..fffcdd7de87 --- /dev/null +++ b/homeassistant/components/linear_garage_door/diagnostics.py @@ -0,0 +1,26 @@ +"""Diagnostics support for Linear Garage Door.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import LinearUpdateCoordinator + +TO_REDACT = {CONF_PASSWORD, CONF_EMAIL} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: LinearUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + return { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "coordinator_data": coordinator.data, + } diff --git a/homeassistant/components/linear_garage_door/manifest.json b/homeassistant/components/linear_garage_door/manifest.json new file mode 100644 index 00000000000..c7918e21e20 --- /dev/null +++ b/homeassistant/components/linear_garage_door/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "linear_garage_door", + "name": "Linear Garage Door", + "codeowners": ["@IceBotYT"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/linear_garage_door", + "iot_class": "cloud_polling", + "requirements": ["linear-garage-door==0.2.7"] +} diff --git a/homeassistant/components/linear_garage_door/strings.json b/homeassistant/components/linear_garage_door/strings.json new file mode 100644 index 00000000000..93dd17c5bce --- /dev/null +++ b/homeassistant/components/linear_garage_door/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d5a5176a974..3bbed6d145b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -262,6 +262,7 @@ FLOWS = { "lidarr", "life360", "lifx", + "linear_garage_door", "litejet", "litterrobot", "livisi", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ec35b83b630..d3685e45432 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3053,6 +3053,12 @@ "config_flow": false, "iot_class": "assumed_state" }, + "linear_garage_door": { + "name": "Linear Garage Door", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "linksys_smart": { "name": "Linksys Smart Wi-Fi", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 41a02600d94..0ed06edaa1d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1781,6 +1781,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.linear_garage_door.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.litejet.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index b91b3a3fa06..61e74f00c0d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1177,6 +1177,9 @@ lightwave==0.24 # homeassistant.components.limitlessled limitlessled==1.1.3 +# homeassistant.components.linear_garage_door +linear-garage-door==0.2.7 + # homeassistant.components.linode linode-api==4.1.9b1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a0e5c0b9871..560d8a9694b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -919,6 +919,9 @@ libsoundtouch==0.8 # homeassistant.components.life360 life360==6.0.0 +# homeassistant.components.linear_garage_door +linear-garage-door==0.2.7 + # homeassistant.components.logi_circle logi-circle==0.2.3 diff --git a/tests/components/linear_garage_door/__init__.py b/tests/components/linear_garage_door/__init__.py new file mode 100644 index 00000000000..e5abc6c943c --- /dev/null +++ b/tests/components/linear_garage_door/__init__.py @@ -0,0 +1 @@ +"""Tests for the Linear Garage Door integration.""" diff --git a/tests/components/linear_garage_door/test_config_flow.py b/tests/components/linear_garage_door/test_config_flow.py new file mode 100644 index 00000000000..88cfca71f98 --- /dev/null +++ b/tests/components/linear_garage_door/test_config_flow.py @@ -0,0 +1,161 @@ +"""Test the Linear Garage Door config flow.""" + +from unittest.mock import patch + +from linear_garage_door.errors import InvalidLoginError + +from homeassistant import config_entries +from homeassistant.components.linear_garage_door.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .util import async_init_integration + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.login", + return_value=True, + ), patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.get_sites", + return_value=[{"id": "test-site-id", "name": "test-site-name"}], + ), patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.close", + return_value=None, + ), patch( + "uuid.uuid4", return_value="test-uuid" + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "email": "test-email", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.linear_garage_door.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], {"site": "test-site-id"} + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "test-site-name" + assert result3["data"] == { + "email": "test-email", + "password": "test-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth(hass: HomeAssistant) -> None: + """Test reauthentication.""" + + entry = await async_init_integration(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "title_placeholders": {"name": entry.title}, + "unique_id": entry.unique_id, + }, + data=entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.login", + return_value=True, + ), patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.get_sites", + return_value=[{"id": "test-site-id", "name": "test-site-name"}], + ), patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.close", + return_value=None, + ), patch( + "uuid.uuid4", return_value="test-uuid" + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "email": "new-email", + "password": "new-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + entries = hass.config_entries.async_entries() + assert len(entries) == 1 + assert entries[0].data == { + "email": "new-email", + "password": "new-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + } + + +async def test_form_invalid_login(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.login", + side_effect=InvalidLoginError, + ), patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.close", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "email": "test-email", + "password": "test-password", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_exception(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + with patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.login", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "email": "test-email", + "password": "test-password", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/linear_garage_door/test_coordinator.py b/tests/components/linear_garage_door/test_coordinator.py new file mode 100644 index 00000000000..fc3087db354 --- /dev/null +++ b/tests/components/linear_garage_door/test_coordinator.py @@ -0,0 +1,99 @@ +"""Test data update coordinator for Linear Garage Door.""" + +from unittest.mock import patch + +from linear_garage_door.errors import InvalidLoginError, ResponseError + +from homeassistant.components.linear_garage_door.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_invalid_password( + hass: HomeAssistant, +) -> None: + """Test invalid password.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "email": "test-email", + "password": "test-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + }, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.login", + side_effect=InvalidLoginError( + "Login provided is invalid, please check the email and password" + ), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 1 + assert entries[0].state == ConfigEntryState.SETUP_ERROR + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert flows + assert len(flows) == 1 + assert flows[0]["context"]["source"] == "reauth" + + +async def test_response_error(hass: HomeAssistant) -> None: + """Test response error.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "email": "test-email", + "password": "test-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + }, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.login", + side_effect=ResponseError, + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 1 + assert entries[0].state == ConfigEntryState.SETUP_RETRY + + +async def test_invalid_login( + hass: HomeAssistant, +) -> None: + """Test invalid login.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "email": "test-email", + "password": "test-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + }, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.login", + side_effect=InvalidLoginError("Some other error"), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 1 + assert entries[0].state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/linear_garage_door/test_cover.py b/tests/components/linear_garage_door/test_cover.py new file mode 100644 index 00000000000..428411d39e0 --- /dev/null +++ b/tests/components/linear_garage_door/test_cover.py @@ -0,0 +1,187 @@ +"""Test Linear Garage Door cover.""" + +from datetime import timedelta +from unittest.mock import patch + +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.components.linear_garage_door.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util + +from .util import async_init_integration + +from tests.common import async_fire_time_changed + + +async def test_data(hass: HomeAssistant) -> None: + """Test that data gets parsed and returned appropriately.""" + + await async_init_integration(hass) + + assert hass.data[DOMAIN] + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 1 + assert entries[0].state == ConfigEntryState.LOADED + assert hass.states.get("cover.test_garage_1").state == STATE_OPEN + assert hass.states.get("cover.test_garage_2").state == STATE_CLOSED + assert hass.states.get("cover.test_garage_3").state == STATE_OPENING + assert hass.states.get("cover.test_garage_4").state == STATE_CLOSING + + +async def test_open_cover(hass: HomeAssistant) -> None: + """Test that opening the cover works as intended.""" + + await async_init_integration(hass) + + with patch( + "homeassistant.components.linear_garage_door.cover.Linear.operate_device" + ) as operate_device: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_1"}, + blocking=True, + ) + + assert operate_device.call_count == 0 + + with patch( + "homeassistant.components.linear_garage_door.cover.Linear.login", + return_value=True, + ), patch( + "homeassistant.components.linear_garage_door.cover.Linear.operate_device", + return_value=None, + ) as operate_device, patch( + "homeassistant.components.linear_garage_door.cover.Linear.close", + return_value=True, + ): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_2"}, + blocking=True, + ) + + assert operate_device.call_count == 1 + with patch( + "homeassistant.components.linear_garage_door.cover.Linear.login", + return_value=True, + ), patch( + "homeassistant.components.linear_garage_door.cover.Linear.get_devices", + return_value=[ + {"id": "test1", "name": "Test Garage 1", "subdevices": ["GDO", "Light"]}, + {"id": "test2", "name": "Test Garage 2", "subdevices": ["GDO", "Light"]}, + ], + ), patch( + "homeassistant.components.linear_garage_door.cover.Linear.get_device_state", + side_effect=lambda id: { + "test1": { + "GDO": {"Open_B": "true", "Open_P": "100"}, + "Light": {"On_B": "true", "On_P": "100"}, + }, + "test2": { + "GDO": {"Open_B": "false", "Opening_P": "0"}, + "Light": {"On_B": "false", "On_P": "0"}, + }, + "test3": { + "GDO": {"Open_B": "false", "Opening_P": "0"}, + "Light": {"On_B": "false", "On_P": "0"}, + }, + "test4": { + "GDO": {"Open_B": "true", "Opening_P": "100"}, + "Light": {"On_B": "true", "On_P": "100"}, + }, + }[id], + ), patch( + "homeassistant.components.linear_garage_door.cover.Linear.close", + return_value=True, + ): + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + + assert hass.states.get("cover.test_garage_2").state == STATE_OPENING + + +async def test_close_cover(hass: HomeAssistant) -> None: + """Test that closing the cover works as intended.""" + + await async_init_integration(hass) + + with patch( + "homeassistant.components.linear_garage_door.cover.Linear.operate_device" + ) as operate_device: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_2"}, + blocking=True, + ) + + assert operate_device.call_count == 0 + + with patch( + "homeassistant.components.linear_garage_door.cover.Linear.login", + return_value=True, + ), patch( + "homeassistant.components.linear_garage_door.cover.Linear.operate_device", + return_value=None, + ) as operate_device, patch( + "homeassistant.components.linear_garage_door.cover.Linear.close", + return_value=True, + ): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_1"}, + blocking=True, + ) + + assert operate_device.call_count == 1 + with patch( + "homeassistant.components.linear_garage_door.cover.Linear.login", + return_value=True, + ), patch( + "homeassistant.components.linear_garage_door.cover.Linear.get_devices", + return_value=[ + {"id": "test1", "name": "Test Garage 1", "subdevices": ["GDO", "Light"]}, + {"id": "test2", "name": "Test Garage 2", "subdevices": ["GDO", "Light"]}, + ], + ), patch( + "homeassistant.components.linear_garage_door.cover.Linear.get_device_state", + side_effect=lambda id: { + "test1": { + "GDO": {"Open_B": "true", "Opening_P": "100"}, + "Light": {"On_B": "true", "On_P": "100"}, + }, + "test2": { + "GDO": {"Open_B": "false", "Open_P": "0"}, + "Light": {"On_B": "false", "On_P": "0"}, + }, + "test3": { + "GDO": {"Open_B": "false", "Opening_P": "0"}, + "Light": {"On_B": "false", "On_P": "0"}, + }, + "test4": { + "GDO": {"Open_B": "true", "Opening_P": "100"}, + "Light": {"On_B": "true", "On_P": "100"}, + }, + }[id], + ), patch( + "homeassistant.components.linear_garage_door.cover.Linear.close", + return_value=True, + ): + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + + assert hass.states.get("cover.test_garage_1").state == STATE_CLOSING diff --git a/tests/components/linear_garage_door/test_diagnostics.py b/tests/components/linear_garage_door/test_diagnostics.py new file mode 100644 index 00000000000..0650196d619 --- /dev/null +++ b/tests/components/linear_garage_door/test_diagnostics.py @@ -0,0 +1,53 @@ +"""Test diagnostics of Linear Garage Door.""" + +from homeassistant.core import HomeAssistant + +from .util import async_init_integration + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test config entry diagnostics.""" + entry = await async_init_integration(hass) + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert result["entry"]["data"] == { + "email": "**REDACTED**", + "password": "**REDACTED**", + "site_id": "test-site-id", + "device_id": "test-uuid", + } + assert result["coordinator_data"] == { + "test1": { + "name": "Test Garage 1", + "subdevices": { + "GDO": {"Open_B": "true", "Open_P": "100"}, + "Light": {"On_B": "true", "On_P": "100"}, + }, + }, + "test2": { + "name": "Test Garage 2", + "subdevices": { + "GDO": {"Open_B": "false", "Open_P": "0"}, + "Light": {"On_B": "false", "On_P": "0"}, + }, + }, + "test3": { + "name": "Test Garage 3", + "subdevices": { + "GDO": {"Open_B": "false", "Opening_P": "0"}, + "Light": {"On_B": "false", "On_P": "0"}, + }, + }, + "test4": { + "name": "Test Garage 4", + "subdevices": { + "GDO": {"Open_B": "true", "Opening_P": "100"}, + "Light": {"On_B": "true", "On_P": "100"}, + }, + }, + } diff --git a/tests/components/linear_garage_door/test_init.py b/tests/components/linear_garage_door/test_init.py new file mode 100644 index 00000000000..e8d76770050 --- /dev/null +++ b/tests/components/linear_garage_door/test_init.py @@ -0,0 +1,59 @@ +"""Test Linear Garage Door init.""" + +from unittest.mock import patch + +from homeassistant.components.linear_garage_door.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass: HomeAssistant) -> None: + """Test the unload entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "email": "test-email", + "password": "test-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + }, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.login", + return_value=True, + ), patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.get_devices", + return_value=[ + {"id": "test", "name": "Test Garage", "subdevices": ["GDO", "Light"]} + ], + ), patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.get_device_state", + return_value={ + "GDO": {"Open_B": "true", "Open_P": "100"}, + "Light": {"On_B": "true", "On_P": "10"}, + }, + ), patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.close", + return_value=True, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.data[DOMAIN] + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 1 + assert entries[0].state == ConfigEntryState.LOADED + + with patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.close", + return_value=True, + ): + await hass.config_entries.async_unload(entries[0].entry_id) + await hass.async_block_till_done() + assert entries[0].state == ConfigEntryState.NOT_LOADED diff --git a/tests/components/linear_garage_door/util.py b/tests/components/linear_garage_door/util.py new file mode 100644 index 00000000000..d8348b9bb64 --- /dev/null +++ b/tests/components/linear_garage_door/util.py @@ -0,0 +1,62 @@ +"""Utilities for Linear Garage Door testing.""" + +from unittest.mock import patch + +from homeassistant.components.linear_garage_door.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def async_init_integration(hass: HomeAssistant) -> MockConfigEntry: + """Initialize mock integration.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "email": "test-email", + "password": "test-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + }, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.login", + return_value=True, + ), patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.get_devices", + return_value=[ + {"id": "test1", "name": "Test Garage 1", "subdevices": ["GDO", "Light"]}, + {"id": "test2", "name": "Test Garage 2", "subdevices": ["GDO", "Light"]}, + {"id": "test3", "name": "Test Garage 3", "subdevices": ["GDO", "Light"]}, + {"id": "test4", "name": "Test Garage 4", "subdevices": ["GDO", "Light"]}, + ], + ), patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.get_device_state", + side_effect=lambda id: { + "test1": { + "GDO": {"Open_B": "true", "Open_P": "100"}, + "Light": {"On_B": "true", "On_P": "100"}, + }, + "test2": { + "GDO": {"Open_B": "false", "Open_P": "0"}, + "Light": {"On_B": "false", "On_P": "0"}, + }, + "test3": { + "GDO": {"Open_B": "false", "Opening_P": "0"}, + "Light": {"On_B": "false", "On_P": "0"}, + }, + "test4": { + "GDO": {"Open_B": "true", "Opening_P": "100"}, + "Light": {"On_B": "true", "On_P": "100"}, + }, + }[id], + ), patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.close", + return_value=True, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry