Add new integration for WMS WebControl pro using local API (#124176)

* Add new integration for WMS WebControl pro using local API

Warema recently released a new local API for their WMS hub
called "WebControl pro". This integration makes use of the
new local API via a new dedicated Python library pywmspro.

For now this integration only supports awnings as covers.
But pywmspro is device-agnostic to ease future extensions.

* Incorporated review feedback from joostlek

Thanks a lot!

* Incorporated more review feedback from joostlek

Thanks a lot!

* Incorporated more review feedback from joostlek

Thanks a lot!

* Fix

* Follow-up fix

* Improve handling of DHCP discovery

* Further test improvements suggested by joostlek, thanks!

---------

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
Marc Hörsken 2024-09-16 16:07:43 +02:00 committed by GitHub
parent 4fbc5a9558
commit 587ebd5d47
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1194 additions and 0 deletions

View File

@ -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

View File

@ -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)

View File

@ -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
)

View File

@ -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"

View File

@ -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()

View File

@ -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

View File

@ -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"]
}

View File

@ -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%]"
}
}
}

View File

@ -670,6 +670,7 @@ FLOWS = {
"withings",
"wiz",
"wled",
"wmspro",
"wolflink",
"workday",
"worldclock",

View File

@ -1089,6 +1089,14 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"domain": "wiz",
"hostname": "wiz_*",
},
{
"domain": "wmspro",
"macaddress": "0023D5*",
},
{
"domain": "wmspro",
"registered_devices": True,
},
{
"domain": "yale",
"hostname": "yale-connect-plus",

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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", "", "", ""]
}
]
}

View File

@ -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", "", "", ""]
}
]
}

View File

@ -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
}
}
]
}
}
]
}

View File

@ -0,0 +1,50 @@
# serializer version: 1
# name: test_cover_device
DeviceRegistryEntrySnapshot({
'area_id': 'terrasse',
'config_entries': <ANY>,
'configuration_url': 'http://webcontrol/control',
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'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': <ANY>,
'serial_number': '58717',
'suggested_area': 'Terrasse',
'sw_version': None,
'via_device_id': <ANY>,
})
# ---
# 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': <CoverEntityFeature: 15>,
}),
'context': <ANY>,
'entity_id': 'cover.markise',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'open',
})
# ---

View File

@ -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

View File

@ -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

View File

@ -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