From 092e362f01ab345b3d99b88538c4bcff328a8ae3 Mon Sep 17 00:00:00 2001 From: cnico Date: Thu, 4 Jul 2024 16:45:20 +0200 Subject: [PATCH] Add new integration for Dio Chacon cover devices (#116267) * Dio Chacon integration addition with config flow and cover entity * Addition of model information for device * Addition of light and service to force reloading states * Logger improvements * Convert light to switch and usage of v1.0.0 of the api * 100% for tests coverage * Invalid credential implementation and rebase on latest ha dev code * Simplify PR with only one platform * Ruff correction * restore original .gitignore content * Correction of cover state bug when using cover when using actions on cover group. * Begin of corrections following review. * unit tests correction * Refactor with a coordinator as asked by review * Implemented a post constructor callback init method via dio-chacon-api-1.0.2. Improved typing. * Corrections for 2nd review * Reimplemented without coordinator as reviewed with Joostlek * Review improvement * generalize callback in entity * Other review improvements * Refactored tests for readability * Test 100% operationals * Tests review corrections * Tests review corrections * Review tests improvements * simplified tests with snapshots and callback method * Final fixes * Final fixes * Final fixes * Rename to chacon_dio --------- Co-authored-by: Joostlek --- CODEOWNERS | 2 + .../components/chacon_dio/__init__.py | 80 +++++++++ .../components/chacon_dio/config_flow.py | 67 ++++++++ homeassistant/components/chacon_dio/const.py | 5 + homeassistant/components/chacon_dio/cover.py | 124 ++++++++++++++ homeassistant/components/chacon_dio/entity.py | 53 ++++++ .../components/chacon_dio/manifest.json | 10 ++ .../components/chacon_dio/strings.json | 20 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/chacon_dio/__init__.py | 13 ++ tests/components/chacon_dio/conftest.py | 71 ++++++++ .../chacon_dio/snapshots/test_cover.ambr | 50 ++++++ .../components/chacon_dio/test_config_flow.py | 122 ++++++++++++++ tests/components/chacon_dio/test_cover.py | 157 ++++++++++++++++++ tests/components/chacon_dio/test_init.py | 43 +++++ 18 files changed, 830 insertions(+) create mode 100644 homeassistant/components/chacon_dio/__init__.py create mode 100644 homeassistant/components/chacon_dio/config_flow.py create mode 100644 homeassistant/components/chacon_dio/const.py create mode 100644 homeassistant/components/chacon_dio/cover.py create mode 100644 homeassistant/components/chacon_dio/entity.py create mode 100644 homeassistant/components/chacon_dio/manifest.json create mode 100644 homeassistant/components/chacon_dio/strings.json create mode 100644 tests/components/chacon_dio/__init__.py create mode 100644 tests/components/chacon_dio/conftest.py create mode 100644 tests/components/chacon_dio/snapshots/test_cover.ambr create mode 100644 tests/components/chacon_dio/test_config_flow.py create mode 100644 tests/components/chacon_dio/test_cover.py create mode 100644 tests/components/chacon_dio/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 14f8a7996bc..7add25202e9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -237,6 +237,8 @@ build.json @home-assistant/supervisor /tests/components/ccm15/ @ocalvo /homeassistant/components/cert_expiry/ @jjlawren /tests/components/cert_expiry/ @jjlawren +/homeassistant/components/chacon_dio/ @cnico +/tests/components/chacon_dio/ @cnico /homeassistant/components/cisco_ios/ @fbradyirl /homeassistant/components/cisco_mobility_express/ @fbradyirl /homeassistant/components/cisco_webex_teams/ @fbradyirl diff --git a/homeassistant/components/chacon_dio/__init__.py b/homeassistant/components/chacon_dio/__init__.py new file mode 100644 index 00000000000..00558572fca --- /dev/null +++ b/homeassistant/components/chacon_dio/__init__.py @@ -0,0 +1,80 @@ +"""The chacon_dio integration.""" + +from dataclasses import dataclass +import logging +from typing import Any + +from dio_chacon_wifi_api import DIOChaconAPIClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_PASSWORD, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import Event, HomeAssistant + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.COVER] + + +@dataclass +class ChaconDioData: + """Chacon Dio data class.""" + + client: DIOChaconAPIClient + list_devices: list[dict[str, Any]] + + +type ChaconDioConfigEntry = ConfigEntry[ChaconDioData] + + +async def async_setup_entry(hass: HomeAssistant, entry: ChaconDioConfigEntry) -> bool: + """Set up chacon_dio from a config entry.""" + + config = entry.data + + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + dio_chacon_id = entry.unique_id + + _LOGGER.debug("Initializing Chacon Dio client %s, %s", username, dio_chacon_id) + client = DIOChaconAPIClient( + username, + password, + dio_chacon_id, + ) + + found_devices = await client.search_all_devices(with_state=True) + list_devices = list(found_devices.values()) + _LOGGER.debug("List of devices %s", list_devices) + + entry.runtime_data = ChaconDioData( + client=client, + list_devices=list_devices, + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # Disconnect the permanent websocket connection of home assistant on shutdown + async def _async_disconnect_websocket(_: Event) -> None: + await client.disconnect() + + entry.async_on_unload( + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, _async_disconnect_websocket + ) + ) + + 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): + await entry.runtime_data.client.disconnect() + + return unload_ok diff --git a/homeassistant/components/chacon_dio/config_flow.py b/homeassistant/components/chacon_dio/config_flow.py new file mode 100644 index 00000000000..54604b81153 --- /dev/null +++ b/homeassistant/components/chacon_dio/config_flow.py @@ -0,0 +1,67 @@ +"""Config flow for chacon_dio integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from dio_chacon_wifi_api import DIOChaconAPIClient +from dio_chacon_wifi_api.exceptions import DIOChaconAPIError, DIOChaconInvalidAuthError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class ChaconDioConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for chacon_dio.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + + errors: dict[str, str] = {} + + if user_input is not None: + client = DIOChaconAPIClient( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + try: + _user_id: str = await client.get_user_id() + except DIOChaconAPIError: + errors["base"] = "cannot_connect" + except DIOChaconInvalidAuthError: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + else: + await self.async_set_unique_id(_user_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=f"Chacon DiO {user_input[CONF_USERNAME]}", + data=user_input, + ) + + finally: + await client.disconnect() + + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/chacon_dio/const.py b/homeassistant/components/chacon_dio/const.py new file mode 100644 index 00000000000..631db533a12 --- /dev/null +++ b/homeassistant/components/chacon_dio/const.py @@ -0,0 +1,5 @@ +"""Constants for the chacon_dio integration.""" + +DOMAIN = "chacon_dio" + +MANUFACTURER = "Chacon" diff --git a/homeassistant/components/chacon_dio/cover.py b/homeassistant/components/chacon_dio/cover.py new file mode 100644 index 00000000000..3a4955adf5c --- /dev/null +++ b/homeassistant/components/chacon_dio/cover.py @@ -0,0 +1,124 @@ +"""Cover Platform for Chacon Dio REV-SHUTTER devices.""" + +import logging +from typing import Any + +from dio_chacon_wifi_api.const import DeviceTypeEnum, ShutterMoveEnum + +from homeassistant.components.cover import ( + ATTR_POSITION, + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ChaconDioConfigEntry +from .entity import ChaconDioEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ChaconDioConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Chacon Dio cover devices.""" + data = config_entry.runtime_data + client = data.client + + async_add_entities( + ChaconDioCover(client, device) + for device in data.list_devices + if device["type"] == DeviceTypeEnum.SHUTTER.value + ) + + +class ChaconDioCover(ChaconDioEntity, CoverEntity): + """Object for controlling a Chacon Dio cover.""" + + _attr_device_class = CoverDeviceClass.SHUTTER + _attr_name = None + + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + def _update_attr(self, data: dict[str, Any]) -> None: + """Recomputes the attributes values either at init or when the device state changes.""" + self._attr_available = data["connected"] + self._attr_current_cover_position = data["openlevel"] + self._attr_is_closing = data["movement"] == ShutterMoveEnum.DOWN.value + self._attr_is_opening = data["movement"] == ShutterMoveEnum.UP.value + self._attr_is_closed = self._attr_current_cover_position == 0 + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover. + + Closed status is effective after the server callback that triggers callback_device_state. + """ + + _LOGGER.debug( + "Close cover %s , %s, %s", + self.target_id, + self._attr_name, + self.is_closed, + ) + + # closes effectively only if cover is not already closing and not fully closed + if not self._attr_is_closing and not self.is_closed: + self._attr_is_closing = True + self.async_write_ha_state() + + await self.client.move_shutter_direction( + self.target_id, ShutterMoveEnum.DOWN + ) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover. + + Opened status is effective after the server callback that triggers callback_device_state. + """ + + _LOGGER.debug( + "Open cover %s , %s, %s", + self.target_id, + self._attr_name, + self.current_cover_position, + ) + + # opens effectively only if cover is not already opening and not fully opened + if not self._attr_is_opening and self.current_cover_position != 100: + self._attr_is_opening = True + self.async_write_ha_state() + + await self.client.move_shutter_direction(self.target_id, ShutterMoveEnum.UP) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + + _LOGGER.debug("Stop cover %s , %s", self.target_id, self._attr_name) + + self._attr_is_opening = False + self._attr_is_closing = False + self.async_write_ha_state() + + await self.client.move_shutter_direction(self.target_id, ShutterMoveEnum.STOP) + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Set the cover open position in percentage. + + Closing or opening status is effective after the server callback that triggers callback_device_state. + """ + position: int = kwargs[ATTR_POSITION] + + _LOGGER.debug( + "Set cover position %i, %s , %s", position, self.target_id, self._attr_name + ) + + await self.client.move_shutter_percentage(self.target_id, position) diff --git a/homeassistant/components/chacon_dio/entity.py b/homeassistant/components/chacon_dio/entity.py new file mode 100644 index 00000000000..38f3d7f5831 --- /dev/null +++ b/homeassistant/components/chacon_dio/entity.py @@ -0,0 +1,53 @@ +"""Base entity for the Chacon Dio entity.""" + +import logging +from typing import Any + +from dio_chacon_wifi_api import DIOChaconAPIClient + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN, MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + + +class ChaconDioEntity(Entity): + """Implements a common class elements representing the Chacon Dio entity.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__(self, client: DIOChaconAPIClient, device: dict[str, Any]) -> None: + """Initialize Chacon Dio entity.""" + + self.client = client + + self.target_id: str = device["id"] + self._attr_unique_id = self.target_id + self._attr_device_info: DeviceInfo | None = DeviceInfo( + identifiers={(DOMAIN, self.target_id)}, + manufacturer=MANUFACTURER, + name=device["name"], + model=device["model"], + ) + + self._update_attr(device) + + def _update_attr(self, data: dict[str, Any]) -> None: + """Recomputes the attributes values.""" + + async def async_added_to_hass(self) -> None: + """Register the callback for server side events.""" + await super().async_added_to_hass() + self.client.set_callback_device_state_by_device( + self.target_id, self.callback_device_state + ) + + def callback_device_state(self, data: dict[str, Any]) -> None: + """Receive callback for device state notification pushed from the server.""" + + _LOGGER.debug("Data received from server %s", data) + self._update_attr(data) + self.async_write_ha_state() diff --git a/homeassistant/components/chacon_dio/manifest.json b/homeassistant/components/chacon_dio/manifest.json new file mode 100644 index 00000000000..d077b130da9 --- /dev/null +++ b/homeassistant/components/chacon_dio/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "chacon_dio", + "name": "Chacon DiO", + "codeowners": ["@cnico"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/chacon_dio", + "iot_class": "cloud_push", + "loggers": ["dio_chacon_api"], + "requirements": ["dio-chacon-wifi-api==1.1.0"] +} diff --git a/homeassistant/components/chacon_dio/strings.json b/homeassistant/components/chacon_dio/strings.json new file mode 100644 index 00000000000..96dc8b371d1 --- /dev/null +++ b/homeassistant/components/chacon_dio/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 463a38feb9f..a715a0ccd76 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -98,6 +98,7 @@ FLOWS = { "cast", "ccm15", "cert_expiry", + "chacon_dio", "cloudflare", "co2signal", "coinbase", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 0ad8ac09c9e..b2ff70eefe1 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -878,6 +878,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "chacon_dio": { + "name": "Chacon DiO", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "channels": { "name": "Channels", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 7b07f994f80..0e8be70b427 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -734,6 +734,9 @@ devolo-home-control-api==0.18.3 # homeassistant.components.devolo_home_network devolo-plc-api==1.4.1 +# homeassistant.components.chacon_dio +dio-chacon-wifi-api==1.1.0 + # homeassistant.components.directv directv==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 44d60bfa13a..bf109a087c0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -615,6 +615,9 @@ devolo-home-control-api==0.18.3 # homeassistant.components.devolo_home_network devolo-plc-api==1.4.1 +# homeassistant.components.chacon_dio +dio-chacon-wifi-api==1.1.0 + # homeassistant.components.directv directv==0.4.0 diff --git a/tests/components/chacon_dio/__init__.py b/tests/components/chacon_dio/__init__.py new file mode 100644 index 00000000000..2a340097eb2 --- /dev/null +++ b/tests/components/chacon_dio/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Chacon Dio integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/chacon_dio/conftest.py b/tests/components/chacon_dio/conftest.py new file mode 100644 index 00000000000..f837403f14e --- /dev/null +++ b/tests/components/chacon_dio/conftest.py @@ -0,0 +1,71 @@ +"""Common fixtures for the chacon_dio tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.chacon_dio.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + +MOCK_COVER_DEVICE = { + "L4HActuator_idmock1": { + "id": "L4HActuator_idmock1", + "name": "Shutter mock 1", + "type": "SHUTTER", + "model": "CERSwd-3B_1.0.6", + "connected": True, + "openlevel": 75, + "movement": "stop", + } +} + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.chacon_dio.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock the config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="test_entry_unique_id", + data={ + CONF_USERNAME: "dummylogin", + CONF_PASSWORD: "dummypass", + }, + ) + + +@pytest.fixture +def mock_dio_chacon_client() -> Generator[AsyncMock]: + """Mock a Dio Chacon client.""" + + with ( + patch( + "homeassistant.components.chacon_dio.DIOChaconAPIClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.chacon_dio.config_flow.DIOChaconAPIClient", + new=mock_client, + ), + ): + client = mock_client.return_value + + # Default values for the tests using this mock : + client.get_user_id.return_value = "dummy-user-id" + client.search_all_devices.return_value = MOCK_COVER_DEVICE + + client.move_shutter_direction.return_value = {} + client.disconnect.return_value = {} + + yield client diff --git a/tests/components/chacon_dio/snapshots/test_cover.ambr b/tests/components/chacon_dio/snapshots/test_cover.ambr new file mode 100644 index 00000000000..b2febe20070 --- /dev/null +++ b/tests/components/chacon_dio/snapshots/test_cover.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_entities[cover.shutter_mock_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.shutter_mock_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'chacon_dio', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'L4HActuator_idmock1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[cover.shutter_mock_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 75, + 'device_class': 'shutter', + 'friendly_name': 'Shutter mock 1', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.shutter_mock_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- diff --git a/tests/components/chacon_dio/test_config_flow.py b/tests/components/chacon_dio/test_config_flow.py new file mode 100644 index 00000000000..d72b5a7dec3 --- /dev/null +++ b/tests/components/chacon_dio/test_config_flow.py @@ -0,0 +1,122 @@ +"""Test the chacon_dio config flow.""" + +from unittest.mock import AsyncMock + +from dio_chacon_wifi_api.exceptions import DIOChaconAPIError, DIOChaconInvalidAuthError +import pytest + +from homeassistant.components.chacon_dio.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_dio_chacon_client: AsyncMock +) -> None: + """Test the full flow.""" + + 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 not result["errors"] + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_USERNAME: "dummylogin", + CONF_PASSWORD: "dummypass", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Chacon DiO dummylogin" + assert result["result"].unique_id == "dummy-user-id" + assert result["data"] == { + CONF_USERNAME: "dummylogin", + CONF_PASSWORD: "dummypass", + } + + +@pytest.mark.parametrize( + ("exception", "expected"), + [ + (Exception("Bad request Boy :) --"), {"base": "unknown"}), + (DIOChaconInvalidAuthError, {"base": "invalid_auth"}), + (DIOChaconAPIError, {"base": "cannot_connect"}), + ], +) +async def test_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_dio_chacon_client: AsyncMock, + exception: Exception, + expected: dict[str, str], +) -> None: + """Test we handle any error.""" + mock_dio_chacon_client.get_user_id.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_USERNAME: "nada", + CONF_PASSWORD: "nadap", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == expected + + # Test of recover in normal state after correction of the 1st error + mock_dio_chacon_client.get_user_id.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "dummylogin", + CONF_PASSWORD: "dummypass", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Chacon DiO dummylogin" + assert result["result"].unique_id == "dummy-user-id" + assert result["data"] == { + CONF_USERNAME: "dummylogin", + CONF_PASSWORD: "dummypass", + } + + +async def test_duplicate_entry( + hass: HomeAssistant, + mock_dio_chacon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test abort when setting up duplicate entry.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + mock_dio_chacon_client.get_user_id.return_value = "test_entry_unique_id" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "dummylogin", + CONF_PASSWORD: "dummypass", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/chacon_dio/test_cover.py b/tests/components/chacon_dio/test_cover.py new file mode 100644 index 00000000000..be606e67e1e --- /dev/null +++ b/tests/components/chacon_dio/test_cover.py @@ -0,0 +1,157 @@ +"""Test the Chacon Dio cover.""" + +from collections.abc import Callable +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + SERVICE_STOP_COVER, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +COVER_ENTITY_ID = "cover.shutter_mock_1" + + +async def test_entities( + hass: HomeAssistant, + mock_dio_chacon_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the creation and values of the Chacon Dio covers.""" + + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_cover_actions( + hass: HomeAssistant, + mock_dio_chacon_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the creation and values of the Chacon Dio covers.""" + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: COVER_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(COVER_ENTITY_ID) + assert state.state == STATE_CLOSING + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: COVER_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(COVER_ENTITY_ID) + assert state.state == STATE_OPEN + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: COVER_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(COVER_ENTITY_ID) + assert state.state == STATE_OPENING + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_POSITION: 25, ATTR_ENTITY_ID: COVER_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(COVER_ENTITY_ID) + assert state.state == STATE_OPENING + + +async def test_cover_callbacks( + hass: HomeAssistant, + mock_dio_chacon_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the creation and values of the Chacon Dio covers.""" + + await setup_integration(hass, mock_config_entry) + + # Server side callback tests + # We find the callback method on the mock client + callback_device_state_function: Callable = ( + mock_dio_chacon_client.set_callback_device_state_by_device.call_args[0][1] + ) + + # Define a method to simply call it + async def _callback_device_state_function(open_level: int, movement: str) -> None: + callback_device_state_function( + { + "id": "L4HActuator_idmock1", + "connected": True, + "openlevel": open_level, + "movement": movement, + } + ) + await hass.async_block_till_done() + + # And call it to effectively launch the callback as the server would do + await _callback_device_state_function(79, "stop") + state = hass.states.get(COVER_ENTITY_ID) + assert state + assert state.attributes.get(ATTR_CURRENT_POSITION) == 79 + assert state.state == STATE_OPEN + + await _callback_device_state_function(90, "up") + state = hass.states.get(COVER_ENTITY_ID) + assert state + assert state.attributes.get(ATTR_CURRENT_POSITION) == 90 + assert state.state == STATE_OPENING + + await _callback_device_state_function(60, "down") + state = hass.states.get(COVER_ENTITY_ID) + assert state + assert state.attributes.get(ATTR_CURRENT_POSITION) == 60 + assert state.state == STATE_CLOSING + + +async def test_no_cover_found( + hass: HomeAssistant, + mock_dio_chacon_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the cover absence.""" + + mock_dio_chacon_client.search_all_devices.return_value = None + + await setup_integration(hass, mock_config_entry) + + assert not hass.states.get(COVER_ENTITY_ID) diff --git a/tests/components/chacon_dio/test_init.py b/tests/components/chacon_dio/test_init.py new file mode 100644 index 00000000000..78f1a85c71a --- /dev/null +++ b/tests/components/chacon_dio/test_init.py @@ -0,0 +1,43 @@ +"""Test the Dio Chacon Cover init.""" + +from unittest.mock import AsyncMock + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_cover_unload_entry( + hass: HomeAssistant, + mock_dio_chacon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the creation and values of the Dio Chacon covers.""" + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + mock_dio_chacon_client.disconnect.assert_called() + + +async def test_cover_shutdown_event( + hass: HomeAssistant, + mock_dio_chacon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the creation and values of the Dio Chacon covers.""" + + await setup_integration(hass, mock_config_entry) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + mock_dio_chacon_client.disconnect.assert_called()