Add Tailwind integration (#105926)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>pull/105946/head
parent
6acbbec839
commit
d50b79ba84
|
@ -334,6 +334,7 @@ homeassistant.components.synology_dsm.*
|
||||||
homeassistant.components.systemmonitor.*
|
homeassistant.components.systemmonitor.*
|
||||||
homeassistant.components.tag.*
|
homeassistant.components.tag.*
|
||||||
homeassistant.components.tailscale.*
|
homeassistant.components.tailscale.*
|
||||||
|
homeassistant.components.tailwind.*
|
||||||
homeassistant.components.tami4.*
|
homeassistant.components.tami4.*
|
||||||
homeassistant.components.tautulli.*
|
homeassistant.components.tautulli.*
|
||||||
homeassistant.components.tcp.*
|
homeassistant.components.tcp.*
|
||||||
|
|
|
@ -1289,6 +1289,8 @@ build.json @home-assistant/supervisor
|
||||||
/tests/components/tag/ @balloob @dmulcahey
|
/tests/components/tag/ @balloob @dmulcahey
|
||||||
/homeassistant/components/tailscale/ @frenck
|
/homeassistant/components/tailscale/ @frenck
|
||||||
/tests/components/tailscale/ @frenck
|
/tests/components/tailscale/ @frenck
|
||||||
|
/homeassistant/components/tailwind/ @frenck
|
||||||
|
/tests/components/tailwind/ @frenck
|
||||||
/homeassistant/components/tami4/ @Guy293
|
/homeassistant/components/tami4/ @Guy293
|
||||||
/tests/components/tami4/ @Guy293
|
/tests/components/tami4/ @Guy293
|
||||||
/homeassistant/components/tankerkoenig/ @guillempages @mib1185
|
/homeassistant/components/tankerkoenig/ @guillempages @mib1185
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""Integration for Tailwind devices."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import Platform
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import TailwindDataUpdateCoordinator
|
||||||
|
|
||||||
|
PLATFORMS = [Platform.NUMBER]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Set up Tailwind device from a config entry."""
|
||||||
|
coordinator = TailwindDataUpdateCoordinator(hass, entry)
|
||||||
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
|
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||||
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Unload Tailwind config entry."""
|
||||||
|
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||||
|
del hass.data[DOMAIN][entry.entry_id]
|
||||||
|
return unload_ok
|
|
@ -0,0 +1,79 @@
|
||||||
|
"""Config flow to configure the Tailwind integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from gotailwind import (
|
||||||
|
Tailwind,
|
||||||
|
TailwindAuthenticationError,
|
||||||
|
TailwindConnectionError,
|
||||||
|
TailwindUnsupportedFirmwareVersionError,
|
||||||
|
)
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigFlow
|
||||||
|
from homeassistant.const import CONF_HOST, CONF_TOKEN
|
||||||
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
from homeassistant.helpers.selector import (
|
||||||
|
TextSelector,
|
||||||
|
TextSelectorConfig,
|
||||||
|
TextSelectorType,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .const import DOMAIN, LOGGER
|
||||||
|
|
||||||
|
|
||||||
|
class TailwindFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a Tailwind config flow."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle a flow initiated by the user."""
|
||||||
|
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")
|
||||||
|
except TailwindAuthenticationError:
|
||||||
|
errors[CONF_TOKEN] = "invalid_auth"
|
||||||
|
except TailwindConnectionError:
|
||||||
|
errors[CONF_HOST] = "cannot_connect"
|
||||||
|
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 = {}
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(
|
||||||
|
CONF_HOST, default=user_input.get(CONF_HOST)
|
||||||
|
): TextSelector(TextSelectorConfig(autocomplete="off")),
|
||||||
|
vol.Required(CONF_TOKEN): TextSelector(
|
||||||
|
TextSelectorConfig(type=TextSelectorType.PASSWORD)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
description_placeholders={
|
||||||
|
"url": "https://web.gotailwind.com/client/integration/local-control-key",
|
||||||
|
},
|
||||||
|
errors=errors,
|
||||||
|
)
|
|
@ -0,0 +1,9 @@
|
||||||
|
"""Constants for the Tailwind integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
DOMAIN: Final = "tailwind"
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__package__)
|
|
@ -0,0 +1,39 @@
|
||||||
|
"""Data update coordinator for Tailwind."""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from gotailwind import Tailwind, TailwindDeviceStatus, TailwindError
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import CONF_HOST, CONF_TOKEN
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
|
from .const import DOMAIN, LOGGER
|
||||||
|
|
||||||
|
|
||||||
|
class TailwindDataUpdateCoordinator(DataUpdateCoordinator[TailwindDeviceStatus]):
|
||||||
|
"""Class to manage fetching Tailwind data."""
|
||||||
|
|
||||||
|
config_entry: ConfigEntry
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
"""Initialize the coordinator."""
|
||||||
|
self.tailwind = Tailwind(
|
||||||
|
host=entry.data[CONF_HOST],
|
||||||
|
token=entry.data[CONF_TOKEN],
|
||||||
|
session=async_get_clientsession(hass),
|
||||||
|
)
|
||||||
|
super().__init__(
|
||||||
|
hass,
|
||||||
|
LOGGER,
|
||||||
|
name=f"{DOMAIN}_{entry.data[CONF_HOST]}",
|
||||||
|
update_interval=timedelta(seconds=5),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _async_update_data(self) -> TailwindDeviceStatus:
|
||||||
|
"""Fetch data from the Tailwind device."""
|
||||||
|
try:
|
||||||
|
return await self.tailwind.status()
|
||||||
|
except TailwindError as err:
|
||||||
|
raise UpdateFailed(err) from err
|
|
@ -0,0 +1,25 @@
|
||||||
|
"""Base entity for the Tailwind integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import TailwindDataUpdateCoordinator
|
||||||
|
|
||||||
|
|
||||||
|
class TailwindEntity(CoordinatorEntity[TailwindDataUpdateCoordinator]):
|
||||||
|
"""Defines an Tailwind entity."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
|
def __init__(self, coordinator: TailwindDataUpdateCoordinator) -> None:
|
||||||
|
"""Initialize an Tailwind entity."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, coordinator.data.device_id)},
|
||||||
|
connections={(CONNECTION_NETWORK_MAC, coordinator.data.mac_address)},
|
||||||
|
manufacturer="Tailwind",
|
||||||
|
model=coordinator.data.product,
|
||||||
|
sw_version=coordinator.data.firmware_version,
|
||||||
|
)
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"domain": "tailwind",
|
||||||
|
"name": "Tailwind",
|
||||||
|
"codeowners": ["@frenck"],
|
||||||
|
"config_flow": true,
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/tailwind",
|
||||||
|
"integration_type": "device",
|
||||||
|
"iot_class": "local_polling",
|
||||||
|
"requirements": ["gotailwind==0.2.1"]
|
||||||
|
}
|
|
@ -0,0 +1,86 @@
|
||||||
|
"""Number entity platform for Tailwind."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from gotailwind import Tailwind, TailwindDeviceStatus
|
||||||
|
|
||||||
|
from homeassistant.components.number import NumberEntity, NumberEntityDescription
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import PERCENTAGE, EntityCategory
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import TailwindDataUpdateCoordinator
|
||||||
|
from .entity import TailwindEntity
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(kw_only=True)
|
||||||
|
class TailwindNumberEntityDescription(NumberEntityDescription):
|
||||||
|
"""Class describing Tailwind number entities."""
|
||||||
|
|
||||||
|
value_fn: Callable[[TailwindDeviceStatus], int]
|
||||||
|
set_value_fn: Callable[[Tailwind, float], Awaitable[Any]]
|
||||||
|
|
||||||
|
|
||||||
|
DESCRIPTIONS = [
|
||||||
|
TailwindNumberEntityDescription(
|
||||||
|
key="brightness",
|
||||||
|
icon="mdi:led-on",
|
||||||
|
translation_key="brightness",
|
||||||
|
entity_category=EntityCategory.CONFIG,
|
||||||
|
native_step=1,
|
||||||
|
native_min_value=0,
|
||||||
|
native_max_value=100,
|
||||||
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
|
value_fn=lambda data: data.led_brightness,
|
||||||
|
set_value_fn=lambda tailwind, brightness: tailwind.status_led(
|
||||||
|
brightness=int(brightness),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up Tailwind number based on a config entry."""
|
||||||
|
coordinator: TailwindDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
async_add_entities(
|
||||||
|
TailwindNumberEntity(
|
||||||
|
coordinator,
|
||||||
|
description,
|
||||||
|
)
|
||||||
|
for description in DESCRIPTIONS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TailwindNumberEntity(TailwindEntity, NumberEntity):
|
||||||
|
"""Representation of a Tailwind number entity."""
|
||||||
|
|
||||||
|
entity_description: TailwindNumberEntityDescription
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: TailwindDataUpdateCoordinator,
|
||||||
|
description: TailwindNumberEntityDescription,
|
||||||
|
) -> None:
|
||||||
|
"""Initiate Tailwind number entity."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self.entity_description = description
|
||||||
|
self._attr_unique_id = f"{coordinator.data.device_id}-{description.key}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> int | None:
|
||||||
|
"""Return the number value."""
|
||||||
|
return self.entity_description.value_fn(self.coordinator.data)
|
||||||
|
|
||||||
|
async def async_set_native_value(self, value: float) -> None:
|
||||||
|
"""Change to new number value."""
|
||||||
|
await self.entity_description.set_value_fn(self.coordinator.tailwind, value)
|
||||||
|
await self.coordinator.async_request_refresh()
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "Set up your Tailwind garage door opener to integrate with Home Assistant.\n\nTo do so, you will need to get the local control key and IP address of your Tailwind device. For more details, see the description below the fields down below.",
|
||||||
|
"data": {
|
||||||
|
"host": "[%key:common::config_flow::data::host%]",
|
||||||
|
"token": "Local control key token"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
|
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||||
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||||
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"number": {
|
||||||
|
"brightness": {
|
||||||
|
"name": "Status LED brightness"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -485,6 +485,7 @@ FLOWS = {
|
||||||
"system_bridge",
|
"system_bridge",
|
||||||
"tado",
|
"tado",
|
||||||
"tailscale",
|
"tailscale",
|
||||||
|
"tailwind",
|
||||||
"tami4",
|
"tami4",
|
||||||
"tankerkoenig",
|
"tankerkoenig",
|
||||||
"tasmota",
|
"tasmota",
|
||||||
|
|
|
@ -5685,6 +5685,12 @@
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"iot_class": "cloud_polling"
|
"iot_class": "cloud_polling"
|
||||||
},
|
},
|
||||||
|
"tailwind": {
|
||||||
|
"name": "Tailwind",
|
||||||
|
"integration_type": "device",
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "local_polling"
|
||||||
|
},
|
||||||
"tami4": {
|
"tami4": {
|
||||||
"name": "Tami4 Edge / Edge+",
|
"name": "Tami4 Edge / Edge+",
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
|
|
10
mypy.ini
10
mypy.ini
|
@ -3102,6 +3102,16 @@ disallow_untyped_defs = true
|
||||||
warn_return_any = true
|
warn_return_any = true
|
||||||
warn_unreachable = true
|
warn_unreachable = true
|
||||||
|
|
||||||
|
[mypy-homeassistant.components.tailwind.*]
|
||||||
|
check_untyped_defs = true
|
||||||
|
disallow_incomplete_defs = true
|
||||||
|
disallow_subclassing_any = true
|
||||||
|
disallow_untyped_calls = true
|
||||||
|
disallow_untyped_decorators = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unreachable = true
|
||||||
|
|
||||||
[mypy-homeassistant.components.tami4.*]
|
[mypy-homeassistant.components.tami4.*]
|
||||||
check_untyped_defs = true
|
check_untyped_defs = true
|
||||||
disallow_incomplete_defs = true
|
disallow_incomplete_defs = true
|
||||||
|
|
|
@ -933,6 +933,9 @@ googlemaps==2.5.1
|
||||||
# homeassistant.components.slide
|
# homeassistant.components.slide
|
||||||
goslide-api==0.5.1
|
goslide-api==0.5.1
|
||||||
|
|
||||||
|
# homeassistant.components.tailwind
|
||||||
|
gotailwind==0.2.1
|
||||||
|
|
||||||
# homeassistant.components.govee_ble
|
# homeassistant.components.govee_ble
|
||||||
govee-ble==0.24.0
|
govee-ble==0.24.0
|
||||||
|
|
||||||
|
|
|
@ -744,6 +744,9 @@ google-nest-sdm==3.0.3
|
||||||
# homeassistant.components.google_travel_time
|
# homeassistant.components.google_travel_time
|
||||||
googlemaps==2.5.1
|
googlemaps==2.5.1
|
||||||
|
|
||||||
|
# homeassistant.components.tailwind
|
||||||
|
gotailwind==0.2.1
|
||||||
|
|
||||||
# homeassistant.components.govee_ble
|
# homeassistant.components.govee_ble
|
||||||
govee-ble==0.24.0
|
govee-ble==0.24.0
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
"""Integration tests for the Tailwind integration."""
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""Fixtures for the Tailwind integration tests."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from gotailwind import TailwindDeviceStatus
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.tailwind.const import DOMAIN
|
||||||
|
from homeassistant.const import CONF_HOST, CONF_TOKEN
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry, load_fixture
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def device_fixture() -> str:
|
||||||
|
"""Return the device fixtures for a specific device."""
|
||||||
|
return "iq3"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_config_entry() -> MockConfigEntry:
|
||||||
|
"""Return the default mocked config entry."""
|
||||||
|
return MockConfigEntry(
|
||||||
|
title="Tailwind iQ3",
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={
|
||||||
|
CONF_HOST: "127.0.0.127",
|
||||||
|
CONF_TOKEN: "123456",
|
||||||
|
},
|
||||||
|
unique_id="3c:e9:0e:6d:21:84",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
|
||||||
|
"""Mock setting up a config entry."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.tailwind.async_setup_entry", return_value=True
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_tailwind(device_fixture: str) -> Generator[MagicMock, None, None]:
|
||||||
|
"""Return a mocked Tailwind client."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.tailwind.coordinator.Tailwind", autospec=True
|
||||||
|
) as tailwind_mock, patch(
|
||||||
|
"homeassistant.components.tailwind.config_flow.Tailwind",
|
||||||
|
new=tailwind_mock,
|
||||||
|
):
|
||||||
|
tailwind = tailwind_mock.return_value
|
||||||
|
tailwind.status.return_value = TailwindDeviceStatus.from_json(
|
||||||
|
load_fixture(f"{device_fixture}.json", DOMAIN)
|
||||||
|
)
|
||||||
|
yield tailwind
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def init_integration(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
mock_tailwind: MagicMock,
|
||||||
|
) -> MockConfigEntry:
|
||||||
|
"""Set up the Tailwind integration for testing."""
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
return mock_config_entry
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"result": "OK",
|
||||||
|
"product": "iQ3",
|
||||||
|
"dev_id": "_3c_e9_e_6d_21_84_",
|
||||||
|
"proto_ver": "0.1",
|
||||||
|
"door_num": 2,
|
||||||
|
"night_mode_en": 0,
|
||||||
|
"fw_ver": "10.10",
|
||||||
|
"led_brightness": 100,
|
||||||
|
"data": {
|
||||||
|
"door1": {
|
||||||
|
"index": 0,
|
||||||
|
"status": "open",
|
||||||
|
"lockup": 0,
|
||||||
|
"disabled": 0
|
||||||
|
},
|
||||||
|
"door2": {
|
||||||
|
"index": 1,
|
||||||
|
"status": "open",
|
||||||
|
"lockup": 0,
|
||||||
|
"disabled": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
# serializer version: 1
|
||||||
|
# name: test_user_flow
|
||||||
|
FlowResultSnapshot({
|
||||||
|
'context': dict({
|
||||||
|
'source': 'user',
|
||||||
|
}),
|
||||||
|
'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': 'user',
|
||||||
|
'title': 'Tailwind iQ3',
|
||||||
|
'unique_id': None,
|
||||||
|
'version': 1,
|
||||||
|
}),
|
||||||
|
'title': 'Tailwind iQ3',
|
||||||
|
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
|
||||||
|
'version': 1,
|
||||||
|
})
|
||||||
|
# ---
|
|
@ -0,0 +1,87 @@
|
||||||
|
# serializer version: 1
|
||||||
|
# name: test_number_entities
|
||||||
|
StateSnapshot({
|
||||||
|
'attributes': ReadOnlyDict({
|
||||||
|
'friendly_name': 'Tailwind iQ3 Status LED brightness',
|
||||||
|
'icon': 'mdi:led-on',
|
||||||
|
'max': 100,
|
||||||
|
'min': 0,
|
||||||
|
'mode': <NumberMode.AUTO: 'auto'>,
|
||||||
|
'step': 1,
|
||||||
|
'unit_of_measurement': '%',
|
||||||
|
}),
|
||||||
|
'context': <ANY>,
|
||||||
|
'entity_id': 'number.tailwind_iq3_status_led_brightness',
|
||||||
|
'last_changed': <ANY>,
|
||||||
|
'last_updated': <ANY>,
|
||||||
|
'state': '100',
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_number_entities.1
|
||||||
|
EntityRegistryEntrySnapshot({
|
||||||
|
'aliases': set({
|
||||||
|
}),
|
||||||
|
'area_id': None,
|
||||||
|
'capabilities': dict({
|
||||||
|
'max': 100,
|
||||||
|
'min': 0,
|
||||||
|
'mode': <NumberMode.AUTO: 'auto'>,
|
||||||
|
'step': 1,
|
||||||
|
}),
|
||||||
|
'config_entry_id': <ANY>,
|
||||||
|
'device_class': None,
|
||||||
|
'device_id': <ANY>,
|
||||||
|
'disabled_by': None,
|
||||||
|
'domain': 'number',
|
||||||
|
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||||
|
'entity_id': 'number.tailwind_iq3_status_led_brightness',
|
||||||
|
'has_entity_name': True,
|
||||||
|
'hidden_by': None,
|
||||||
|
'icon': None,
|
||||||
|
'id': <ANY>,
|
||||||
|
'name': None,
|
||||||
|
'options': dict({
|
||||||
|
}),
|
||||||
|
'original_device_class': None,
|
||||||
|
'original_icon': 'mdi:led-on',
|
||||||
|
'original_name': 'Status LED brightness',
|
||||||
|
'platform': 'tailwind',
|
||||||
|
'previous_unique_id': None,
|
||||||
|
'supported_features': 0,
|
||||||
|
'translation_key': 'brightness',
|
||||||
|
'unique_id': '_3c_e9_e_6d_21_84_-brightness',
|
||||||
|
'unit_of_measurement': '%',
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_number_entities.2
|
||||||
|
DeviceRegistryEntrySnapshot({
|
||||||
|
'area_id': None,
|
||||||
|
'config_entries': <ANY>,
|
||||||
|
'configuration_url': None,
|
||||||
|
'connections': set({
|
||||||
|
tuple(
|
||||||
|
'mac',
|
||||||
|
'3c:e9:0e:6d:21:84',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
'disabled_by': None,
|
||||||
|
'entry_type': None,
|
||||||
|
'hw_version': None,
|
||||||
|
'id': <ANY>,
|
||||||
|
'identifiers': set({
|
||||||
|
tuple(
|
||||||
|
'tailwind',
|
||||||
|
'_3c_e9_e_6d_21_84_',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
'is_new': False,
|
||||||
|
'manufacturer': 'Tailwind',
|
||||||
|
'model': 'iQ3',
|
||||||
|
'name': 'Tailwind iQ3',
|
||||||
|
'name_by_user': None,
|
||||||
|
'serial_number': None,
|
||||||
|
'suggested_area': None,
|
||||||
|
'sw_version': '10.10',
|
||||||
|
'via_device_id': None,
|
||||||
|
})
|
||||||
|
# ---
|
|
@ -0,0 +1,103 @@
|
||||||
|
"""Configuration flow tests for the Tailwind integration."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from gotailwind import (
|
||||||
|
TailwindAuthenticationError,
|
||||||
|
TailwindConnectionError,
|
||||||
|
TailwindUnsupportedFirmwareVersionError,
|
||||||
|
)
|
||||||
|
import pytest
|
||||||
|
from syrupy.assertion import SnapshotAssertion
|
||||||
|
|
||||||
|
from homeassistant.components.tailwind.const import DOMAIN
|
||||||
|
from homeassistant.config_entries import SOURCE_USER
|
||||||
|
from homeassistant.const import CONF_HOST, CONF_TOKEN
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("mock_tailwind")
|
||||||
|
async def test_user_flow(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
snapshot: SnapshotAssertion,
|
||||||
|
) -> None:
|
||||||
|
"""Test the full happy path user flow from start to finish."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": SOURCE_USER},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.get("type") == FlowResultType.FORM
|
||||||
|
assert result.get("step_id") == "user"
|
||||||
|
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={
|
||||||
|
CONF_HOST: "127.0.0.1",
|
||||||
|
CONF_TOKEN: "987654",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result2.get("type") == FlowResultType.CREATE_ENTRY
|
||||||
|
assert result2 == snapshot
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("side_effect", "expected_error"),
|
||||||
|
[
|
||||||
|
(TailwindConnectionError, {CONF_HOST: "cannot_connect"}),
|
||||||
|
(TailwindAuthenticationError, {CONF_TOKEN: "invalid_auth"}),
|
||||||
|
(Exception, {"base": "unknown"}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_user_flow_errors(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_tailwind: MagicMock,
|
||||||
|
side_effect: Exception,
|
||||||
|
expected_error: dict[str, str],
|
||||||
|
) -> None:
|
||||||
|
"""Test we show user form on a connection error."""
|
||||||
|
mock_tailwind.status.side_effect = side_effect
|
||||||
|
|
||||||
|
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.FORM
|
||||||
|
assert result.get("step_id") == "user"
|
||||||
|
assert result.get("errors") == expected_error
|
||||||
|
|
||||||
|
mock_tailwind.status.side_effect = None
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={
|
||||||
|
CONF_HOST: "127.0.0.2",
|
||||||
|
CONF_TOKEN: "123456",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert result2.get("type") == FlowResultType.CREATE_ENTRY
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unsupported_firmware_version(
|
||||||
|
hass: HomeAssistant, mock_tailwind: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""Test configuration flow aborts when the firmware version is not supported."""
|
||||||
|
mock_tailwind.status.side_effect = TailwindUnsupportedFirmwareVersionError
|
||||||
|
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") == "unsupported_firmware"
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""Integration tests for the Tailwind integration."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from gotailwind import TailwindConnectionError
|
||||||
|
|
||||||
|
from homeassistant.components.tailwind.const import DOMAIN
|
||||||
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
async def test_load_unload_config_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
mock_tailwind: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test the Tailwind configuration entry loading/unloading."""
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||||
|
assert len(mock_tailwind.status.mock_calls) == 1
|
||||||
|
|
||||||
|
await hass.config_entries.async_unload(mock_config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert not hass.data.get(DOMAIN)
|
||||||
|
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
|
||||||
|
|
||||||
|
|
||||||
|
async def test_config_entry_not_ready(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
mock_tailwind: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test the Tailwind configuration entry not ready."""
|
||||||
|
mock_tailwind.status.side_effect = TailwindConnectionError
|
||||||
|
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert len(mock_tailwind.status.mock_calls) == 1
|
||||||
|
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""Tests for number entities provided by the Tailwind integration."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from syrupy.assertion import SnapshotAssertion
|
||||||
|
|
||||||
|
from homeassistant.components import number
|
||||||
|
from homeassistant.components.number import ATTR_VALUE, SERVICE_SET_VALUE
|
||||||
|
from homeassistant.const import ATTR_ENTITY_ID
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.usefixtures("init_integration")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_number_entities(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
device_registry: dr.DeviceRegistry,
|
||||||
|
entity_registry: er.EntityRegistry,
|
||||||
|
mock_tailwind: MagicMock,
|
||||||
|
snapshot: SnapshotAssertion,
|
||||||
|
) -> None:
|
||||||
|
"""Test number entities provided by the Tailwind integration."""
|
||||||
|
assert (state := hass.states.get("number.tailwind_iq3_status_led_brightness"))
|
||||||
|
assert snapshot == state
|
||||||
|
|
||||||
|
assert (entity_entry := entity_registry.async_get(state.entity_id))
|
||||||
|
assert snapshot == entity_entry
|
||||||
|
|
||||||
|
assert entity_entry.device_id
|
||||||
|
assert (device_entry := device_registry.async_get(entity_entry.device_id))
|
||||||
|
assert snapshot == device_entry
|
||||||
|
|
||||||
|
assert len(mock_tailwind.status_led.mock_calls) == 0
|
||||||
|
await hass.services.async_call(
|
||||||
|
number.DOMAIN,
|
||||||
|
SERVICE_SET_VALUE,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: state.entity_id,
|
||||||
|
ATTR_VALUE: 42,
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(mock_tailwind.status_led.mock_calls) == 1
|
||||||
|
mock_tailwind.status_led.assert_called_with(brightness=42)
|
Loading…
Reference in New Issue