Add Tailwind integration (#105926)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Franck Nijhof 2023-12-18 08:42:53 +01:00 committed by GitHub
parent 6acbbec839
commit d50b79ba84
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 758 additions and 0 deletions

View File

@ -334,6 +334,7 @@ homeassistant.components.synology_dsm.*
homeassistant.components.systemmonitor.*
homeassistant.components.tag.*
homeassistant.components.tailscale.*
homeassistant.components.tailwind.*
homeassistant.components.tami4.*
homeassistant.components.tautulli.*
homeassistant.components.tcp.*

View File

@ -1289,6 +1289,8 @@ build.json @home-assistant/supervisor
/tests/components/tag/ @balloob @dmulcahey
/homeassistant/components/tailscale/ @frenck
/tests/components/tailscale/ @frenck
/homeassistant/components/tailwind/ @frenck
/tests/components/tailwind/ @frenck
/homeassistant/components/tami4/ @Guy293
/tests/components/tami4/ @Guy293
/homeassistant/components/tankerkoenig/ @guillempages @mib1185

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -485,6 +485,7 @@ FLOWS = {
"system_bridge",
"tado",
"tailscale",
"tailwind",
"tami4",
"tankerkoenig",
"tasmota",

View File

@ -5685,6 +5685,12 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
"tailwind": {
"name": "Tailwind",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
"tami4": {
"name": "Tami4 Edge / Edge+",
"integration_type": "hub",

View File

@ -3102,6 +3102,16 @@ disallow_untyped_defs = true
warn_return_any = 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.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@ -933,6 +933,9 @@ googlemaps==2.5.1
# homeassistant.components.slide
goslide-api==0.5.1
# homeassistant.components.tailwind
gotailwind==0.2.1
# homeassistant.components.govee_ble
govee-ble==0.24.0

View File

@ -744,6 +744,9 @@ google-nest-sdm==3.0.3
# homeassistant.components.google_travel_time
googlemaps==2.5.1
# homeassistant.components.tailwind
gotailwind==0.2.1
# homeassistant.components.govee_ble
govee-ble==0.24.0

View File

@ -0,0 +1 @@
"""Integration tests for the Tailwind integration."""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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