Add Tailwind zeroconf discovery (#105949)
parent
f912b9c34a
commit
90fef6b9c9
|
@ -4,17 +4,21 @@ from __future__ import annotations
|
|||
from typing import Any
|
||||
|
||||
from gotailwind import (
|
||||
MIN_REQUIRED_FIRMWARE_VERSION,
|
||||
Tailwind,
|
||||
TailwindAuthenticationError,
|
||||
TailwindConnectionError,
|
||||
TailwindUnsupportedFirmwareVersionError,
|
||||
tailwind_device_id_to_mac_address,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import zeroconf
|
||||
from homeassistant.config_entries import ConfigFlow
|
||||
from homeassistant.const import CONF_HOST, CONF_TOKEN
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.data_entry_flow import AbortFlow, FlowResult
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.selector import (
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
|
@ -23,12 +27,18 @@ from homeassistant.helpers.selector import (
|
|||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
LOCAL_CONTROL_KEY_URL = (
|
||||
"https://web.gotailwind.com/client/integration/local-control-key"
|
||||
)
|
||||
|
||||
|
||||
class TailwindFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a Tailwind config flow."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
host: str
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
|
@ -36,15 +46,13 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
tailwind = Tailwind(
|
||||
host=user_input[CONF_HOST],
|
||||
token=user_input[CONF_TOKEN],
|
||||
session=async_get_clientsession(self.hass),
|
||||
)
|
||||
try:
|
||||
status = await tailwind.status()
|
||||
except TailwindUnsupportedFirmwareVersionError:
|
||||
return self.async_abort(reason="unsupported_firmware")
|
||||
return await self._async_step_create_entry(
|
||||
host=user_input[CONF_HOST],
|
||||
token=user_input[CONF_TOKEN],
|
||||
)
|
||||
except AbortFlow:
|
||||
raise
|
||||
except TailwindAuthenticationError:
|
||||
errors[CONF_TOKEN] = "invalid_auth"
|
||||
except TailwindConnectionError:
|
||||
|
@ -52,11 +60,6 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||
except Exception: # pylint: disable=broad-except
|
||||
LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=f"Tailwind {status.product}",
|
||||
data=user_input,
|
||||
)
|
||||
else:
|
||||
user_input = {}
|
||||
|
||||
|
@ -72,8 +75,93 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||
),
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"url": "https://web.gotailwind.com/client/integration/local-control-key",
|
||||
},
|
||||
description_placeholders={"url": LOCAL_CONTROL_KEY_URL},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: zeroconf.ZeroconfServiceInfo
|
||||
) -> FlowResult:
|
||||
"""Handle zeroconf discovery of a Tailwind device."""
|
||||
if not (device_id := discovery_info.properties.get("device_id")):
|
||||
return self.async_abort(reason="no_device_id")
|
||||
|
||||
if (
|
||||
version := discovery_info.properties.get("SW ver")
|
||||
) and version < MIN_REQUIRED_FIRMWARE_VERSION:
|
||||
return self.async_abort(reason="unsupported_firmware")
|
||||
|
||||
await self.async_set_unique_id(
|
||||
format_mac(tailwind_device_id_to_mac_address(device_id))
|
||||
)
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host})
|
||||
|
||||
self.host = discovery_info.host
|
||||
self.context.update(
|
||||
{
|
||||
"title_placeholders": {
|
||||
"name": f"Tailwind {discovery_info.properties.get('product')}"
|
||||
},
|
||||
"configuration_url": LOCAL_CONTROL_KEY_URL,
|
||||
}
|
||||
)
|
||||
return await self.async_step_zeroconf_confirm()
|
||||
|
||||
async def async_step_zeroconf_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle a flow initiated by zeroconf."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
return await self._async_step_create_entry(
|
||||
host=self.host,
|
||||
token=user_input[CONF_TOKEN],
|
||||
)
|
||||
except TailwindAuthenticationError:
|
||||
errors[CONF_TOKEN] = "invalid_auth"
|
||||
except TailwindConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception: # pylint: disable=broad-except
|
||||
LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="zeroconf_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_TOKEN): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.PASSWORD)
|
||||
),
|
||||
}
|
||||
),
|
||||
description_placeholders={"url": LOCAL_CONTROL_KEY_URL},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def _async_step_create_entry(self, *, host: str, token: str) -> FlowResult:
|
||||
"""Create entry."""
|
||||
tailwind = Tailwind(
|
||||
host=host, token=token, session=async_get_clientsession(self.hass)
|
||||
)
|
||||
|
||||
try:
|
||||
status = await tailwind.status()
|
||||
except TailwindUnsupportedFirmwareVersionError:
|
||||
return self.async_abort(reason="unsupported_firmware")
|
||||
|
||||
await self.async_set_unique_id(
|
||||
format_mac(status.mac_address), raise_on_progress=False
|
||||
)
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={
|
||||
CONF_HOST: host,
|
||||
CONF_TOKEN: token,
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=f"Tailwind {status.product}",
|
||||
data={CONF_HOST: host, CONF_TOKEN: token},
|
||||
)
|
||||
|
|
|
@ -6,5 +6,13 @@
|
|||
"documentation": "https://www.home-assistant.io/integrations/tailwind",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["gotailwind==0.2.1"]
|
||||
"requirements": ["gotailwind==0.2.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_http._tcp.local.",
|
||||
"properties": {
|
||||
"vendor": "tailwind"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -11,6 +11,15 @@
|
|||
"host": "The hostname or IP address of your Tailwind device. You can find the IP address by going into the Tailwind app and selecting your Tailwind device's cog icon. The IP address is shown in the **Device Info** section.",
|
||||
"token": "To find local control key token, browse to the [Tailwind web portal]({url}), log in with your Tailwind account, and select the [**Local Control Key**]({url}) tab. The 6-digit number shown is your local control key token."
|
||||
}
|
||||
},
|
||||
"zeroconf_confirm": {
|
||||
"description": "Set up your discovered Tailwind garage door opener to integrate with Home Assistant.\n\nTo do so, you will need to get the local control key of your Tailwind device. For more details, see the description below the field down below.",
|
||||
"data": {
|
||||
"token": "[%key:component::tailwind::config::step::user::data::token%]"
|
||||
},
|
||||
"data_description": {
|
||||
"token": "[%key:component::tailwind::config::step::user::data_description::token%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
@ -21,6 +30,7 @@
|
|||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"no_device_id": "The discovered Tailwind device did not provide a device ID.",
|
||||
"unsupported_firmware": "The firmware of your Tailwind device is not supported. Please update your Tailwind device to the latest firmware version using the Tailwind app."
|
||||
}
|
||||
},
|
||||
|
|
|
@ -494,6 +494,12 @@ ZEROCONF = {
|
|||
"vendor": "synology*",
|
||||
},
|
||||
},
|
||||
{
|
||||
"domain": "tailwind",
|
||||
"properties": {
|
||||
"vendor": "tailwind",
|
||||
},
|
||||
},
|
||||
],
|
||||
"_hue._tcp.local.": [
|
||||
{
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
FlowResultSnapshot({
|
||||
'context': dict({
|
||||
'source': 'user',
|
||||
'unique_id': '3c:e9:0e:6d:21:84',
|
||||
}),
|
||||
'data': dict({
|
||||
'host': '127.0.0.1',
|
||||
|
@ -30,7 +31,51 @@
|
|||
'pref_disable_polling': False,
|
||||
'source': 'user',
|
||||
'title': 'Tailwind iQ3',
|
||||
'unique_id': None,
|
||||
'unique_id': '3c:e9:0e:6d:21:84',
|
||||
'version': 1,
|
||||
}),
|
||||
'title': 'Tailwind iQ3',
|
||||
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
# name: test_zeroconf_flow
|
||||
FlowResultSnapshot({
|
||||
'context': dict({
|
||||
'configuration_url': 'https://web.gotailwind.com/client/integration/local-control-key',
|
||||
'source': 'zeroconf',
|
||||
'title_placeholders': dict({
|
||||
'name': 'Tailwind iQ3',
|
||||
}),
|
||||
'unique_id': '3c:e9:0e:6d:21:84',
|
||||
}),
|
||||
'data': dict({
|
||||
'host': '127.0.0.1',
|
||||
'token': '987654',
|
||||
}),
|
||||
'description': None,
|
||||
'description_placeholders': None,
|
||||
'flow_id': <ANY>,
|
||||
'handler': 'tailwind',
|
||||
'minor_version': 1,
|
||||
'options': dict({
|
||||
}),
|
||||
'result': ConfigEntrySnapshot({
|
||||
'data': dict({
|
||||
'host': '127.0.0.1',
|
||||
'token': '987654',
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'domain': 'tailwind',
|
||||
'entry_id': <ANY>,
|
||||
'minor_version': 1,
|
||||
'options': dict({
|
||||
}),
|
||||
'pref_disable_new_entities': False,
|
||||
'pref_disable_polling': False,
|
||||
'source': 'zeroconf',
|
||||
'title': 'Tailwind iQ3',
|
||||
'unique_id': '3c:e9:0e:6d:21:84',
|
||||
'version': 1,
|
||||
}),
|
||||
'title': 'Tailwind iQ3',
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
"""Configuration flow tests for the Tailwind integration."""
|
||||
from ipaddress import ip_address
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from gotailwind import (
|
||||
|
@ -9,12 +10,15 @@ from gotailwind import (
|
|||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components import zeroconf
|
||||
from homeassistant.components.tailwind.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
|
||||
from homeassistant.const import CONF_HOST, CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
|
||||
|
||||
|
||||
|
@ -85,7 +89,7 @@ async def test_user_flow_errors(
|
|||
assert result2.get("type") == FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
async def test_unsupported_firmware_version(
|
||||
async def test_user_flow_unsupported_firmware_version(
|
||||
hass: HomeAssistant, mock_tailwind: MagicMock
|
||||
) -> None:
|
||||
"""Test configuration flow aborts when the firmware version is not supported."""
|
||||
|
@ -101,3 +105,191 @@ async def test_unsupported_firmware_version(
|
|||
|
||||
assert result.get("type") == FlowResultType.ABORT
|
||||
assert result.get("reason") == "unsupported_firmware"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_tailwind")
|
||||
async def test_user_flow_already_configured(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test configuration flow aborts when the device is already configured.
|
||||
|
||||
Also, ensures the existing config entry is updated with the new host.
|
||||
"""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
assert mock_config_entry.data[CONF_HOST] == "127.0.0.127"
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
data={
|
||||
CONF_HOST: "127.0.0.1",
|
||||
CONF_TOKEN: "987654",
|
||||
},
|
||||
)
|
||||
|
||||
assert result.get("type") == FlowResultType.ABORT
|
||||
assert result.get("reason") == "already_configured"
|
||||
assert mock_config_entry.data[CONF_HOST] == "127.0.0.1"
|
||||
assert mock_config_entry.data[CONF_TOKEN] == "987654"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_tailwind")
|
||||
async def test_zeroconf_flow(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test the zeroconf happy flow from start to finish."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_ZEROCONF},
|
||||
data=zeroconf.ZeroconfServiceInfo(
|
||||
ip_address=ip_address("127.0.0.1"),
|
||||
ip_addresses=[ip_address("127.0.0.1")],
|
||||
port=80,
|
||||
hostname="tailwind-3ce90e6d2184.local.",
|
||||
name="mock_name",
|
||||
properties={
|
||||
"device_id": "_3c_e9_e_6d_21_84_",
|
||||
"product": "iQ3",
|
||||
"SW ver": "10.10",
|
||||
"vendor": "tailwind",
|
||||
},
|
||||
type="mock_type",
|
||||
),
|
||||
)
|
||||
|
||||
assert result.get("step_id") == "zeroconf_confirm"
|
||||
assert result.get("type") == FlowResultType.FORM
|
||||
|
||||
progress = hass.config_entries.flow.async_progress()
|
||||
assert len(progress) == 1
|
||||
assert progress[0].get("flow_id") == result["flow_id"]
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_TOKEN: "987654"}
|
||||
)
|
||||
|
||||
assert result2.get("type") == FlowResultType.CREATE_ENTRY
|
||||
assert result2 == snapshot
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("properties", "expected_reason"),
|
||||
[
|
||||
({"SW ver": "10.10"}, "no_device_id"),
|
||||
({"device_id": "_3c_e9_e_6d_21_84_", "SW ver": "0.0"}, "unsupported_firmware"),
|
||||
],
|
||||
)
|
||||
async def test_zeroconf_flow_abort_incompatible_properties(
|
||||
hass: HomeAssistant, properties: dict[str, str], expected_reason: str
|
||||
) -> None:
|
||||
"""Test the zeroconf aborts when it advertises incompatible data."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_ZEROCONF},
|
||||
data=zeroconf.ZeroconfServiceInfo(
|
||||
ip_address=ip_address("127.0.0.1"),
|
||||
ip_addresses=[ip_address("127.0.0.1")],
|
||||
port=80,
|
||||
hostname="tailwind-3ce90e6d2184.local.",
|
||||
name="mock_name",
|
||||
properties=properties,
|
||||
type="mock_type",
|
||||
),
|
||||
)
|
||||
|
||||
assert result.get("type") == FlowResultType.ABORT
|
||||
assert result.get("reason") == expected_reason
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "expected_error"),
|
||||
[
|
||||
(TailwindConnectionError, {"base": "cannot_connect"}),
|
||||
(TailwindAuthenticationError, {CONF_TOKEN: "invalid_auth"}),
|
||||
(Exception, {"base": "unknown"}),
|
||||
],
|
||||
)
|
||||
async def test_zeroconf_flow_errors(
|
||||
hass: HomeAssistant,
|
||||
mock_tailwind: MagicMock,
|
||||
side_effect: Exception,
|
||||
expected_error: dict[str, str],
|
||||
) -> None:
|
||||
"""Test we show form on a error."""
|
||||
mock_tailwind.status.side_effect = side_effect
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_ZEROCONF},
|
||||
data=zeroconf.ZeroconfServiceInfo(
|
||||
ip_address=ip_address("127.0.0.1"),
|
||||
ip_addresses=[ip_address("127.0.0.1")],
|
||||
port=80,
|
||||
hostname="tailwind-3ce90e6d2184.local.",
|
||||
name="mock_name",
|
||||
properties={
|
||||
"device_id": "_3c_e9_e_6d_21_84_",
|
||||
"product": "iQ3",
|
||||
"SW ver": "10.10",
|
||||
"vendor": "tailwind",
|
||||
},
|
||||
type="mock_type",
|
||||
),
|
||||
)
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_TOKEN: "123456",
|
||||
},
|
||||
)
|
||||
|
||||
assert result2.get("type") == FlowResultType.FORM
|
||||
assert result2.get("step_id") == "zeroconf_confirm"
|
||||
assert result2.get("errors") == expected_error
|
||||
|
||||
mock_tailwind.status.side_effect = None
|
||||
result3 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_TOKEN: "123456",
|
||||
},
|
||||
)
|
||||
assert result3.get("type") == FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_tailwind")
|
||||
async def test_zeroconf_flow_not_discovered_again(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test the zeroconf doesn't re-discover an existing device.
|
||||
|
||||
Also, ensures the existing config entry is updated with the new host.
|
||||
"""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
assert mock_config_entry.data[CONF_HOST] == "127.0.0.127"
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_ZEROCONF},
|
||||
data=zeroconf.ZeroconfServiceInfo(
|
||||
ip_address=ip_address("127.0.0.1"),
|
||||
ip_addresses=[ip_address("127.0.0.1")],
|
||||
port=80,
|
||||
hostname="tailwind-3ce90e6d2184.local.",
|
||||
name="mock_name",
|
||||
properties={
|
||||
"device_id": "_3c_e9_e_6d_21_84_",
|
||||
"product": "iQ3",
|
||||
"SW ver": "10.10",
|
||||
"vendor": "tailwind",
|
||||
},
|
||||
type="mock_type",
|
||||
),
|
||||
)
|
||||
|
||||
assert result.get("type") == FlowResultType.ABORT
|
||||
assert result.get("reason") == "already_configured"
|
||||
assert mock_config_entry.data[CONF_HOST] == "127.0.0.1"
|
||||
|
|
Loading…
Reference in New Issue