diff --git a/homeassistant/components/idasen_desk/__init__.py b/homeassistant/components/idasen_desk/__init__.py index 2edd04b1d59..ee0a9e9024e 100644 --- a/homeassistant/components/idasen_desk/__init__.py +++ b/homeassistant/components/idasen_desk/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import logging from attr import dataclass @@ -10,6 +11,7 @@ from idasen_ha import Desk from idasen_ha.errors import AuthFailedError from homeassistant.components import bluetooth +from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_NAME, @@ -46,6 +48,7 @@ class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]): # pylint: disab self._address = address self._expected_connected = False self._connection_lost = False + self._disconnect_lock = asyncio.Lock() self.desk = Desk(self.async_set_updated_data) @@ -56,6 +59,7 @@ class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]): # pylint: disab self.hass, self._address, connectable=True ) if ble_device is None: + _LOGGER.debug("No BLEDevice for %s", self._address) return False self._expected_connected = True await self.desk.connect(ble_device) @@ -68,20 +72,28 @@ class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]): # pylint: disab self._connection_lost = False await self.desk.disconnect() - @callback - def async_set_updated_data(self, data: int | None) -> None: - """Handle data update.""" + async def async_ensure_connection_state(self) -> None: + """Check if the expected connection state matches the current state and correct it if needed.""" if self._expected_connected: if not self.desk.is_connected: _LOGGER.debug("Desk disconnected. Reconnecting") self._connection_lost = True - self.hass.async_create_task(self.async_connect(), eager_start=False) + await self.async_connect() elif self._connection_lost: _LOGGER.info("Reconnected to desk") self._connection_lost = False elif self.desk.is_connected: - _LOGGER.warning("Desk is connected but should not be. Disconnecting") - self.hass.async_create_task(self.desk.disconnect()) + if self._disconnect_lock.locked(): + _LOGGER.debug("Already disconnecting") + return + async with self._disconnect_lock: + _LOGGER.debug("Desk is connected but should not be. Disconnecting") + await self.desk.disconnect() + + @callback + def async_set_updated_data(self, data: int | None) -> None: + """Handle data update.""" + self.hass.async_create_task(self.async_ensure_connection_state()) return super().async_set_updated_data(data) @@ -116,6 +128,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + @callback + def _async_bluetooth_callback( + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + """Update from a Bluetooth callback to ensure that a new BLEDevice is fetched.""" + _LOGGER.debug("Bluetooth callback triggered") + hass.async_create_task(coordinator.async_ensure_connection_state()) + + entry.async_on_unload( + bluetooth.async_register_callback( + hass, + _async_bluetooth_callback, + BluetoothCallbackMatcher({ADDRESS: address}), + bluetooth.BluetoothScanningMode.ACTIVE, + ) + ) + async def _async_stop(event: Event) -> None: """Close the connection.""" await coordinator.async_disconnect() diff --git a/homeassistant/components/idasen_desk/manifest.json b/homeassistant/components/idasen_desk/manifest.json index 84e97534d7c..a912fabfa54 100644 --- a/homeassistant/components/idasen_desk/manifest.json +++ b/homeassistant/components/idasen_desk/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/idasen_desk", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["idasen-ha==2.5.1"] + "requirements": ["idasen-ha==2.5.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 64cde65db55..0813639c627 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1122,7 +1122,7 @@ ical==8.0.0 icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==2.5.1 +idasen-ha==2.5.3 # homeassistant.components.network ifaddr==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 907fa102ee3..05ebed895f5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -915,7 +915,7 @@ ical==8.0.0 icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==2.5.1 +idasen-ha==2.5.3 # homeassistant.components.network ifaddr==0.2.0 diff --git a/tests/components/idasen_desk/test_cover.py b/tests/components/idasen_desk/test_cover.py index 3c18d604549..0110fe7d820 100644 --- a/tests/components/idasen_desk/test_cover.py +++ b/tests/components/idasen_desk/test_cover.py @@ -1,7 +1,7 @@ """Test the IKEA Idasen Desk cover.""" from typing import Any -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from bleak.exc import BleakError import pytest @@ -39,6 +39,7 @@ async def test_cover_available( assert state.state == STATE_OPEN assert state.attributes[ATTR_CURRENT_POSITION] == 60 + mock_desk_api.connect = AsyncMock() mock_desk_api.is_connected = False mock_desk_api.trigger_update_callback(None) diff --git a/tests/components/idasen_desk/test_init.py b/tests/components/idasen_desk/test_init.py index 5b8258c8d33..0973e8326bf 100644 --- a/tests/components/idasen_desk/test_init.py +++ b/tests/components/idasen_desk/test_init.py @@ -1,5 +1,6 @@ """Test the IKEA Idasen Desk init.""" +import asyncio from unittest import mock from unittest.mock import AsyncMock, MagicMock @@ -53,6 +54,77 @@ async def test_no_ble_device(hass: HomeAssistant, mock_desk_api: MagicMock) -> N assert entry.state is ConfigEntryState.SETUP_RETRY +async def test_reconnect_on_bluetooth_callback( + hass: HomeAssistant, mock_desk_api: MagicMock +) -> None: + """Test that a reconnet is made after the bluetooth callback is triggered.""" + with mock.patch( + "homeassistant.components.idasen_desk.bluetooth.async_register_callback" + ) as mock_register_callback: + await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + mock_desk_api.connect.assert_called_once() + mock_register_callback.assert_called_once() + + mock_desk_api.is_connected = False + _, register_callback_args, _ = mock_register_callback.mock_calls[0] + bt_callback = register_callback_args[1] + bt_callback(None, None) + await hass.async_block_till_done() + assert mock_desk_api.connect.call_count == 2 + + +async def test_duplicated_disconnect_is_no_op( + hass: HomeAssistant, mock_desk_api: MagicMock +) -> None: + """Test that calling disconnect while disconnecting is a no-op.""" + await init_integration(hass) + + await hass.services.async_call( + "button", "press", {"entity_id": "button.test_disconnect"}, blocking=True + ) + await hass.async_block_till_done() + + async def mock_disconnect(): + await asyncio.sleep(0) + + mock_desk_api.disconnect.reset_mock() + mock_desk_api.disconnect.side_effect = mock_disconnect + + # Since the disconnect button was pressed but the desk indicates "connected", + # any update event will call disconnect() + mock_desk_api.is_connected = True + mock_desk_api.trigger_update_callback(None) + mock_desk_api.trigger_update_callback(None) + mock_desk_api.trigger_update_callback(None) + await hass.async_block_till_done() + mock_desk_api.disconnect.assert_called_once() + + +async def test_ensure_connection_state( + hass: HomeAssistant, mock_desk_api: MagicMock +) -> None: + """Test that the connection state is ensured.""" + await init_integration(hass) + + mock_desk_api.connect.reset_mock() + mock_desk_api.is_connected = False + mock_desk_api.trigger_update_callback(None) + await hass.async_block_till_done() + mock_desk_api.connect.assert_called_once() + + await hass.services.async_call( + "button", "press", {"entity_id": "button.test_disconnect"}, blocking=True + ) + await hass.async_block_till_done() + + mock_desk_api.disconnect.reset_mock() + mock_desk_api.is_connected = True + mock_desk_api.trigger_update_callback(None) + await hass.async_block_till_done() + mock_desk_api.disconnect.assert_called_once() + + async def test_unload_entry(hass: HomeAssistant, mock_desk_api: MagicMock) -> None: """Test successful unload of entry.""" entry = await init_integration(hass)