Skip to content

Commit

Permalink
Add new integration for WMS WebControl pro using local API (#124176)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
mback2k and joostlek authored Sep 16, 2024
1 parent 4fbc5a9 commit 587ebd5
Show file tree
Hide file tree
Showing 22 changed files with 1,194 additions and 0 deletions.
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions homeassistant/components/wmspro/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
89 changes: 89 additions & 0 deletions homeassistant/components/wmspro/config_flow.py
Original file line number Diff line number Diff line change
@@ -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
)
7 changes: 7 additions & 0 deletions homeassistant/components/wmspro/const.py
Original file line number Diff line number Diff line change
@@ -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"
77 changes: 77 additions & 0 deletions homeassistant/components/wmspro/cover.py
Original file line number Diff line number Diff line change
@@ -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()
43 changes: 43 additions & 0 deletions homeassistant/components/wmspro/entity.py
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions homeassistant/components/wmspro/manifest.json
Original file line number Diff line number Diff line change
@@ -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"]
}
25 changes: 25 additions & 0 deletions homeassistant/components/wmspro/strings.json
Original file line number Diff line number Diff line change
@@ -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%]"
}
}
}
1 change: 1 addition & 0 deletions homeassistant/generated/config_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,7 @@
"withings",
"wiz",
"wled",
"wmspro",
"wolflink",
"workday",
"worldclock",
Expand Down
8 changes: 8 additions & 0 deletions homeassistant/generated/dhcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -1089,6 +1089,14 @@
"domain": "wiz",
"hostname": "wiz_*",
},
{
"domain": "wmspro",
"macaddress": "0023D5*",
},
{
"domain": "wmspro",
"registered_devices": True,
},
{
"domain": "yale",
"hostname": "yale-connect-plus",
Expand Down
6 changes: 6 additions & 0 deletions homeassistant/generated/integrations.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions requirements_test_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading

0 comments on commit 587ebd5

Please sign in to comment.