diff --git a/.strict-typing b/.strict-typing index 56c7bf248e1..97af46884c4 100644 --- a/.strict-typing +++ b/.strict-typing @@ -180,6 +180,7 @@ homeassistant.components.huawei_lte.* homeassistant.components.hydrawise.* homeassistant.components.hyperion.* homeassistant.components.ibeacon.* +homeassistant.components.idasen_desk.* homeassistant.components.image.* homeassistant.components.image_processing.* homeassistant.components.image_upload.* diff --git a/CODEOWNERS b/CODEOWNERS index b3d2889b108..fe6aba2e5bb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -569,6 +569,8 @@ build.json @home-assistant/supervisor /tests/components/ibeacon/ @bdraco /homeassistant/components/icloud/ @Quentame @nzapponi /tests/components/icloud/ @Quentame @nzapponi +/homeassistant/components/idasen_desk/ @abmantis +/tests/components/idasen_desk/ @abmantis /homeassistant/components/ign_sismologia/ @exxamalte /tests/components/ign_sismologia/ @exxamalte /homeassistant/components/image/ @home-assistant/core diff --git a/homeassistant/brands/ikea.json b/homeassistant/brands/ikea.json index 702a59ad4d1..dee69001add 100644 --- a/homeassistant/brands/ikea.json +++ b/homeassistant/brands/ikea.json @@ -1,5 +1,5 @@ { "domain": "ikea", "name": "IKEA", - "integrations": ["symfonisk", "tradfri"] + "integrations": ["symfonisk", "tradfri", "idasen_desk"] } diff --git a/homeassistant/components/idasen_desk/__init__.py b/homeassistant/components/idasen_desk/__init__.py new file mode 100644 index 00000000000..5fd23ba47e0 --- /dev/null +++ b/homeassistant/components/idasen_desk/__init__.py @@ -0,0 +1,94 @@ +"""The IKEA Idasen Desk integration.""" +from __future__ import annotations + +import logging + +from attr import dataclass +from bleak import BleakError +from idasen_ha import Desk + +from homeassistant.components import bluetooth +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_NAME, + CONF_ADDRESS, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.COVER] + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class DeskData: + """Data for the Idasen Desk integration.""" + + desk: Desk + address: str + device_info: DeviceInfo + coordinator: DataUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up IKEA Idasen from a config entry.""" + address: str = entry.data[CONF_ADDRESS].upper() + + coordinator: DataUpdateCoordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=entry.title, + ) + + desk = Desk(coordinator.async_set_updated_data) + device_info = DeviceInfo( + name=entry.title, + connections={(dr.CONNECTION_BLUETOOTH, address)}, + ) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = DeskData( + desk, address, device_info, coordinator + ) + + ble_device = bluetooth.async_ble_device_from_address( + hass, address, connectable=True + ) + try: + await desk.connect(ble_device) + except (TimeoutError, BleakError) as ex: + raise ConfigEntryNotReady(f"Unable to connect to desk {address}") from ex + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + async def _async_stop(event: Event) -> None: + """Close the connection.""" + await desk.disconnect() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop) + ) + return True + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + data: DeskData = hass.data[DOMAIN][entry.entry_id] + if entry.title != data.device_info[ATTR_NAME]: + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + data: DeskData = hass.data[DOMAIN].pop(entry.entry_id) + await data.desk.disconnect() + + return unload_ok diff --git a/homeassistant/components/idasen_desk/config_flow.py b/homeassistant/components/idasen_desk/config_flow.py new file mode 100644 index 00000000000..f56446396d2 --- /dev/null +++ b/homeassistant/components/idasen_desk/config_flow.py @@ -0,0 +1,115 @@ +"""Config flow for Idasen Desk integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from bleak import BleakError +from bluetooth_data_tools import human_readable_name +from idasen_ha import Desk +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_discovered_service_info, +) +from homeassistant.const import CONF_ADDRESS +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN, EXPECTED_SERVICE_UUID + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Idasen Desk integration.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovery_info: BluetoothServiceInfoBleak | None = None + self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> FlowResult: + """Handle the bluetooth discovery step.""" + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + self._discovery_info = discovery_info + self.context["title_placeholders"] = { + "name": human_readable_name( + None, discovery_info.name, discovery_info.address + ) + } + return await self.async_step_user() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the user step to pick discovered device.""" + errors: dict[str, str] = {} + + if user_input is not None: + address = user_input[CONF_ADDRESS] + discovery_info = self._discovered_devices[address] + local_name = discovery_info.name + await self.async_set_unique_id( + discovery_info.address, raise_on_progress=False + ) + self._abort_if_unique_id_configured() + + desk = Desk(None) + try: + await desk.connect(discovery_info.device, monitor_height=False) + except TimeoutError as err: + _LOGGER.exception("TimeoutError", exc_info=err) + errors["base"] = "cannot_connect" + except BleakError as err: + _LOGGER.exception("BleakError", exc_info=err) + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected error") + errors["base"] = "unknown" + else: + await desk.disconnect() + return self.async_create_entry( + title=local_name, + data={ + CONF_ADDRESS: discovery_info.address, + }, + ) + + if discovery := self._discovery_info: + self._discovered_devices[discovery.address] = discovery + else: + current_addresses = self._async_current_ids() + for discovery in async_discovered_service_info(self.hass): + if ( + discovery.address in current_addresses + or discovery.address in self._discovered_devices + or EXPECTED_SERVICE_UUID not in discovery.service_uuids + ): + continue + self._discovered_devices[discovery.address] = discovery + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + data_schema = vol.Schema( + { + vol.Required(CONF_ADDRESS): vol.In( + { + service_info.address: f"{service_info.name} ({service_info.address})" + for service_info in self._discovered_devices.values() + } + ), + } + ) + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ) diff --git a/homeassistant/components/idasen_desk/const.py b/homeassistant/components/idasen_desk/const.py new file mode 100644 index 00000000000..0d37d77307b --- /dev/null +++ b/homeassistant/components/idasen_desk/const.py @@ -0,0 +1,6 @@ +"""Constants for the Idasen Desk integration.""" + + +DOMAIN = "idasen_desk" + +EXPECTED_SERVICE_UUID = "99fa0001-338a-1024-8a49-009c0215f78a" diff --git a/homeassistant/components/idasen_desk/cover.py b/homeassistant/components/idasen_desk/cover.py new file mode 100644 index 00000000000..c1d1bb48fd8 --- /dev/null +++ b/homeassistant/components/idasen_desk/cover.py @@ -0,0 +1,101 @@ +"""Idasen Desk integration cover platform.""" +from __future__ import annotations + +import logging +from typing import Any + +from idasen_ha import Desk + +from homeassistant.components.cover import ( + ATTR_POSITION, + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import DeskData +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the cover platform for Idasen Desk.""" + data: DeskData = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [IdasenDeskCover(data.desk, data.address, data.device_info, data.coordinator)] + ) + + +class IdasenDeskCover(CoordinatorEntity, CoverEntity): + """Representation of Idasen Desk device.""" + + _attr_device_class = CoverDeviceClass.DAMPER + _attr_icon = "mdi:desk" + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + def __init__( + self, + desk: Desk, + address: str, + device_info: DeviceInfo, + coordinator: DataUpdateCoordinator, + ) -> None: + """Initialize an Idasen Desk cover.""" + super().__init__(coordinator) + self._desk = desk + self._attr_name = device_info[ATTR_NAME] + self._attr_unique_id = address + self._attr_device_info = device_info + + self._attr_current_cover_position = self._desk.height_percent + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._desk.is_connected is True + + @property + def is_closed(self) -> bool: + """Return if the cover is closed.""" + return self.current_cover_position == 0 + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self._desk.move_down() + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self._desk.move_up() + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + await self._desk.stop() + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover shutter to a specific position.""" + await self._desk.move_to(int(kwargs[ATTR_POSITION])) + + @callback + def _handle_coordinator_update(self, *args: Any) -> None: + """Handle data update.""" + self._attr_current_cover_position = self._desk.height_percent + self.async_write_ha_state() diff --git a/homeassistant/components/idasen_desk/manifest.json b/homeassistant/components/idasen_desk/manifest.json new file mode 100644 index 00000000000..f77e0c22373 --- /dev/null +++ b/homeassistant/components/idasen_desk/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "idasen_desk", + "name": "IKEA Idasen Desk", + "bluetooth": [ + { + "service_uuid": "99fa0001-338a-1024-8a49-009c0215f78a" + } + ], + "codeowners": ["@abmantis"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/idasen_desk", + "iot_class": "local_push", + "requirements": ["idasen-ha==1.4"] +} diff --git a/homeassistant/components/idasen_desk/strings.json b/homeassistant/components/idasen_desk/strings.json new file mode 100644 index 00000000000..e2be7e6deff --- /dev/null +++ b/homeassistant/components/idasen_desk/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "data": { + "address": "Bluetooth address" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "not_supported": "Device not supported", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + } + } +} diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 7b0aa78d69e..5784667bc67 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -213,6 +213,10 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ ], "manufacturer_id": 76, }, + { + "domain": "idasen_desk", + "service_uuid": "99fa0001-338a-1024-8a49-009c0215f78a", + }, { "connectable": False, "domain": "inkbird", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 0d20e80317c..3f37f3a19df 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -210,6 +210,7 @@ FLOWS = { "iaqualink", "ibeacon", "icloud", + "idasen_desk", "ifttt", "imap", "inkbird", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d1efd527b69..966cf186346 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2578,6 +2578,12 @@ "config_flow": true, "iot_class": "local_polling", "name": "IKEA TR\u00c5DFRI" + }, + "idasen_desk": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "IKEA Idasen Desk" } } }, diff --git a/mypy.ini b/mypy.ini index d2c2a66d738..67390ef2ddf 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1562,6 +1562,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.idasen_desk.*] +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.image.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 2a9f39baf4c..49806f29942 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1042,6 +1042,9 @@ ical==5.0.1 # homeassistant.components.ping icmplib==3.0 +# homeassistant.components.idasen_desk +idasen-ha==1.4 + # homeassistant.components.network ifaddr==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2bac718dc57..51195c7cecd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -819,6 +819,9 @@ ical==5.0.1 # homeassistant.components.ping icmplib==3.0 +# homeassistant.components.idasen_desk +idasen-ha==1.4 + # homeassistant.components.network ifaddr==0.2.0 diff --git a/tests/components/idasen_desk/__init__.py b/tests/components/idasen_desk/__init__.py new file mode 100644 index 00000000000..7e8becc4689 --- /dev/null +++ b/tests/components/idasen_desk/__init__.py @@ -0,0 +1,51 @@ +"""Tests for the IKEA Idasen Desk integration.""" + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from homeassistant.components.idasen_desk.const import DOMAIN +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device + +IDASEN_DISCOVERY_INFO = BluetoothServiceInfoBleak( + name="Desk 1234", + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + manufacturer_data={}, + service_uuids=["99fa0001-338a-1024-8a49-009c0215f78a"], + service_data={}, + source="local", + device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name="Desk 1234"), + advertisement=generate_advertisement_data(), + time=0, + connectable=True, +) + +NOT_IDASEN_DISCOVERY_INFO = BluetoothServiceInfoBleak( + name="Not Desk", + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + manufacturer_data={}, + service_uuids=[], + service_data={}, + source="local", + device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name="Not Desk"), + advertisement=generate_advertisement_data(), + time=0, + connectable=True, +) + + +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: + """Set up the IKEA Idasen Desk integration in Home Assistant.""" + entry = MockConfigEntry( + title="Test", + domain=DOMAIN, + data={CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"}, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/idasen_desk/conftest.py b/tests/components/idasen_desk/conftest.py new file mode 100644 index 00000000000..736bc6346ce --- /dev/null +++ b/tests/components/idasen_desk/conftest.py @@ -0,0 +1,49 @@ +"""IKEA Idasen Desk fixtures.""" + +from collections.abc import Callable +from unittest import mock +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth): + """Auto mock bluetooth.""" + + +@pytest.fixture(autouse=False) +def mock_desk_api(): + """Set up idasen desk API fixture.""" + with mock.patch("homeassistant.components.idasen_desk.Desk") as desk_patched: + mock_desk = MagicMock() + + def mock_init(update_callback: Callable[[int | None], None] | None): + mock_desk.trigger_update_callback = update_callback + return mock_desk + + desk_patched.side_effect = mock_init + + async def mock_connect(ble_device, monitor_height: bool = True): + mock_desk.is_connected = True + + async def mock_move_to(height: float): + mock_desk.height_percent = height + mock_desk.trigger_update_callback(height) + + async def mock_move_up(): + await mock_move_to(100) + + async def mock_move_down(): + await mock_move_to(0) + + mock_desk.connect = AsyncMock(side_effect=mock_connect) + mock_desk.disconnect = AsyncMock() + mock_desk.move_to = AsyncMock(side_effect=mock_move_to) + mock_desk.move_up = AsyncMock(side_effect=mock_move_up) + mock_desk.move_down = AsyncMock(side_effect=mock_move_down) + mock_desk.stop = AsyncMock() + mock_desk.height_percent = 60 + mock_desk.is_moving = False + + yield mock_desk diff --git a/tests/components/idasen_desk/test_config_flow.py b/tests/components/idasen_desk/test_config_flow.py new file mode 100644 index 00000000000..8635e5bfddc --- /dev/null +++ b/tests/components/idasen_desk/test_config_flow.py @@ -0,0 +1,230 @@ +"""Test the IKEA Idasen Desk config flow.""" +from unittest.mock import patch + +from bleak import BleakError +import pytest + +from homeassistant import config_entries +from homeassistant.components.idasen_desk.const import DOMAIN +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import IDASEN_DISCOVERY_INFO, NOT_IDASEN_DISCOVERY_INFO + +from tests.common import MockConfigEntry + + +async def test_user_step_success(hass: HomeAssistant) -> None: + """Test user step success path.""" + with patch( + "homeassistant.components.idasen_desk.config_flow.async_discovered_service_info", + return_value=[NOT_IDASEN_DISCOVERY_INFO, IDASEN_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch("homeassistant.components.idasen_desk.config_flow.Desk.connect"), patch( + "homeassistant.components.idasen_desk.config_flow.Desk.disconnect" + ), patch( + "homeassistant.components.idasen_desk.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == IDASEN_DISCOVERY_INFO.name + assert result2["data"] == { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + } + assert result2["result"].unique_id == IDASEN_DISCOVERY_INFO.address + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_step_no_devices_found(hass: HomeAssistant) -> None: + """Test user step with no devices found.""" + with patch( + "homeassistant.components.idasen_desk.config_flow.async_discovered_service_info", + return_value=[NOT_IDASEN_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_user_step_no_new_devices_found(hass: HomeAssistant) -> None: + """Test user step with only existing devices found.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + unique_id=IDASEN_DISCOVERY_INFO.address, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.idasen_desk.config_flow.async_discovered_service_info", + return_value=[IDASEN_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +@pytest.mark.parametrize("exception", [TimeoutError(), BleakError()]) +async def test_user_step_cannot_connect( + hass: HomeAssistant, exception: Exception +) -> None: + """Test user step and we cannot connect.""" + with patch( + "homeassistant.components.idasen_desk.config_flow.async_discovered_service_info", + return_value=[IDASEN_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.idasen_desk.config_flow.Desk.connect", + side_effect=exception, + ), patch("homeassistant.components.idasen_desk.config_flow.Desk.disconnect"): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "cannot_connect"} + + with patch("homeassistant.components.idasen_desk.config_flow.Desk.connect"), patch( + "homeassistant.components.idasen_desk.config_flow.Desk.disconnect" + ), patch( + "homeassistant.components.idasen_desk.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == IDASEN_DISCOVERY_INFO.name + assert result3["data"] == { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + } + assert result3["result"].unique_id == IDASEN_DISCOVERY_INFO.address + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_step_unknown_exception(hass: HomeAssistant) -> None: + """Test user step with an unknown exception.""" + with patch( + "homeassistant.components.idasen_desk.config_flow.async_discovered_service_info", + return_value=[NOT_IDASEN_DISCOVERY_INFO, IDASEN_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.idasen_desk.config_flow.Desk.connect", + side_effect=RuntimeError, + ), patch( + "homeassistant.components.idasen_desk.config_flow.Desk.disconnect", + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "unknown"} + + with patch( + "homeassistant.components.idasen_desk.config_flow.Desk.connect", + ), patch( + "homeassistant.components.idasen_desk.config_flow.Desk.disconnect", + ), patch( + "homeassistant.components.idasen_desk.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == IDASEN_DISCOVERY_INFO.name + assert result3["data"] == { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + } + assert result3["result"].unique_id == IDASEN_DISCOVERY_INFO.address + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_bluetooth_step_success(hass: HomeAssistant) -> None: + """Test bluetooth step success path.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IDASEN_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch("homeassistant.components.idasen_desk.config_flow.Desk.connect"), patch( + "homeassistant.components.idasen_desk.config_flow.Desk.disconnect" + ), patch( + "homeassistant.components.idasen_desk.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == IDASEN_DISCOVERY_INFO.name + assert result2["data"] == { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + } + assert result2["result"].unique_id == IDASEN_DISCOVERY_INFO.address + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/idasen_desk/test_cover.py b/tests/components/idasen_desk/test_cover.py new file mode 100644 index 00000000000..a9c74be7081 --- /dev/null +++ b/tests/components/idasen_desk/test_cover.py @@ -0,0 +1,82 @@ +"""Test the IKEA Idasen Desk cover.""" +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + DOMAIN as COVER_DOMAIN, +) +from homeassistant.const import ( + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + SERVICE_STOP_COVER, + STATE_CLOSED, + STATE_OPEN, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant + +from . import init_integration + + +async def test_cover_available( + hass: HomeAssistant, + mock_desk_api: MagicMock, +) -> None: + """Test cover available property.""" + entity_id = "cover.test" + await init_integration(hass) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 60 + + mock_desk_api.is_connected = False + mock_desk_api.trigger_update_callback(None) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("service", "service_data", "expected_state", "expected_position"), + [ + (SERVICE_SET_COVER_POSITION, {ATTR_POSITION: 100}, STATE_OPEN, 100), + (SERVICE_SET_COVER_POSITION, {ATTR_POSITION: 0}, STATE_CLOSED, 0), + (SERVICE_OPEN_COVER, {}, STATE_OPEN, 100), + (SERVICE_CLOSE_COVER, {}, STATE_CLOSED, 0), + (SERVICE_STOP_COVER, {}, STATE_OPEN, 60), + ], +) +async def test_cover_services( + hass: HomeAssistant, + mock_desk_api: MagicMock, + service: str, + service_data: dict[str, Any], + expected_state: str, + expected_position: int, +) -> None: + """Test cover services.""" + entity_id = "cover.test" + await init_integration(hass) + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 60 + await hass.services.async_call( + COVER_DOMAIN, + service, + {"entity_id": entity_id, **service_data}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state + assert state.state == expected_state + assert state.attributes[ATTR_CURRENT_POSITION] == expected_position diff --git a/tests/components/idasen_desk/test_init.py b/tests/components/idasen_desk/test_init.py new file mode 100644 index 00000000000..e596f0fe000 --- /dev/null +++ b/tests/components/idasen_desk/test_init.py @@ -0,0 +1,55 @@ +"""Test the IKEA Idasen Desk init.""" +from unittest.mock import AsyncMock, MagicMock + +from bleak import BleakError +import pytest + +from homeassistant.components.idasen_desk.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant + +from . import init_integration + + +async def test_setup_and_shutdown( + hass: HomeAssistant, + mock_desk_api: MagicMock, +) -> None: + """Test setup.""" + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.LOADED + mock_desk_api.connect.assert_called_once() + mock_desk_api.is_connected = True + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + mock_desk_api.disconnect.assert_called_once() + + +@pytest.mark.parametrize("exception", [TimeoutError(), BleakError()]) +async def test_setup_connect_exception( + hass: HomeAssistant, mock_desk_api: MagicMock, exception: Exception +) -> None: + """Test setup with an connection exception.""" + mock_desk_api.connect = AsyncMock(side_effect=exception) + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_unload_entry(hass: HomeAssistant, mock_desk_api: MagicMock) -> None: + """Test successful unload of entry.""" + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.LOADED + mock_desk_api.connect.assert_called_once() + mock_desk_api.is_connected = True + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + mock_desk_api.disconnect.assert_called_once() + + assert entry.state is ConfigEntryState.NOT_LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 1