mirror of
https://github.com/home-assistant/core.git
synced 2025-07-10 23:07:09 +00:00
Add better connection management for Idasen Desk (#102135)
This commit is contained in:
parent
2531b0bc09
commit
606b76c681
@ -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
|
||||||
|
@ -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"
|
||||||
|
@ -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
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user