Make Idasen Desk react to bluetooth changes (#115939)

This commit is contained in:
Abílio Costa
2024-05-06 23:52:54 +01:00
committed by GitHub
parent 4037f52d62
commit 486b8ca7c4
6 changed files with 113 additions and 10 deletions

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
from attr import dataclass from attr import dataclass
@@ -10,6 +11,7 @@ from idasen_ha import Desk
from idasen_ha.errors import AuthFailedError from idasen_ha.errors import AuthFailedError
from homeassistant.components import bluetooth from homeassistant.components import bluetooth
from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
ATTR_NAME, ATTR_NAME,
@@ -46,6 +48,7 @@ class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]): # pylint: disab
self._address = address self._address = address
self._expected_connected = False self._expected_connected = False
self._connection_lost = False self._connection_lost = False
self._disconnect_lock = asyncio.Lock()
self.desk = Desk(self.async_set_updated_data) 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 self.hass, self._address, connectable=True
) )
if ble_device is None: if ble_device is None:
_LOGGER.debug("No BLEDevice for %s", self._address)
return False return False
self._expected_connected = True self._expected_connected = True
await self.desk.connect(ble_device) await self.desk.connect(ble_device)
@@ -68,20 +72,28 @@ class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]): # pylint: disab
self._connection_lost = False self._connection_lost = False
await self.desk.disconnect() await self.desk.disconnect()
@callback async def async_ensure_connection_state(self) -> None:
def async_set_updated_data(self, data: int | None) -> None: """Check if the expected connection state matches the current state and correct it if needed."""
"""Handle data update."""
if self._expected_connected: if self._expected_connected:
if not self.desk.is_connected: if not self.desk.is_connected:
_LOGGER.debug("Desk disconnected. Reconnecting") _LOGGER.debug("Desk disconnected. Reconnecting")
self._connection_lost = True self._connection_lost = True
self.hass.async_create_task(self.async_connect(), eager_start=False) await self.async_connect()
elif self._connection_lost: elif self._connection_lost:
_LOGGER.info("Reconnected to desk") _LOGGER.info("Reconnected to desk")
self._connection_lost = False self._connection_lost = False
elif self.desk.is_connected: elif self.desk.is_connected:
_LOGGER.warning("Desk is connected but should not be. Disconnecting") if self._disconnect_lock.locked():
self.hass.async_create_task(self.desk.disconnect()) _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) 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) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(_async_update_listener)) 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: async def _async_stop(event: Event) -> None:
"""Close the connection.""" """Close the connection."""
await coordinator.async_disconnect() await coordinator.async_disconnect()

View File

@@ -12,5 +12,5 @@
"documentation": "https://www.home-assistant.io/integrations/idasen_desk", "documentation": "https://www.home-assistant.io/integrations/idasen_desk",
"iot_class": "local_push", "iot_class": "local_push",
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["idasen-ha==2.5.1"] "requirements": ["idasen-ha==2.5.3"]
} }

View File

@@ -1122,7 +1122,7 @@ ical==8.0.0
icmplib==3.0 icmplib==3.0
# homeassistant.components.idasen_desk # homeassistant.components.idasen_desk
idasen-ha==2.5.1 idasen-ha==2.5.3
# homeassistant.components.network # homeassistant.components.network
ifaddr==0.2.0 ifaddr==0.2.0

View File

@@ -915,7 +915,7 @@ ical==8.0.0
icmplib==3.0 icmplib==3.0
# homeassistant.components.idasen_desk # homeassistant.components.idasen_desk
idasen-ha==2.5.1 idasen-ha==2.5.3
# homeassistant.components.network # homeassistant.components.network
ifaddr==0.2.0 ifaddr==0.2.0

View File

@@ -1,7 +1,7 @@
"""Test the IKEA Idasen Desk cover.""" """Test the IKEA Idasen Desk cover."""
from typing import Any from typing import Any
from unittest.mock import MagicMock from unittest.mock import AsyncMock, MagicMock
from bleak.exc import BleakError from bleak.exc import BleakError
import pytest import pytest
@@ -39,6 +39,7 @@ async def test_cover_available(
assert state.state == STATE_OPEN assert state.state == STATE_OPEN
assert state.attributes[ATTR_CURRENT_POSITION] == 60 assert state.attributes[ATTR_CURRENT_POSITION] == 60
mock_desk_api.connect = AsyncMock()
mock_desk_api.is_connected = False mock_desk_api.is_connected = False
mock_desk_api.trigger_update_callback(None) mock_desk_api.trigger_update_callback(None)

View File

@@ -1,5 +1,6 @@
"""Test the IKEA Idasen Desk init.""" """Test the IKEA Idasen Desk init."""
import asyncio
from unittest import mock from unittest import mock
from unittest.mock import AsyncMock, MagicMock 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 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: async def test_unload_entry(hass: HomeAssistant, mock_desk_api: MagicMock) -> None:
"""Test successful unload of entry.""" """Test successful unload of entry."""
entry = await init_integration(hass) entry = await init_integration(hass)