Add Tailwind zeroconf discovery (#105949)

pull/105966/head
Franck Nijhof 2023-12-18 10:39:15 +01:00 committed by GitHub
parent f912b9c34a
commit 90fef6b9c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 370 additions and 21 deletions

View File

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

View File

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

View File

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

View File

@ -494,6 +494,12 @@ ZEROCONF = {
"vendor": "synology*",
},
},
{
"domain": "tailwind",
"properties": {
"vendor": "tailwind",
},
},
],
"_hue._tcp.local.": [
{

View File

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

View File

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