diff --git a/CODEOWNERS b/CODEOWNERS index 04906e6bf88f41..13981b3f6f831c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1668,6 +1668,8 @@ build.json @home-assistant/supervisor /tests/components/wiz/ @sbidy /homeassistant/components/wled/ @frenck /tests/components/wled/ @frenck +/homeassistant/components/wmspro/ @mback2k +/tests/components/wmspro/ @mback2k /homeassistant/components/wolflink/ @adamkrol93 @mtielen /tests/components/wolflink/ @adamkrol93 @mtielen /homeassistant/components/workday/ @fabaff @gjohansson-ST diff --git a/homeassistant/components/wmspro/__init__.py b/homeassistant/components/wmspro/__init__.py new file mode 100644 index 00000000000000..c0c4a9e3950065 --- /dev/null +++ b/homeassistant/components/wmspro/__init__.py @@ -0,0 +1,66 @@ +"""The WMS WebControl pro API integration.""" + +from __future__ import annotations + +import aiohttp +from wmspro.webcontrol import WebControlPro + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import UNDEFINED + +from .const import DOMAIN, MANUFACTURER + +PLATFORMS: list[Platform] = [Platform.COVER] + +type WebControlProConfigEntry = ConfigEntry[WebControlPro] + + +async def async_setup_entry( + hass: HomeAssistant, entry: WebControlProConfigEntry +) -> bool: + """Set up wmspro from a config entry.""" + host = entry.data[CONF_HOST] + session = async_get_clientsession(hass) + hub = WebControlPro(host, session) + + try: + await hub.ping() + except aiohttp.ClientError as err: + raise ConfigEntryNotReady(f"Error while connecting to {host}") from err + + entry.runtime_data = hub + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} + if entry.unique_id + else UNDEFINED, + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer=MANUFACTURER, + model="WMS WebControl pro", + configuration_url=f"http://{hub.host}/system", + ) + + try: + await hub.refresh() + for dest in hub.dests.values(): + await dest.refresh() + except aiohttp.ClientError as err: + raise ConfigEntryNotReady(f"Error while refreshing from {host}") from err + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: WebControlProConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/wmspro/config_flow.py b/homeassistant/components/wmspro/config_flow.py new file mode 100644 index 00000000000000..ba3b5ef367d1af --- /dev/null +++ b/homeassistant/components/wmspro/config_flow.py @@ -0,0 +1,89 @@ +"""Config flow for WMS WebControl pro API integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import aiohttp +import voluptuous as vol +from wmspro.webcontrol import WebControlPro + +from homeassistant.components import dhcp +from homeassistant.components.dhcp import DhcpServiceInfo +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac + +from .const import DOMAIN, SUGGESTED_HOST + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) + + +class WebControlProConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for wmspro.""" + + VERSION = 1 + + async def async_step_dhcp( + self, discovery_info: dhcp.DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle the DHCP discovery step.""" + unique_id = format_mac(discovery_info.macaddress) + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + for entry in self.hass.config_entries.async_entries(DOMAIN): + if not entry.unique_id and entry.data[CONF_HOST] in ( + discovery_info.hostname, + discovery_info.ip, + ): + self.hass.config_entries.async_update_entry(entry, unique_id=unique_id) + return self.async_abort(reason="already_configured") + + return await self.async_step_user() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user-based step.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match(user_input) + host = user_input[CONF_HOST] + session = async_get_clientsession(self.hass) + hub = WebControlPro(host, session) + try: + pong = await hub.ping() + except aiohttp.ClientError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if not pong: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry(title=host, data=user_input) + + if self.source == dhcp.DOMAIN: + discovery_info: DhcpServiceInfo = self.init_data + data_values = {CONF_HOST: discovery_info.hostname or discovery_info.ip} + else: + data_values = {CONF_HOST: SUGGESTED_HOST} + + self.context["title_placeholders"] = data_values + data_schema = self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, data_values + ) + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) diff --git a/homeassistant/components/wmspro/const.py b/homeassistant/components/wmspro/const.py new file mode 100644 index 00000000000000..0a1036cf632234 --- /dev/null +++ b/homeassistant/components/wmspro/const.py @@ -0,0 +1,7 @@ +"""Constants for the WMS WebControl pro API integration.""" + +DOMAIN = "wmspro" +SUGGESTED_HOST = "webcontrol" + +ATTRIBUTION = "Data provided by WMS WebControl pro API" +MANUFACTURER = "WAREMA Renkhoff SE" diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py new file mode 100644 index 00000000000000..b8540a5bf08327 --- /dev/null +++ b/homeassistant/components/wmspro/cover.py @@ -0,0 +1,77 @@ +"""Support for covers connected with WMS WebControl pro.""" + +from __future__ import annotations + +from datetime import timedelta +from typing import Any + +from wmspro.const import ( + WMS_WebControl_pro_API_actionDescription, + WMS_WebControl_pro_API_actionType, +) + +from homeassistant.components.cover import ATTR_POSITION, CoverDeviceClass, CoverEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import WebControlProConfigEntry +from .entity import WebControlProGenericEntity + +SCAN_INTERVAL = timedelta(seconds=5) +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: WebControlProConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the WMS based covers from a config entry.""" + hub = config_entry.runtime_data + + entities: list[WebControlProGenericEntity] = [] + for dest in hub.dests.values(): + if dest.action(WMS_WebControl_pro_API_actionDescription.AwningDrive): + entities.append(WebControlProAwning(config_entry.entry_id, dest)) # noqa: PERF401 + + async_add_entities(entities) + + +class WebControlProAwning(WebControlProGenericEntity, CoverEntity): + """Representation of a WMS based awning.""" + + _attr_device_class = CoverDeviceClass.AWNING + + @property + def current_cover_position(self) -> int | None: + """Return current position of cover.""" + action = self._dest.action(WMS_WebControl_pro_API_actionDescription.AwningDrive) + return action["percentage"] + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + action = self._dest.action(WMS_WebControl_pro_API_actionDescription.AwningDrive) + await action(percentage=kwargs[ATTR_POSITION]) + + @property + def is_closed(self) -> bool | None: + """Return if the cover is closed.""" + return self.current_cover_position == 0 + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + action = self._dest.action(WMS_WebControl_pro_API_actionDescription.AwningDrive) + await action(percentage=100) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + action = self._dest.action(WMS_WebControl_pro_API_actionDescription.AwningDrive) + await action(percentage=0) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the device if in motion.""" + action = self._dest.action( + WMS_WebControl_pro_API_actionDescription.ManualCommand, + WMS_WebControl_pro_API_actionType.Stop, + ) + await action() diff --git a/homeassistant/components/wmspro/entity.py b/homeassistant/components/wmspro/entity.py new file mode 100644 index 00000000000000..0bbbc69a294f1d --- /dev/null +++ b/homeassistant/components/wmspro/entity.py @@ -0,0 +1,43 @@ +"""Generic entity for the WMS WebControl pro API integration.""" + +from __future__ import annotations + +from wmspro.destination import Destination + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import ATTRIBUTION, DOMAIN, MANUFACTURER + + +class WebControlProGenericEntity(Entity): + """Foundation of all WMS based entities.""" + + _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, config_entry_id: str, dest: Destination) -> None: + """Initialize the entity with destination channel.""" + dest_id_str = str(dest.id) + self._dest = dest + self._attr_unique_id = dest_id_str + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, dest_id_str)}, + manufacturer=MANUFACTURER, + model=dest.animationType.name, + name=dest.name, + serial_number=dest_id_str, + suggested_area=dest.room.name, + via_device=(DOMAIN, config_entry_id), + configuration_url=f"http://{dest.host}/control", + ) + + async def async_update(self) -> None: + """Update the entity.""" + await self._dest.refresh() + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self._dest.available diff --git a/homeassistant/components/wmspro/manifest.json b/homeassistant/components/wmspro/manifest.json new file mode 100644 index 00000000000000..ec97f444a54cc8 --- /dev/null +++ b/homeassistant/components/wmspro/manifest.json @@ -0,0 +1,19 @@ +{ + "domain": "wmspro", + "name": "WMS WebControl pro", + "codeowners": ["@mback2k"], + "config_flow": true, + "dependencies": [], + "dhcp": [ + { + "macaddress": "0023D5*" + }, + { + "registered_devices": true + } + ], + "documentation": "https://www.home-assistant.io/integrations/wmspro", + "integration_type": "hub", + "iot_class": "local_polling", + "requirements": ["pywmspro==0.1.0"] +} diff --git a/homeassistant/components/wmspro/strings.json b/homeassistant/components/wmspro/strings.json new file mode 100644 index 00000000000000..9b6d129905b259 --- /dev/null +++ b/homeassistant/components/wmspro/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "flow_title": "{host}", + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your WMS WebControl pro." + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b26519c6319033..55fa5f116e6c09 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -670,6 +670,7 @@ "withings", "wiz", "wled", + "wmspro", "wolflink", "workday", "worldclock", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 8f5964f1618100..757c43c96a7838 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -1089,6 +1089,14 @@ "domain": "wiz", "hostname": "wiz_*", }, + { + "domain": "wmspro", + "macaddress": "0023D5*", + }, + { + "domain": "wmspro", + "registered_devices": True, + }, { "domain": "yale", "hostname": "yale-connect-plus", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9963409f62e28d..cb550f38bc3989 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6942,6 +6942,12 @@ "config_flow": true, "iot_class": "local_push" }, + "wmspro": { + "name": "WMS WebControl pro", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "wolflink": { "name": "Wolf SmartSet Service", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 1aaccce6e06c88..a314b6c51cb831 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2470,6 +2470,9 @@ pywilight==0.0.74 # homeassistant.components.wiz pywizlight==0.5.14 +# homeassistant.components.wmspro +pywmspro==0.1.0 + # homeassistant.components.ws66i pyws66i==1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 15f261592992bc..d0341c2502bc02 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1970,6 +1970,9 @@ pywilight==0.0.74 # homeassistant.components.wiz pywizlight==0.5.14 +# homeassistant.components.wmspro +pywmspro==0.1.0 + # homeassistant.components.ws66i pyws66i==1.1 diff --git a/tests/components/wmspro/__init__.py b/tests/components/wmspro/__init__.py new file mode 100644 index 00000000000000..fee2fc64849c5f --- /dev/null +++ b/tests/components/wmspro/__init__.py @@ -0,0 +1,16 @@ +"""Tests for the wmspro integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> bool: + """Set up a config entry.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return result diff --git a/tests/components/wmspro/conftest.py b/tests/components/wmspro/conftest.py new file mode 100644 index 00000000000000..76c11e71316bb4 --- /dev/null +++ b/tests/components/wmspro/conftest.py @@ -0,0 +1,106 @@ +"""Common fixtures for the wmspro tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.wmspro.const import DOMAIN +from homeassistant.const import CONF_HOST + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a dummy config entry.""" + return MockConfigEntry( + title="WebControl", + domain=DOMAIN, + data={CONF_HOST: "webcontrol"}, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.wmspro.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_hub_ping() -> Generator[AsyncMock]: + """Override WebControlPro.ping.""" + with patch( + "wmspro.webcontrol.WebControlPro.ping", + return_value=True, + ) as mock_hub_ping: + yield mock_hub_ping + + +@pytest.fixture +def mock_hub_refresh() -> Generator[AsyncMock]: + """Override WebControlPro.refresh.""" + with patch( + "wmspro.webcontrol.WebControlPro.refresh", + return_value=True, + ) as mock_hub_refresh: + yield mock_hub_refresh + + +@pytest.fixture +def mock_hub_configuration_test() -> Generator[AsyncMock]: + """Override WebControlPro.configuration.""" + with patch( + "wmspro.webcontrol.WebControlPro._getConfiguration", + return_value=load_json_object_fixture("example_config_test.json", DOMAIN), + ) as mock_hub_configuration: + yield mock_hub_configuration + + +@pytest.fixture +def mock_hub_configuration_prod() -> Generator[AsyncMock]: + """Override WebControlPro._getConfiguration.""" + with patch( + "wmspro.webcontrol.WebControlPro._getConfiguration", + return_value=load_json_object_fixture("example_config_prod.json", DOMAIN), + ) as mock_hub_configuration: + yield mock_hub_configuration + + +@pytest.fixture +def mock_hub_status_prod_awning() -> Generator[AsyncMock]: + """Override WebControlPro._getStatus.""" + with patch( + "wmspro.webcontrol.WebControlPro._getStatus", + return_value=load_json_object_fixture( + "example_status_prod_awning.json", DOMAIN + ), + ) as mock_dest_refresh: + yield mock_dest_refresh + + +@pytest.fixture +def mock_dest_refresh() -> Generator[AsyncMock]: + """Override Destination.refresh.""" + with patch( + "wmspro.destination.Destination.refresh", + return_value=True, + ) as mock_dest_refresh: + yield mock_dest_refresh + + +@pytest.fixture +def mock_action_call() -> Generator[AsyncMock]: + """Override Action.__call__.""" + + async def fake_call(self, **kwargs): + self._update_params(kwargs) + + with patch( + "wmspro.action.Action.__call__", + fake_call, + ) as mock_action_call: + yield mock_action_call diff --git a/tests/components/wmspro/fixtures/example_config_prod.json b/tests/components/wmspro/fixtures/example_config_prod.json new file mode 100644 index 00000000000000..6e313b566f7146 --- /dev/null +++ b/tests/components/wmspro/fixtures/example_config_prod.json @@ -0,0 +1,77 @@ +{ + "command": "getConfiguration", + "protocolVersion": "1.0.0", + "destinations": [ + { + "id": 58717, + "animationType": 1, + "names": ["Markise", "", "", ""], + "actions": [ + { + "id": 0, + "actionType": 0, + "actionDescription": 0, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 16, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + }, + { + "id": 97358, + "animationType": 6, + "names": ["Licht", "", "", ""], + "actions": [ + { + "id": 0, + "actionType": 0, + "actionDescription": 8, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 17, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 20, + "actionType": 4, + "actionDescription": 6 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + } + ], + "rooms": [ + { + "id": 19239, + "name": "Terrasse", + "destinations": [58717, 97358], + "scenes": [687471, 765095] + } + ], + "scenes": [ + { + "id": 687471, + "names": ["Licht an", "", "", ""] + }, + { + "id": 765095, + "names": ["Licht aus", "", "", ""] + } + ] +} diff --git a/tests/components/wmspro/fixtures/example_config_test.json b/tests/components/wmspro/fixtures/example_config_test.json new file mode 100644 index 00000000000000..1bb63e089ad43e --- /dev/null +++ b/tests/components/wmspro/fixtures/example_config_test.json @@ -0,0 +1,75 @@ +{ + "command": "getConfiguration", + "protocolVersion": "1.0.0", + "destinations": [ + { + "id": 17776, + "animationType": 0, + "names": ["Küche", "", "", ""], + "actions": [ + { + "id": 0, + "actionType": 0, + "actionDescription": 2, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 6, + "actionType": 2, + "actionDescription": 3, + "minValue": -127, + "maxValue": 127 + }, + { + "id": 16, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + }, + { + "id": 23, + "actionType": 7, + "actionDescription": 12 + } + ] + }, + { + "id": 200951, + "animationType": 999, + "names": ["Aktor Potentialfrei", "", "", ""], + "actions": [ + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + }, + { + "id": 26, + "actionType": 9, + "actionDescription": 999, + "minValue": 0, + "maxValue": 16 + } + ] + } + ], + "rooms": [ + { + "id": 42581, + "name": "Raum 0", + "destinations": [17776, 116682, 194367, 200951], + "scenes": [688966] + } + ], + "scenes": [ + { + "id": 688966, + "names": ["Gute Nacht", "", "", ""] + } + ] +} diff --git a/tests/components/wmspro/fixtures/example_status_prod_awning.json b/tests/components/wmspro/fixtures/example_status_prod_awning.json new file mode 100644 index 00000000000000..6ca697a45322b4 --- /dev/null +++ b/tests/components/wmspro/fixtures/example_status_prod_awning.json @@ -0,0 +1,22 @@ +{ + "command": "getStatus", + "protocolVersion": "1.0.0", + "details": [ + { + "destinationId": 58717, + "data": { + "drivingCause": 0, + "heartbeatError": false, + "blocking": false, + "productData": [ + { + "actionId": 0, + "value": { + "percentage": 100 + } + } + ] + } + } + ] +} diff --git a/tests/components/wmspro/snapshots/test_cover.ambr b/tests/components/wmspro/snapshots/test_cover.ambr new file mode 100644 index 00000000000000..21042789c16fcf --- /dev/null +++ b/tests/components/wmspro/snapshots/test_cover.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_cover_device + DeviceRegistryEntrySnapshot({ + 'area_id': 'terrasse', + 'config_entries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '58717', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'Awning', + 'model_id': None, + 'name': 'Markise', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '58717', + 'suggested_area': 'Terrasse', + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_cover_update + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by WMS WebControl pro API', + 'current_position': 100, + 'device_class': 'awning', + 'friendly_name': 'Markise', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.markise', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- diff --git a/tests/components/wmspro/test_config_flow.py b/tests/components/wmspro/test_config_flow.py new file mode 100644 index 00000000000000..6a254a93836e7a --- /dev/null +++ b/tests/components/wmspro/test_config_flow.py @@ -0,0 +1,235 @@ +"""Test the wmspro config flow.""" + +from unittest.mock import AsyncMock, patch + +import aiohttp + +from homeassistant.components.dhcp import DhcpServiceInfo +from homeassistant.components.wmspro.const import DOMAIN +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_config_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we can handle user-input to create a config entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "wmspro.webcontrol.WebControlPro.ping", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.2.3.4", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "1.2.3.4" + assert result["data"] == { + CONF_HOST: "1.2.3.4", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_config_flow_from_dhcp( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we can handle DHCP discovery to create a config entry.""" + info = DhcpServiceInfo( + ip="1.2.3.4", hostname="webcontrol", macaddress="00:11:22:33:44:55" + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=info + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "wmspro.webcontrol.WebControlPro.ping", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.2.3.4", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "1.2.3.4" + assert result["data"] == { + CONF_HOST: "1.2.3.4", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_config_flow_from_dhcp_add_mac( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test we can use DHCP discovery to add MAC address to a config entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "wmspro.webcontrol.WebControlPro.ping", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.2.3.4", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "1.2.3.4" + assert result["data"] == { + CONF_HOST: "1.2.3.4", + } + assert len(mock_setup_entry.mock_calls) == 1 + assert hass.config_entries.async_entries(DOMAIN)[0].unique_id is None + + info = DhcpServiceInfo( + ip="1.2.3.4", hostname="webcontrol", macaddress="00:11:22:33:44:55" + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=info + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == "00:11:22:33:44:55" + + +async def test_config_flow_ping_failed( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle ping failed error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "wmspro.webcontrol.WebControlPro.ping", + return_value=False, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.2.3.4", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + with patch( + "wmspro.webcontrol.WebControlPro.ping", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.2.3.4", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "1.2.3.4" + assert result["data"] == { + CONF_HOST: "1.2.3.4", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_config_flow_cannot_connect( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "wmspro.webcontrol.WebControlPro.ping", + side_effect=aiohttp.ClientError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.2.3.4", + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + with patch( + "wmspro.webcontrol.WebControlPro.ping", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.2.3.4", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "1.2.3.4" + assert result["data"] == { + CONF_HOST: "1.2.3.4", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_config_flow_unknown_error( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle an unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "wmspro.webcontrol.WebControlPro.ping", + side_effect=RuntimeError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.2.3.4", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + with patch( + "wmspro.webcontrol.WebControlPro.ping", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.2.3.4", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "1.2.3.4" + assert result["data"] == { + CONF_HOST: "1.2.3.4", + } + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/wmspro/test_cover.py b/tests/components/wmspro/test_cover.py new file mode 100644 index 00000000000000..1e8653335a7ee2 --- /dev/null +++ b/tests/components/wmspro/test_cover.py @@ -0,0 +1,226 @@ +"""Test the wmspro diagnostics.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.wmspro.const import DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + SERVICE_STOP_COVER, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from . import setup_config_entry + +from tests.common import MockConfigEntry + + +async def test_cover_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_configuration_prod: AsyncMock, + mock_hub_status_prod_awning: AsyncMock, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that a cover device is created correctly.""" + assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_configuration_prod.mock_calls) == 1 + assert len(mock_hub_status_prod_awning.mock_calls) == 2 + + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "58717")}) + assert device_entry is not None + assert device_entry == snapshot + + +async def test_cover_update( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_configuration_prod: AsyncMock, + mock_hub_status_prod_awning: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test that a cover entity is created and updated correctly.""" + assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_configuration_prod.mock_calls) == 1 + assert len(mock_hub_status_prod_awning.mock_calls) == 2 + + entity = hass.states.get("cover.markise") + assert entity is not None + assert entity == snapshot + + await async_setup_component(hass, "homeassistant", {}) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: entity.entity_id}, + blocking=True, + ) + + assert len(mock_hub_status_prod_awning.mock_calls) == 3 + + +async def test_cover_close_and_open( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_configuration_prod: AsyncMock, + mock_hub_status_prod_awning: AsyncMock, + mock_action_call: AsyncMock, +) -> None: + """Test that a cover entity is opened and closed correctly.""" + assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_configuration_prod.mock_calls) == 1 + assert len(mock_hub_status_prod_awning.mock_calls) >= 1 + + entity = hass.states.get("cover.markise") + assert entity is not None + assert entity.state == "open" + assert entity.attributes["current_position"] == 100 + + with patch( + "wmspro.destination.Destination.refresh", + return_value=True, + ): + before = len(mock_hub_status_prod_awning.mock_calls) + + await hass.services.async_call( + Platform.COVER, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity.entity_id}, + blocking=True, + ) + + entity = hass.states.get("cover.markise") + assert entity is not None + assert entity.state == "closed" + assert entity.attributes["current_position"] == 0 + assert len(mock_hub_status_prod_awning.mock_calls) == before + + with patch( + "wmspro.destination.Destination.refresh", + return_value=True, + ): + before = len(mock_hub_status_prod_awning.mock_calls) + + await hass.services.async_call( + Platform.COVER, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity.entity_id}, + blocking=True, + ) + + entity = hass.states.get("cover.markise") + assert entity is not None + assert entity.state == "open" + assert entity.attributes["current_position"] == 100 + assert len(mock_hub_status_prod_awning.mock_calls) == before + + +async def test_cover_move( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_configuration_prod: AsyncMock, + mock_hub_status_prod_awning: AsyncMock, + mock_action_call: AsyncMock, +) -> None: + """Test that a cover entity is moved and closed correctly.""" + assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_configuration_prod.mock_calls) == 1 + assert len(mock_hub_status_prod_awning.mock_calls) >= 1 + + entity = hass.states.get("cover.markise") + assert entity is not None + assert entity.state == "open" + assert entity.attributes["current_position"] == 100 + + with patch( + "wmspro.destination.Destination.refresh", + return_value=True, + ): + before = len(mock_hub_status_prod_awning.mock_calls) + + await hass.services.async_call( + Platform.COVER, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: entity.entity_id, "position": 50}, + blocking=True, + ) + + entity = hass.states.get("cover.markise") + assert entity is not None + assert entity.state == "open" + assert entity.attributes["current_position"] == 50 + assert len(mock_hub_status_prod_awning.mock_calls) == before + + +async def test_cover_move_and_stop( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_configuration_prod: AsyncMock, + mock_hub_status_prod_awning: AsyncMock, + mock_action_call: AsyncMock, +) -> None: + """Test that a cover entity is moved and closed correctly.""" + assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_configuration_prod.mock_calls) == 1 + assert len(mock_hub_status_prod_awning.mock_calls) >= 1 + + entity = hass.states.get("cover.markise") + assert entity is not None + assert entity.state == "open" + assert entity.attributes["current_position"] == 100 + + with patch( + "wmspro.destination.Destination.refresh", + return_value=True, + ): + before = len(mock_hub_status_prod_awning.mock_calls) + + await hass.services.async_call( + Platform.COVER, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: entity.entity_id, "position": 80}, + blocking=True, + ) + + entity = hass.states.get("cover.markise") + assert entity is not None + assert entity.state == "open" + assert entity.attributes["current_position"] == 80 + assert len(mock_hub_status_prod_awning.mock_calls) == before + + with patch( + "wmspro.destination.Destination.refresh", + return_value=True, + ): + before = len(mock_hub_status_prod_awning.mock_calls) + + await hass.services.async_call( + Platform.COVER, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: entity.entity_id}, + blocking=True, + ) + + entity = hass.states.get("cover.markise") + assert entity is not None + assert entity.state == "open" + assert entity.attributes["current_position"] == 80 + assert len(mock_hub_status_prod_awning.mock_calls) == before diff --git a/tests/components/wmspro/test_init.py b/tests/components/wmspro/test_init.py new file mode 100644 index 00000000000000..aeb5f3db152ef0 --- /dev/null +++ b/tests/components/wmspro/test_init.py @@ -0,0 +1,38 @@ +"""Test the wmspro initialization.""" + +from unittest.mock import AsyncMock + +import aiohttp + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_config_entry + +from tests.common import MockConfigEntry + + +async def test_config_entry_device_config_ping_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, +) -> None: + """Test that a config entry will be retried due to ConfigEntryNotReady.""" + mock_hub_ping.side_effect = aiohttp.ClientError + await setup_config_entry(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert len(mock_hub_ping.mock_calls) == 1 + + +async def test_config_entry_device_config_refresh_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_refresh: AsyncMock, +) -> None: + """Test that a config entry will be retried due to ConfigEntryNotReady.""" + mock_hub_refresh.side_effect = aiohttp.ClientError + await setup_config_entry(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_refresh.mock_calls) == 1