Add better connection management for Idasen Desk (#102135)

This commit is contained in:
Abílio Costa 2023-10-18 23:58:31 +01:00 committed by GitHub
parent 2531b0bc09
commit 606b76c681
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 111 additions and 46 deletions

View File

@ -4,8 +4,9 @@ from __future__ import annotations
import logging import logging
from attr import dataclass from attr import dataclass
from bleak import BleakError from bleak.exc import BleakError
from idasen_ha import Desk from idasen_ha import Desk
from idasen_ha.errors import AuthFailedError
from homeassistant.components import bluetooth from homeassistant.components import bluetooth
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -15,7 +16,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_STOP,
Platform, Platform,
) )
from homeassistant.core import Event, HomeAssistant from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
@ -28,41 +29,84 @@ PLATFORMS: list[Platform] = [Platform.COVER]
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class IdasenDeskCoordinator(DataUpdateCoordinator):
"""Class to manage updates for the Idasen Desk."""
def __init__(
self,
hass: HomeAssistant,
logger: logging.Logger,
name: str,
address: str,
) -> None:
"""Init IdasenDeskCoordinator."""
super().__init__(hass, logger, name=name)
self._address = address
self._expected_connected = False
self.desk = Desk(self.async_set_updated_data)
async def async_connect(self) -> bool:
"""Connect to desk."""
_LOGGER.debug("Trying to connect %s", self._address)
ble_device = bluetooth.async_ble_device_from_address(
self.hass, self._address, connectable=True
)
if ble_device is None:
return False
self._expected_connected = True
await self.desk.connect(ble_device)
return True
async def async_disconnect(self) -> None:
"""Disconnect from desk."""
_LOGGER.debug("Disconnecting from %s", self._address)
self._expected_connected = False
await self.desk.disconnect()
@callback
def async_set_updated_data(self, data: int | None) -> None:
"""Handle data update."""
if self._expected_connected:
if not self.desk.is_connected:
_LOGGER.debug("Desk disconnected. Reconnecting")
self.hass.async_create_task(self.async_connect())
elif self.desk.is_connected:
_LOGGER.warning("Desk is connected but should not be. Disconnecting")
self.hass.async_create_task(self.desk.disconnect())
return super().async_set_updated_data(data)
@dataclass @dataclass
class DeskData: class DeskData:
"""Data for the Idasen Desk integration.""" """Data for the Idasen Desk integration."""
desk: Desk
address: str address: str
device_info: DeviceInfo device_info: DeviceInfo
coordinator: DataUpdateCoordinator coordinator: IdasenDeskCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up IKEA Idasen from a config entry.""" """Set up IKEA Idasen from a config entry."""
address: str = entry.data[CONF_ADDRESS].upper() address: str = entry.data[CONF_ADDRESS].upper()
coordinator: DataUpdateCoordinator = DataUpdateCoordinator( coordinator: IdasenDeskCoordinator = IdasenDeskCoordinator(
hass, hass, _LOGGER, entry.title, address
_LOGGER,
name=entry.title,
) )
desk = Desk(coordinator.async_set_updated_data)
device_info = DeviceInfo( device_info = DeviceInfo(
name=entry.title, name=entry.title,
connections={(dr.CONNECTION_BLUETOOTH, address)}, connections={(dr.CONNECTION_BLUETOOTH, address)},
) )
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = DeskData( hass.data.setdefault(DOMAIN, {})[entry.entry_id] = DeskData(
desk, address, device_info, coordinator address, device_info, coordinator
) )
ble_device = bluetooth.async_ble_device_from_address(
hass, address, connectable=True
)
try: try:
await desk.connect(ble_device) if not await coordinator.async_connect():
except (TimeoutError, BleakError) as ex: raise ConfigEntryNotReady(f"Unable to connect to desk {address}")
except (AuthFailedError, TimeoutError, BleakError, Exception) as ex:
raise ConfigEntryNotReady(f"Unable to connect to desk {address}") from ex raise ConfigEntryNotReady(f"Unable to connect to desk {address}") from ex
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@ -70,7 +114,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def _async_stop(event: Event) -> None: async def _async_stop(event: Event) -> None:
"""Close the connection.""" """Close the connection."""
await desk.disconnect() await coordinator.async_disconnect()
entry.async_on_unload( entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop)
@ -89,7 +133,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
data: DeskData = hass.data[DOMAIN].pop(entry.entry_id) data: DeskData = hass.data[DOMAIN].pop(entry.entry_id)
await data.desk.disconnect() await data.coordinator.async_disconnect()
bluetooth.async_rediscover_address(hass, data.address) bluetooth.async_rediscover_address(hass, data.address)
return unload_ok return unload_ok

View File

@ -6,7 +6,8 @@ from typing import Any
from bleak.exc import BleakError from bleak.exc import BleakError
from bluetooth_data_tools import human_readable_name from bluetooth_data_tools import human_readable_name
from idasen_ha import AuthFailedError, Desk from idasen_ha import Desk
from idasen_ha.errors import AuthFailedError
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
@ -61,9 +62,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) )
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
desk = Desk(None) desk = Desk(None, monitor_height=False)
try: try:
await desk.connect(discovery_info.device, monitor_height=False) await desk.connect(discovery_info.device, auto_reconnect=False)
except AuthFailedError as err: except AuthFailedError as err:
_LOGGER.exception("AuthFailedError", exc_info=err) _LOGGER.exception("AuthFailedError", exc_info=err)
errors["base"] = "auth_failed" errors["base"] = "auth_failed"

View File

@ -1,11 +1,8 @@
"""Idasen Desk integration cover platform.""" """Idasen Desk integration cover platform."""
from __future__ import annotations from __future__ import annotations
import logging
from typing import Any from typing import Any
from idasen_ha import Desk
from homeassistant.components.cover import ( from homeassistant.components.cover import (
ATTR_POSITION, ATTR_POSITION,
CoverDeviceClass, CoverDeviceClass,
@ -17,16 +14,11 @@ from homeassistant.const import ATTR_NAME
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import ( from homeassistant.helpers.update_coordinator import CoordinatorEntity
CoordinatorEntity,
DataUpdateCoordinator,
)
from . import DeskData from . import DeskData, IdasenDeskCoordinator
from .const import DOMAIN from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -36,7 +28,7 @@ async def async_setup_entry(
"""Set up the cover platform for Idasen Desk.""" """Set up the cover platform for Idasen Desk."""
data: DeskData = hass.data[DOMAIN][entry.entry_id] data: DeskData = hass.data[DOMAIN][entry.entry_id]
async_add_entities( async_add_entities(
[IdasenDeskCover(data.desk, data.address, data.device_info, data.coordinator)] [IdasenDeskCover(data.address, data.device_info, data.coordinator)]
) )
@ -54,14 +46,13 @@ class IdasenDeskCover(CoordinatorEntity, CoverEntity):
def __init__( def __init__(
self, self,
desk: Desk,
address: str, address: str,
device_info: DeviceInfo, device_info: DeviceInfo,
coordinator: DataUpdateCoordinator, coordinator: IdasenDeskCoordinator,
) -> None: ) -> None:
"""Initialize an Idasen Desk cover.""" """Initialize an Idasen Desk cover."""
super().__init__(coordinator) super().__init__(coordinator)
self._desk = desk self._desk = coordinator.desk
self._attr_name = device_info[ATTR_NAME] self._attr_name = device_info[ATTR_NAME]
self._attr_unique_id = address self._attr_unique_id = address
self._attr_device_info = device_info self._attr_device_info = device_info

View File

@ -11,5 +11,5 @@
"dependencies": ["bluetooth_adapters"], "dependencies": ["bluetooth_adapters"],
"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",
"requirements": ["idasen-ha==1.4.1"] "requirements": ["idasen-ha==2.3"]
} }

View File

@ -1051,7 +1051,7 @@ ical==5.0.1
icmplib==3.0 icmplib==3.0
# homeassistant.components.idasen_desk # homeassistant.components.idasen_desk
idasen-ha==1.4.1 idasen-ha==2.3
# homeassistant.components.network # homeassistant.components.network
ifaddr==0.2.0 ifaddr==0.2.0

View File

@ -831,7 +831,7 @@ ical==5.0.1
icmplib==3.0 icmplib==3.0
# homeassistant.components.idasen_desk # homeassistant.components.idasen_desk
idasen-ha==1.4.1 idasen-ha==2.3
# homeassistant.components.network # homeassistant.components.network
ifaddr==0.2.0 ifaddr==0.2.0

View File

@ -10,6 +10,10 @@ import pytest
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def mock_bluetooth(enable_bluetooth): def mock_bluetooth(enable_bluetooth):
"""Auto mock bluetooth.""" """Auto mock bluetooth."""
with mock.patch(
"homeassistant.components.idasen_desk.bluetooth.async_ble_device_from_address"
):
yield MagicMock()
@pytest.fixture(autouse=False) @pytest.fixture(autouse=False)
@ -18,14 +22,22 @@ def mock_desk_api():
with mock.patch("homeassistant.components.idasen_desk.Desk") as desk_patched: with mock.patch("homeassistant.components.idasen_desk.Desk") as desk_patched:
mock_desk = MagicMock() mock_desk = MagicMock()
def mock_init(update_callback: Callable[[int | None], None] | None): def mock_init(
update_callback: Callable[[int | None], None] | None,
monitor_height: bool = True,
):
mock_desk.trigger_update_callback = update_callback mock_desk.trigger_update_callback = update_callback
return mock_desk return mock_desk
desk_patched.side_effect = mock_init desk_patched.side_effect = mock_init
async def mock_connect(ble_device, monitor_height: bool = True): async def mock_connect(ble_device):
mock_desk.is_connected = True mock_desk.is_connected = True
mock_desk.trigger_update_callback(None)
async def mock_disconnect():
mock_desk.is_connected = False
mock_desk.trigger_update_callback(None)
async def mock_move_to(height: float): async def mock_move_to(height: float):
mock_desk.height_percent = height mock_desk.height_percent = height
@ -38,12 +50,13 @@ def mock_desk_api():
await mock_move_to(0) await mock_move_to(0)
mock_desk.connect = AsyncMock(side_effect=mock_connect) mock_desk.connect = AsyncMock(side_effect=mock_connect)
mock_desk.disconnect = AsyncMock() mock_desk.disconnect = AsyncMock(side_effect=mock_disconnect)
mock_desk.move_to = AsyncMock(side_effect=mock_move_to) mock_desk.move_to = AsyncMock(side_effect=mock_move_to)
mock_desk.move_up = AsyncMock(side_effect=mock_move_up) mock_desk.move_up = AsyncMock(side_effect=mock_move_up)
mock_desk.move_down = AsyncMock(side_effect=mock_move_down) mock_desk.move_down = AsyncMock(side_effect=mock_move_down)
mock_desk.stop = AsyncMock() mock_desk.stop = AsyncMock()
mock_desk.height_percent = 60 mock_desk.height_percent = 60
mock_desk.is_moving = False mock_desk.is_moving = False
mock_desk.address = "AA:BB:CC:DD:EE:FF"
yield mock_desk yield mock_desk

View File

@ -1,8 +1,8 @@
"""Test the IKEA Idasen Desk config flow.""" """Test the IKEA Idasen Desk config flow."""
from unittest.mock import patch from unittest.mock import ANY, patch
from bleak import BleakError from bleak.exc import BleakError
from idasen_ha import AuthFailedError from idasen_ha.errors import AuthFailedError
import pytest import pytest
from homeassistant import config_entries from homeassistant import config_entries
@ -260,7 +260,9 @@ async def test_bluetooth_step_success(hass: HomeAssistant) -> None:
assert result["step_id"] == "user" assert result["step_id"] == "user"
assert result["errors"] == {} assert result["errors"] == {}
with patch("homeassistant.components.idasen_desk.config_flow.Desk.connect"), patch( with patch(
"homeassistant.components.idasen_desk.config_flow.Desk.connect"
) as desk_connect, patch(
"homeassistant.components.idasen_desk.config_flow.Desk.disconnect" "homeassistant.components.idasen_desk.config_flow.Desk.disconnect"
), patch( ), patch(
"homeassistant.components.idasen_desk.async_setup_entry", "homeassistant.components.idasen_desk.async_setup_entry",
@ -281,3 +283,4 @@ async def test_bluetooth_step_success(hass: HomeAssistant) -> None:
} }
assert result2["result"].unique_id == IDASEN_DISCOVERY_INFO.address assert result2["result"].unique_id == IDASEN_DISCOVERY_INFO.address
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
desk_connect.assert_called_with(ANY, auto_reconnect=False)

View File

@ -1,7 +1,9 @@
"""Test the IKEA Idasen Desk init.""" """Test the IKEA Idasen Desk init."""
from unittest import mock
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
from bleak import BleakError from bleak.exc import BleakError
from idasen_ha.errors import AuthFailedError
import pytest import pytest
from homeassistant.components.idasen_desk.const import DOMAIN from homeassistant.components.idasen_desk.const import DOMAIN
@ -28,7 +30,7 @@ async def test_setup_and_shutdown(
mock_desk_api.disconnect.assert_called_once() mock_desk_api.disconnect.assert_called_once()
@pytest.mark.parametrize("exception", [TimeoutError(), BleakError()]) @pytest.mark.parametrize("exception", [AuthFailedError(), TimeoutError(), BleakError()])
async def test_setup_connect_exception( async def test_setup_connect_exception(
hass: HomeAssistant, mock_desk_api: MagicMock, exception: Exception hass: HomeAssistant, mock_desk_api: MagicMock, exception: Exception
) -> None: ) -> None:
@ -39,6 +41,17 @@ async def test_setup_connect_exception(
assert entry.state is ConfigEntryState.SETUP_RETRY assert entry.state is ConfigEntryState.SETUP_RETRY
async def test_no_ble_device(hass: HomeAssistant, mock_desk_api: MagicMock) -> None:
"""Test setup with no BLEDevice from address."""
with mock.patch(
"homeassistant.components.idasen_desk.bluetooth.async_ble_device_from_address",
return_value=None,
):
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: 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)