Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion idasen_ha/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ async def connect(self, ble_device: BLEDevice, retry: bool = True) -> None:
self._ble_device = ble_device
self._connection_manager = self._create_connection_manager(self._ble_device)

await self._connection_manager.connect(retry)
await self._connection_manager.connect(ble_device, retry)

async def disconnect(self) -> None:
"""Disconnect from the desk."""
Expand Down Expand Up @@ -103,6 +103,8 @@ async def _start_monitoring(self) -> None:

async def update_height(height: float) -> None:
self._height = height
if self._idasen_desk is not None:
self._idasen_desk.update_height(height)
self._update_callback(self.height_percent)

await self._idasen_desk.monitor(update_height)
Expand Down Expand Up @@ -151,6 +153,7 @@ async def connect_callback() -> None:

if self._monitor_height:
self._height = await self._idasen_desk.get_height()
self._idasen_desk.update_height(self._height)
await self._start_monitoring()

self._update_callback(self.height_percent)
Expand Down
40 changes: 25 additions & 15 deletions idasen_ha/connection_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@

from bleak.backends.device import BLEDevice
from bleak.exc import BleakDBusError, BleakError
from idasen import IdasenDesk
from bleak_retry_connector import (
BleakClientWithServiceCache,
close_stale_connections_by_address,
establish_connection,
)

from .desk import ManagedIdasenDesk
from .errors import AuthFailedError

