diff --git a/idasen_ha/__init__.py b/idasen_ha/__init__.py index 04d2183..dc031c0 100644 --- a/idasen_ha/__init__.py +++ b/idasen_ha/__init__.py @@ -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.""" @@ -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) @@ -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) diff --git a/idasen_ha/connection_manager.py b/idasen_ha/connection_manager.py index 7728824..771ce7a 100644 --- a/idasen_ha/connection_manager.py +++ b/idasen_ha/connection_manager.py @@ -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__) @@ -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) @@ -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: @@ -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: @@ -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)) diff --git a/idasen_ha/desk.py b/idasen_ha/desk.py new file mode 100644 index 0000000..f7b4a75 --- /dev/null +++ b/idasen_ha/desk.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 248e648..8d5377f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/requirements.txt b/requirements.txt index 31e1bc6..ee9923c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ idasen>=0.10,<=0.12.0 +bleak-retry-connector>=3.4.0 diff --git a/tests/conftest.py b/tests/conftest.py index 061a6c6..0ce6583 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 @@ -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 @@ -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 diff --git a/tests/test_connection_management.py b/tests/test_connection_management.py index edd2be6..bbf77b6 100644 --- a/tests/test_connection_management.py +++ b/tests/test_connection_management.py @@ -21,7 +21,7 @@ async def test_connect_disconnect(mock_idasen_desk: MagicMock): await desk.connect(FAKE_BLE_DEVICE) assert desk.is_connected - mock_idasen_desk._client.connect.assert_awaited() + mock_idasen_desk.connect.assert_awaited() mock_idasen_desk.pair.assert_called() mock_idasen_desk.wakeup.assert_awaited_once() assert update_callback.call_count == 1 @@ -41,7 +41,7 @@ async def test_connect_skipped_when_already_connected(mock_idasen_desk: MagicMoc mock_idasen_desk.is_connected = True await desk.connect(FAKE_BLE_DEVICE) - mock_idasen_desk._client.connect.assert_not_awaited() + mock_idasen_desk.connect.assert_not_awaited() mock_idasen_desk.pair.assert_not_called() mock_idasen_desk.wakeup.assert_not_called() assert update_callback.call_count == 0 @@ -52,18 +52,18 @@ async def test_double_connect_call_with_same_bledevice(mock_idasen_desk: MagicMo update_callback = Mock() desk = Desk(update_callback, False) - default_connect_side_effect = mock_idasen_desk._client.connect.side_effect + default_connect_side_effect = mock_idasen_desk.connect.side_effect - async def connect_side_effect(): + async def connect_side_effect(*args, **kwargs): # call the seccond `connect` while the first is ongoing await desk.connect(FAKE_BLE_DEVICE) - await default_connect_side_effect() + await default_connect_side_effect(*args, **kwargs) - mock_idasen_desk._client.connect.side_effect = connect_side_effect + mock_idasen_desk.connect.side_effect = connect_side_effect await desk.connect(FAKE_BLE_DEVICE) assert desk.is_connected - mock_idasen_desk._client.connect.assert_awaited() + mock_idasen_desk.connect.assert_awaited() mock_idasen_desk.pair.assert_called() mock_idasen_desk.wakeup.assert_awaited_once() assert update_callback.call_count == 1 @@ -72,28 +72,45 @@ async def connect_side_effect(): async def test_double_connect_call_with_different_bledevice(): """Test connect being called again with a new BLEDevice, while still connecting.""" - 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, + ): mock_idasen_desk = patched_idasen_desk.return_value + mock_idasen_desk.is_connected = False + mock_idasen_desk.wakeup = AsyncMock() + + async def mock_disconnect(): + mock_idasen_desk.is_connected = False + + mock_idasen_desk.disconnect = AsyncMock(side_effect=mock_disconnect) + + async def first_establish_side_effect( + client_class, ble_device, address, **kwargs + ): + async def subsequent_establish(*args, **kw): + mock_idasen_desk.is_connected = True + return MagicMock() - async def connect_side_effect(): - # call the seccond `connect` while the first is ongoing - mock_idasen_desk._client.connect.side_effect = MagicMock() + mock_establish_connection.side_effect = subsequent_establish new_ble_device = BLEDevice("AA:BB:CC:DD:EE:AA", None, None) await desk.connect(new_ble_device) + mock_idasen_desk.is_connected = True + return MagicMock() - mock_idasen_desk.is_connected = False - mock_idasen_desk._client = AsyncMock() - mock_idasen_desk._client.connect.side_effect = connect_side_effect - mock_idasen_desk.wakeup = AsyncMock() + mock_establish_connection.side_effect = first_establish_side_effect update_callback = Mock() desk = Desk(update_callback, False) await desk.connect(FAKE_BLE_DEVICE) - mock_idasen_desk._client.connect.assert_awaited() + mock_establish_connection.assert_awaited() mock_idasen_desk.pair.assert_called() + assert mock_idasen_desk.wakeup.await_count == 2 assert update_callback.call_count == 2 assert patched_idasen_desk.call_count == 2 @@ -108,22 +125,22 @@ async def test_connect_called_while_retry_pending( update_callback = Mock() desk = Desk(update_callback, False) - default_connect_side_effect = mock_idasen_desk._client.connect.side_effect + default_connect_side_effect = mock_idasen_desk.connect.side_effect async def sleep_side_effect(delay): - mock_idasen_desk._client.connect.side_effect = default_connect_side_effect + mock_idasen_desk.connect.side_effect = default_connect_side_effect await desk.connect(FAKE_BLE_DEVICE) retry_maxed_future.set_result(None) sleep_mock.side_effect = sleep_side_effect - mock_idasen_desk._client.connect.side_effect = TimeoutError() + mock_idasen_desk.connect.side_effect = TimeoutError() await desk.connect(FAKE_BLE_DEVICE) assert not desk.is_connected await retry_maxed_future assert desk.is_connected - assert mock_idasen_desk._client.connect.call_count == 2 + assert mock_idasen_desk.connect.call_count == 2 assert update_callback.call_count == 1 @@ -131,7 +148,7 @@ async def test_connect_raises_without_auto_reconnect(mock_idasen_desk: MagicMock """Test that connect raises if auto_reconnect is False.""" desk = Desk(Mock(), False) - mock_idasen_desk._client.connect.side_effect = TimeoutError() + mock_idasen_desk.connect.side_effect = TimeoutError() with pytest.raises(TimeoutError): await desk.connect(FAKE_BLE_DEVICE, retry=False) assert not desk.is_connected @@ -185,7 +202,7 @@ async def test_disconnect_on_wakeup_failure(mock_idasen_desk: MagicMock): @mock.patch("idasen_ha.connection_manager.asyncio.sleep") @pytest.mark.parametrize("exception", [TimeoutError(), BleakError()]) -@pytest.mark.parametrize("fail_call_name", ["_client.connect", "pair", "wakeup"]) +@pytest.mark.parametrize("fail_call_name", ["connect", "pair", "wakeup"]) async def test_connect_exception_retry_with_disconnect( sleep_mock, mock_idasen_desk: MagicMock, @@ -208,14 +225,11 @@ async def sleep_handler(delay): desk = Desk(Mock(), False) - fail_target = mock_idasen_desk - for attr in fail_call_name.split("."): - fail_target = getattr(fail_target, attr) - fail_target.side_effect = exception + getattr(mock_idasen_desk, fail_call_name).side_effect = exception await desk.connect(FAKE_BLE_DEVICE) await retry_maxed_future - assert mock_idasen_desk._client.connect.call_count == TEST_RETRIES_MAX + 1 + assert mock_idasen_desk.connect.call_count == TEST_RETRIES_MAX + 1 @mock.patch("idasen_ha.connection_manager.asyncio.sleep") @@ -228,7 +242,7 @@ async def sleep_handler(delay): BleakDBusError("org.bluez.Error.AuthenticationFailed", []), ], ) -@pytest.mark.parametrize("fail_call_name", ["_client.connect", "pair", "wakeup"]) +@pytest.mark.parametrize("fail_call_name", ["connect", "pair", "wakeup"]) async def test_connect_exception_retry_success( sleep_mock, mock_idasen_desk: MagicMock, @@ -240,26 +254,41 @@ async def test_connect_exception_retry_success( retry_count = 0 retry_maxed_future = asyncio.Future() - fail_target = mock_idasen_desk - for attr in fail_call_name.split("."): - fail_target = getattr(fail_target, attr) - default_fail_call_side_effect = fail_target.side_effect + fail_call = getattr(mock_idasen_desk, fail_call_name) + default_fail_call_side_effect = fail_call.side_effect async def sleep_handler(delay): nonlocal retry_count if retry_count == TEST_RETRIES_MAX: - fail_target.side_effect = default_fail_call_side_effect + fail_call.side_effect = default_fail_call_side_effect retry_maxed_future.set_result(None) retry_count = retry_count + 1 sleep_mock.side_effect = sleep_handler desk = Desk(Mock(), False) - fail_target.side_effect = exception + fail_call.side_effect = exception await desk.connect(FAKE_BLE_DEVICE) await retry_maxed_future - assert mock_idasen_desk._client.connect.call_count == TEST_RETRIES_MAX + 2 + assert mock_idasen_desk.connect.call_count == TEST_RETRIES_MAX + 2 + + +async def test_connect_with_different_ble_device_address(mock_idasen_desk: MagicMock): + """Test that connect refreshes the desk when the BLE device address changes.""" + update_callback = Mock() + desk = Desk(update_callback, False) + + await desk.connect(FAKE_BLE_DEVICE) + assert desk.is_connected + assert update_callback.call_count == 1 + + await desk.disconnect() + + new_device = BLEDevice("11:22:33:44:55:66", None, {"path": ""}) + await desk.connect(new_device) + assert desk.is_connected + assert update_callback.call_count == 3 async def test_reconnect_on_connection_drop(mock_idasen_desk: MagicMock): @@ -272,13 +301,12 @@ async def test_reconnect_on_connection_drop(mock_idasen_desk: MagicMock): assert update_callback.call_count == 1 mock_idasen_desk.reset_mock() - mock_idasen_desk._client.connect.reset_mock() await mock_idasen_desk.disconnect() assert not desk.is_connected assert update_callback.call_count == 2 await asyncio.sleep(0) assert desk.is_connected - mock_idasen_desk._client.connect.assert_awaited() + mock_idasen_desk.connect.assert_awaited() mock_idasen_desk.pair.assert_called() assert update_callback.call_count == 3 diff --git a/tests/test_desk.py b/tests/test_desk.py new file mode 100644 index 0000000..c7604a2 --- /dev/null +++ b/tests/test_desk.py @@ -0,0 +1,247 @@ +"""Tests for ManagedIdasenDesk.""" + +import asyncio +import struct +from unittest.mock import AsyncMock, MagicMock, patch + +from bleak.backends.device import BLEDevice +from idasen import ( + _COMMAND_REFERENCE_INPUT_STOP, + _COMMAND_STOP, + _UUID_COMMAND, + _UUID_REFERENCE_INPUT, + IdasenDesk, +) +import pytest + +from idasen_ha.desk import _HEIGHT_TOLERANCE, ManagedIdasenDesk + + +def _encode_height_speed(height_m: float, speed_m_s: float) -> bytearray: + raw_height = int((height_m - IdasenDesk.MIN_HEIGHT) * 10000) + raw_speed = int(speed_m_s * 10000) + return bytearray(struct.pack(" tuple[ManagedIdasenDesk, MagicMock]: + desk = ManagedIdasenDesk("AA:BB:CC:DD:EE:FF") + client = MagicMock() + client.write_gatt_char = AsyncMock() + client.read_gatt_char = AsyncMock() + desk.set_client(client) + return desk, client + + +def test_init_with_ble_device(): + """Test that __init__ does not create a BleakClient.""" + ble_device = BLEDevice("AA:BB:CC:DD:EE:FF", None, {"path": ""}) + desk = ManagedIdasenDesk(ble_device, exit_on_fail=False) + + assert desk.mac == "AA:BB:CC:DD:EE:FF" + assert desk._client is None + assert not desk._moving + assert desk._move_task is None + assert desk._notified_height is None + + +def test_init_with_mac_string(): + """Test init with a plain MAC address string.""" + desk = ManagedIdasenDesk("AA:BB:CC:DD:EE:FF", exit_on_fail=False) + + assert desk.mac == "AA:BB:CC:DD:EE:FF" + assert desk._client is None + + +def test_set_client(): + """Test that set_client replaces the internal client.""" + desk = ManagedIdasenDesk("AA:BB:CC:DD:EE:FF") + assert desk._client is None + + mock_client = MagicMock() + desk.set_client(mock_client) + assert desk._client is mock_client + + +def test_update_height(): + """Test that update_height stores the notified height.""" + desk = ManagedIdasenDesk("AA:BB:CC:DD:EE:FF") + assert desk._notified_height is None + + desk.update_height(0.85) + assert desk._notified_height == 0.85 + + desk.update_height(1.00) + assert desk._notified_height == 1.00 + + +async def test_move_to_target_already_at_target(): + """Test that no movement occurs when already at target height.""" + desk, client = _make_desk_with_client() + target = 0.80 + client.read_gatt_char.return_value = _encode_height_speed(target, 0.0) + + await desk.move_to_target(target) + + assert client.read_gatt_char.call_count == 1 + write_calls = [ + c + for c in client.write_gatt_char.call_args_list + if c.args[0] == _UUID_REFERENCE_INPUT + ] + assert len(write_calls) == 0 + assert not desk._moving + + +async def test_move_to_target_reaches_target(): + """Test normal movement that reaches target via notification.""" + desk, client = _make_desk_with_client() + target = 1.00 + + client.read_gatt_char.return_value = _encode_height_speed(0.80, 0.0) + + async def simulate_height_updates(): + await asyncio.sleep(0.05) + desk.update_height(0.85) + await asyncio.sleep(0.05) + desk.update_height(0.92) + await asyncio.sleep(0.05) + desk.update_height(target) + + with patch("idasen_ha.desk._MOVE_WRITE_INTERVAL", 0.02): + task = asyncio.create_task(simulate_height_updates()) + await desk.move_to_target(target) + await task + + assert not desk._moving + assert client.read_gatt_char.call_count == 1 + + +async def test_move_to_target_sends_stop_after_reaching_target(): + """Test that stop commands are sent after the move loop exits.""" + desk, client = _make_desk_with_client() + target = 1.00 + + client.read_gatt_char.return_value = _encode_height_speed(0.80, 0.0) + + async def simulate_arrival(): + await asyncio.sleep(0.05) + desk.update_height(target) + + with patch("idasen_ha.desk._MOVE_WRITE_INTERVAL", 0.02): + task = asyncio.create_task(simulate_arrival()) + await desk.move_to_target(target) + await task + + write_calls = client.write_gatt_char.call_args_list + stop_calls = [c for c in write_calls if c.args == (_UUID_COMMAND, _COMMAND_STOP)] + ref_stop_calls = [ + c + for c in write_calls + if c.args == (_UUID_REFERENCE_INPUT, _COMMAND_REFERENCE_INPUT_STOP) + ] + assert len(stop_calls) >= 2 + assert len(ref_stop_calls) >= 1 + + +async def test_move_to_target_no_gatt_reads_during_loop(): + """Test that no GATT reads happen after the initial height check.""" + desk, client = _make_desk_with_client() + target = 1.00 + + client.read_gatt_char.return_value = _encode_height_speed(0.80, 0.0) + + async def simulate_arrival(): + await asyncio.sleep(0.08) + desk.update_height(target) + + with patch("idasen_ha.desk._MOVE_WRITE_INTERVAL", 0.02): + task = asyncio.create_task(simulate_arrival()) + await desk.move_to_target(target) + await task + + assert client.read_gatt_char.call_count == 1 + + +async def test_move_to_target_times_out(): + """Test that the move loop exits after the timeout.""" + desk, client = _make_desk_with_client() + target = 1.00 + + client.read_gatt_char.return_value = _encode_height_speed(0.80, 0.0) + desk.update_height(0.80) + + with ( + patch("idasen_ha.desk._MOVE_WRITE_INTERVAL", 0.01), + patch("idasen_ha.desk._MOVE_TIMEOUT", 0.1), + ): + await desk.move_to_target(target) + + assert not desk._moving + + +async def test_move_to_target_out_of_range(): + """Test that ValueError is raised for out-of-range targets.""" + desk, _ = _make_desk_with_client() + + with pytest.raises(ValueError, match="exceeds maximum"): + await desk.move_to_target(IdasenDesk.MAX_HEIGHT + 0.01) + + with pytest.raises(ValueError, match="exceeds minimum"): + await desk.move_to_target(IdasenDesk.MIN_HEIGHT - 0.01) + + +async def test_move_to_target_already_moving(): + """Test that a second move_to_target is rejected while already moving.""" + desk, client = _make_desk_with_client() + desk._moving = True + + await desk.move_to_target(1.00) + + client.read_gatt_char.assert_not_called() + + +async def test_move_to_target_cancelled_resets_moving_flag(): + """Test that _moving is reset when the move task is cancelled.""" + desk, client = _make_desk_with_client() + target = 1.00 + + client.read_gatt_char.return_value = _encode_height_speed(0.80, 0.0) + + with patch("idasen_ha.desk._MOVE_WRITE_INTERVAL", 0.5): + task = asyncio.create_task(desk.move_to_target(target)) + await asyncio.sleep(0.05) + assert desk._moving + + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert not desk._moving + + +async def test_move_to_target_within_tolerance(): + """Test that height within tolerance of target is accepted as arrived.""" + desk, client = _make_desk_with_client() + target = 1.00 + + client.read_gatt_char.return_value = _encode_height_speed(0.80, 0.0) + + async def simulate_near_arrival(): + await asyncio.sleep(0.05) + desk.update_height(target - _HEIGHT_TOLERANCE / 2) + + with patch("idasen_ha.desk._MOVE_WRITE_INTERVAL", 0.02): + task = asyncio.create_task(simulate_near_arrival()) + await desk.move_to_target(target) + await task + + assert not desk._moving + + +async def test_move_to_target_none_client(): + """Test that move_to_target does nothing when client is None.""" + desk = ManagedIdasenDesk("AA:BB:CC:DD:EE:FF") + + await desk.move_to_target(1.00) + + assert not desk._moving diff --git a/tests/test_desk_functions.py b/tests/test_desk_functions.py index be56766..1e90193 100644 --- a/tests/test_desk_functions.py +++ b/tests/test_desk_functions.py @@ -20,7 +20,7 @@ async def test_monitor_height(mock_idasen_desk: MagicMock): mock_idasen_desk.get_height.return_value = HEIGHT_MTS_1 await desk.connect(FAKE_BLE_DEVICE) - mock_idasen_desk._client.connect.assert_called() + mock_idasen_desk.connect.assert_called() mock_idasen_desk.pair.assert_called() mock_idasen_desk.get_height.assert_called() update_callback.assert_called_with(HEIGHT_PCT_1)