_LOGGER = logging.getLogger(__name__)
Expand All @@ -27,19 +32,23 @@ def __init__(
self._keep_connected: bool = False
self._connecting: bool = False
self._retry_pending: bool = False
self._ble_device = ble_device

self._idasen_desk: IdasenDesk = self._create_idasen_desk(ble_device)
self._idasen_desk: ManagedIdasenDesk = self._create_idasen_desk(ble_device)

self._connect_callback = connect_callback
self._disconnect_callback = disconnect_callback

@property
def idasen_desk(self) -> IdasenDesk:
def idasen_desk(self) -> ManagedIdasenDesk:
"""The IdasenDesk instance."""
return self._idasen_desk

async def connect(self, retry: bool) -> None:
async def connect(self, ble_device: BLEDevice, retry: bool) -> None:
"""Perform the bluetooth connection to the desk."""
if ble_device.address != self._ble_device.address:
self._ble_device = ble_device
self._idasen_desk = self._create_idasen_desk(ble_device)
self._keep_connected = True
await self._connect(retry)

Expand All @@ -49,12 +58,8 @@ async def disconnect(self):
if self._idasen_desk.is_connected:
await self._idasen_desk.disconnect()

def _create_idasen_desk(self, ble_device: BLEDevice) -> IdasenDesk:
return IdasenDesk(
ble_device,
exit_on_fail=False,
disconnected_callback=lambda bledevice: self._handle_disconnect(),
)
def _create_idasen_desk(self, ble_device: BLEDevice) -> ManagedIdasenDesk:
return ManagedIdasenDesk(ble_device, exit_on_fail=False)

async def _connect(self, retry: bool) -> None:
if self._idasen_desk.is_connected:
Expand All @@ -68,10 +73,15 @@ async def _connect(self, retry: bool) -> None:
self._connecting = True
try:
try:
_LOGGER.info("Connecting...")
# Connect without wakeup — IdasenDesk.connect() bundles both,
# but we need pair() to complete before wakeup().
await self._idasen_desk._client.connect() # noqa: SLF001
_LOGGER.info("Connecting via bleak-retry-connector...")
await close_stale_connections_by_address(self._ble_device.address)
client = await establish_connection(
BleakClientWithServiceCache,
self._ble_device,
self._ble_device.address,
disconnected_callback=lambda _client: self._handle_disconnect(),
)
self._idasen_desk.set_client(client)
except (TimeoutError, BleakError) as ex:
_LOGGER.exception("Connect failed")
if retry:
Expand Down Expand Up @@ -165,4 +175,4 @@ def _handle_disconnect(self) -> None:
self._disconnect_callback()
if self._keep_connected and not self._retry_pending:
_LOGGER.info("Reconnecting since it should not be disconnected")
asyncio.get_event_loop().create_task(self.connect(True))
asyncio.get_event_loop().create_task(self.connect(self._ble_device, True))
143 changes: 143 additions & 0 deletions idasen_ha/desk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""Idasen desk helpers for Home Assistant integration."""

import asyncio
import logging
import time
from typing import Optional, Union

from bleak import BleakClient
from bleak.backends.device import BLEDevice
from idasen import (
_COMMAND_REFERENCE_INPUT_STOP,
_COMMAND_STOP,
_COMMAND_WAKEUP,
_UUID_COMMAND,
_UUID_REFERENCE_INPUT,
IdasenDesk,
_DeskLoggingAdapter,
_meters_to_bytes,
)

_HEIGHT_TOLERANCE: float = 0.005
_MOVE_WRITE_INTERVAL: float = 0.2
_MOVE_TIMEOUT: float = 30.0


class ManagedIdasenDesk(IdasenDesk):
"""IdasenDesk variant whose BLE client is managed externally.

The upstream ``IdasenDesk.__init__`` creates its own ``BleakClient``, but in
Bluetooth proxy flows the connection is established externally via
``bleak-retry-connector``. This subclass skips the unused client creation
and lets the caller inject one later with :meth:`set_client`.

It also overrides ``move_to_target`` with a write-only move loop that
avoids GATT reads during movement, relying on BLE notifications instead.
"""

def __init__(
self,
mac: Union[BLEDevice, str],
exit_on_fail: bool = False,
):
"""Initialize without creating a BleakClient."""
self._exit_on_fail = exit_on_fail
self._client: Optional[BleakClient] = None
self._mac = mac.address if isinstance(mac, BLEDevice) else mac
self._logger = _DeskLoggingAdapter(
logger=logging.getLogger(__name__), extra={"mac": self.mac}
)
self._moving = False
self._move_task: Optional[asyncio.Task] = None
self._notified_height: Optional[float] = None

@property
def is_connected(self) -> bool:
"""Return whether the desk is connected."""
return self._client is not None and self._client.is_connected

def set_client(self, client: BleakClient) -> None:
"""Adopt an externally-established BLE client."""
self._client = client

def update_height(self, height: float) -> None:
"""Store the latest height received via BLE notification."""
self._notified_height = height

async def move_to_target(self, target: float) -> None:
"""Move the desk to the target position.

Sends only GATT writes (no reads) during the move loop to avoid
blocking the BLE ATT channel, which is especially important over
Bluetooth proxies. Arrival is detected via the height reported by
BLE notifications (see :meth:`update_height`).
"""
if target > self.MAX_HEIGHT:
raise ValueError(
f"target position of {target:.3f} meters exceeds maximum of "
f"{self.MAX_HEIGHT:.3f}"
)
elif target < self.MIN_HEIGHT:
raise ValueError(
f"target position of {target:.3f} meters exceeds minimum of "
f"{self.MIN_HEIGHT:.3f}"
)

if self._moving:
self._logger.error("Already moving")
return
self._moving = True

async def do_move() -> None:
if self._client is None:
return

current_height = await self.get_height()
if abs(current_height - target) < _HEIGHT_TOLERANCE:
return

await self._client.write_gatt_char(_UUID_COMMAND, _COMMAND_WAKEUP)
await self._client.write_gatt_char(_UUID_COMMAND, _COMMAND_STOP)

data = _meters_to_bytes(target)
deadline = time.monotonic() + _MOVE_TIMEOUT
self._logger.debug(
"Moving to target=%.3fm from height=%.3fm", target, current_height
)

try:
while self._moving:
await self._client.write_gatt_char(
_UUID_REFERENCE_INPUT, data, response=True
)

await asyncio.sleep(_MOVE_WRITE_INTERVAL)

height = self._notified_height
if height is not None and abs(height - target) < _HEIGHT_TOLERANCE:
self._logger.debug("Reached target (height=%.3fm)", height)
break

if time.monotonic() >= deadline:
self._logger.warning(
"Move timed out after %.0fs (height=%.3fm target=%.3fm)",
_MOVE_TIMEOUT,
height if height is not None else -1,
target,
)
break
finally:
await self._client.write_gatt_char(
_UUID_COMMAND, _COMMAND_STOP, response=False
)
await self._client.write_gatt_char(
_UUID_REFERENCE_INPUT,
_COMMAND_REFERENCE_INPUT_STOP,
response=False,
)

self._move_task = asyncio.create_task(do_move())
try:
await self._move_task
finally:
self._moving = False
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ classifiers = [
]
requires-python = ">=3.9"
dependencies = [
"idasen>=0.10,<=0.12.0"
"idasen>=0.10,<=0.12.0",
"bleak-retry-connector>=3.4.0",
]

[project.readme]
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
idasen>=0.10,<=0.12.0
bleak-retry-connector>=3.4.0
34 changes: 22 additions & 12 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
"""Generic test fixtures."""

from collections.abc import Awaitable
from typing import Callable, Optional
from typing import Callable
from unittest import mock
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, MagicMock

from bleak import BleakClient
from idasen import IdasenDesk
import pytest

Expand All @@ -14,9 +13,14 @@
async def mock_idasen_desk():
"""Test height monitoring."""

with mock.patch(
"idasen_ha.connection_manager.IdasenDesk", autospec=True
) as patched_idasen_desk:
with (
mock.patch(
"idasen_ha.connection_manager.ManagedIdasenDesk", autospec=True
) as patched_idasen_desk,
mock.patch(
"idasen_ha.connection_manager.establish_connection"
) as mock_establish_connection,
):
patched_idasen_desk.MIN_HEIGHT = IdasenDesk.MIN_HEIGHT
patched_idasen_desk.MAX_HEIGHT = IdasenDesk.MAX_HEIGHT

Expand All @@ -25,29 +29,35 @@ async def mock_idasen_desk():
def mock_init(
mac_bledevice,
exit_on_fail: bool = False,
disconnected_callback: Optional[Callable[[BleakClient], None]] = None,
disconnected_callback=None,
):
mock_desk.trigger_disconnected_callback = disconnected_callback
return mock_desk

patched_idasen_desk.side_effect = mock_init

async def mock_client_connect():
async def mock_establish_conn(
client_class, ble_device, address, disconnected_callback=None, **kwargs
):
mock_desk.is_connected = True
mock_desk.trigger_disconnected_callback = disconnected_callback
return MagicMock()

mock_establish_connection.side_effect = mock_establish_conn

async def mock_disconnect():
mock_desk.is_connected = False
mock_desk.trigger_disconnected_callback(None)
if mock_desk.trigger_disconnected_callback:
mock_desk.trigger_disconnected_callback(None)

async def mock_monitor(callback: Callable[[float], Awaitable[None]]) -> None:
mock_desk.trigger_monitor_callback = callback

mock_desk._client = AsyncMock()
mock_desk._client.connect = AsyncMock(side_effect=mock_client_connect)
mock_desk.connect = mock_establish_connection
mock_desk.disconnect = AsyncMock(side_effect=mock_disconnect)
mock_desk.wakeup = AsyncMock()
mock_desk.monitor = AsyncMock(side_effect=mock_monitor)
mock_desk.is_connected = False
mock_desk.is_moving = False
mock_desk.trigger_disconnected_callback = None

yield mock_desk
Loading
Loading