From 56fbe64bc700d17f91e1700170817dcf29e9c59d Mon Sep 17 00:00:00 2001 From: Steven Roebert Date: Tue, 5 Mar 2024 02:32:47 +0100 Subject: [PATCH] Made get_speed and get_height_and_speed public Added option to monitor for height and speed, next to just height --- idasen/__init__.py | 72 ++++++++++++++++++++++++++++++++++++-------- idasen/cli.py | 16 ++++++++-- tests/test_cli.py | 17 +++++++++-- tests/test_idasen.py | 56 +++++++++++++++++++++++++++++++--- 4 files changed, 138 insertions(+), 23 deletions(-) diff --git a/idasen/__init__.py b/idasen/__init__.py index 5dac5a8..06b6197 100644 --- a/idasen/__init__.py +++ b/idasen/__init__.py @@ -8,6 +8,7 @@ from typing import Optional from typing import Tuple from typing import Union +from inspect import signature import asyncio import logging import struct @@ -28,7 +29,7 @@ # height calculation offset in meters, assumed to be the same for all desks -def _bytes_to_meters_and_speed(raw: bytearray) -> Tuple[float, int]: +def _bytes_to_meters_and_speed(raw: bytearray) -> Tuple[float, float]: """Converts a value read from the desk in bytes to height in meters and speed.""" raw_len = len(raw) expected_len = 4 @@ -36,10 +37,11 @@ def _bytes_to_meters_and_speed(raw: bytearray) -> Tuple[float, int]: raw_len == expected_len ), f"Expected raw value to be {expected_len} bytes long, got {raw_len} bytes" - int_raw, speed = struct.unpack(" bytearray: @@ -183,21 +185,39 @@ async def disconnect(self): """ await self._client.disconnect() - async def monitor(self, callback: Callable[[float], Awaitable[None]]): + async def monitor(self, callback: Callable[..., Awaitable[None]]): output_service_uuid = "99fa0020-338a-1024-8a49-009c0215f78a" output_char_uuid = "99fa0021-338a-1024-8a49-009c0215f78a" + # Determine the amount of callback parameters + # 1st one is height, optional 2nd one is speed, more is not supported + callback_param_count = len(signature(callback).parameters) + if callback_param_count != 1 and callback_param_count != 2: + raise ValueError( + "Invalid callback provided, only 1 or 2 parameters are supported" + ) + + return_speed_value = callback_param_count == 2 previous_height = 0.0 + previous_speed = 0.0 async def output_listener(char: BleakGATTCharacteristic, data: bytearray): - height, _ = _bytes_to_meters_and_speed(data) - self._logger.debug(f"Got data: {height}m") + height, speed = _bytes_to_meters_and_speed(data) + self._logger.debug(f"Got data: {height}m {speed}m/s") nonlocal previous_height - if abs(height - previous_height) < 0.001: + nonlocal previous_speed + if abs(height - previous_height) < 0.001 and ( + not return_speed_value or abs(speed - previous_speed) < 0.001 + ): return previous_height = height - await callback(height) + previous_speed = speed + + if return_speed_value: + await callback(height, speed) + else: + await callback(height) for service in self._client.services: if service.uuid != output_service_uuid: @@ -345,7 +365,7 @@ async def do_move() -> None: # Stop as soon as the speed is 0, # which means the desk has reached the target position - speed = await self._get_speed() + speed = await self.get_speed() if speed == 0: break @@ -386,14 +406,40 @@ async def get_height(self) -> float: >>> asyncio.run(example()) 1.0 """ - height, _ = await self._get_height_and_speed() + height, _ = await self.get_height_and_speed() return height - async def _get_speed(self) -> int: - _, speed = await self._get_height_and_speed() + async def get_speed(self) -> float: + """ + Get the desk speed in meters per second. + + Returns: + Desk speed in meters per second. + + >>> async def example() -> float: + ... async with IdasenDesk(mac="AA:AA:AA:AA:AA:AA") as desk: + ... await desk.move_to_target(1.0) + ... return await desk.get_speed() + >>> asyncio.run(example()) + 0.0 + """ + _, speed = await self.get_height_and_speed() return speed - async def _get_height_and_speed(self) -> Tuple[float, int]: + async def get_height_and_speed(self) -> Tuple[float, float]: + """ + Get a tuple of the desk height in meters and speed in meters per second. + + Returns: + Tuple of desk height in meters and speed in meters per second. + + >>> async def example() -> [float, float]: + ... async with IdasenDesk(mac="AA:AA:AA:AA:AA:AA") as desk: + ... await desk.move_to_target(1.0) + ... return await desk.get_height_and_speed() + >>> asyncio.run(example()) + (1.0, 0.0) + """ raw = await self._client.read_gatt_char(_UUID_HEIGHT) return _bytes_to_meters_and_speed(raw) diff --git a/idasen/cli.py b/idasen/cli.py index 7355a7a..de3a612 100755 --- a/idasen/cli.py +++ b/idasen/cli.py @@ -38,7 +38,7 @@ extra=False, ) -RESERVED_NAMES = {"init", "pair", "monitor", "height", "save", "delete"} +RESERVED_NAMES = {"init", "pair", "monitor", "height", "speed", "save", "delete"} def save_config(config: dict, path: str = IDASEN_CONFIG_PATH): @@ -105,6 +105,7 @@ def get_parser(config: dict) -> argparse.ArgumentParser: sub = parser.add_subparsers(dest="sub", help="Subcommands", required=False) height_parser = sub.add_parser("height", help="Get the desk height.") + speed_parser = sub.add_parser("speed", help="Get the desk speed.") monitor_parser = sub.add_parser("monitor", help="Monitor the desk position.") init_parser = sub.add_parser("init", help="Initialize a new configuration file.") save_parser = sub.add_parser("save", help="Save current desk position.") @@ -128,6 +129,7 @@ def get_parser(config: dict) -> argparse.ArgumentParser: add_common_args(init_parser) add_common_args(pair_parser) add_common_args(height_parser) + add_common_args(speed_parser) add_common_args(monitor_parser) add_common_args(save_parser) add_common_args(delete_parser) @@ -181,8 +183,8 @@ async def monitor(args: argparse.Namespace) -> None: try: async with IdasenDesk(args.mac_address, exit_on_fail=True) as desk: - async def printer(height: float): - print(f"{height:.3f} meters", flush=True) + async def printer(height: float, speed: float): + print(f"{height:.3f} meters - {speed:.3f} meters/second", flush=True) await desk.monitor(printer) while True: @@ -197,6 +199,12 @@ async def height(args: argparse.Namespace): print(f"{height:.3f} meters") +async def speed(args: argparse.Namespace): + async with IdasenDesk(args.mac_address, exit_on_fail=True) as desk: + speed = await desk.get_speed() + print(f"{speed:.3f} meters/second") + + async def move_to(args: argparse.Namespace, position: float) -> None: async with IdasenDesk(args.mac_address, exit_on_fail=True) as desk: await desk.move_to_target(target=position) @@ -267,6 +275,8 @@ def subcommand_to_callable(sub: str, config: dict) -> Callable: return monitor elif sub == "height": return height + elif sub == "speed": + return speed elif sub == "save": return functools.partial(save, config=config) elif sub == "delete": diff --git a/tests/test_cli.py b/tests/test_cli.py index 6fdac6c..ceb8748 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -177,7 +177,8 @@ def test_count_to_level(count: int, level: int): @pytest.mark.parametrize( - "sub", ["init", "pair", "monitor", "sit", "height", "stand", "save", "delete"] + "sub", + ["init", "pair", "monitor", "sit", "height", "speed", "stand", "save", "delete"], ) def test_subcommand_to_callable(sub: str): global seen_it @@ -219,7 +220,19 @@ def test_main_internal_error(): @pytest.mark.parametrize( - "sub", ["init", "pair", "monitor", "sit", "height", "stand", "add", "delete", None] + "sub", + [ + "init", + "pair", + "monitor", + "sit", + "height", + "speed", + "stand", + "add", + "delete", + None, + ], ) def test_main_version(sub: Optional[str]): with mock.patch.object( diff --git a/tests/test_idasen.py b/tests/test_idasen.py index e7eaa60..c97fd49 100644 --- a/tests/test_idasen.py +++ b/tests/test_idasen.py @@ -52,7 +52,11 @@ async def disconnect(self): async def start_notify(self, uuid: str, callback: Callable): await callback(uuid, bytearray([0x00, 0x00, 0x00, 0x00])) - await callback(None, bytearray([0x10, 0x00, 0x00, 0x00])) + await callback(None, bytearray([0x20, 0x0A, 0x00, 0x00])) + await callback(None, bytearray([0x20, 0x0A, 0x00, 0x00])) + await callback(None, bytearray([0x20, 0x0A, 0x20, 0x00])) + await callback(None, bytearray([0x20, 0x0A, 0x20, 0x00])) + await callback(None, bytearray([0x20, 0x2A, 0x00, 0x00])) async def write_gatt_char(self, uuid: str, data: bytearray, response: bool = False): if uuid == idasen._UUID_COMMAND: @@ -129,10 +133,52 @@ async def test_get_height(desk: IdasenDesk): assert isinstance(height, float) -async def test_monitor(desk: IdasenDesk): - monitor_callback = mock.AsyncMock() +async def test_get_speed(desk: IdasenDesk): + speed = await desk.get_speed() + assert isinstance(speed, float) + + +async def test_get_height_and_speed(desk: IdasenDesk): + height, speed = await desk.get_height_and_speed() + assert isinstance(height, float) + assert isinstance(speed, float) + + +async def test_monitor_height(desk: IdasenDesk): + mock_callback = mock.Mock() + + async def monitor_callback(height: float): + mock_callback(height) + + await desk.monitor(monitor_callback) + mock_callback.assert_has_calls( + [mock.call(0.62), mock.call(0.8792), mock.call(1.6984)] + ) + + +async def test_monitor_speed_and_height(desk: IdasenDesk): + mock_callback = mock.Mock() + + async def monitor_callback(height: float, speed: float): + mock_callback(height, speed) + await desk.monitor(monitor_callback) - monitor_callback.assert_has_calls([mock.call(0.62), mock.call(0.6216)]) + mock_callback.assert_has_calls( + [ + mock.call(0.62, 0.0), + mock.call(0.8792, 0.0), + mock.call(0.8792, 0.0032), + mock.call(1.6984, 0.0), + ] + ) + + +async def test_monitoraises(desk: IdasenDesk): + async def monitor_callback(height: float, speed: float, third_argument: float): + pass + + with pytest.raises(ValueError): + await desk.monitor(monitor_callback) @pytest.mark.parametrize("target", [0.0, 2.0]) @@ -201,7 +247,7 @@ async def write_gatt_char_mock( (bytearray([0x00, 0x00, 0x00, 0x00]), IdasenDesk.MIN_HEIGHT, 0), (bytearray([0x51, 0x04, 0x00, 0x00]), 0.7305, 0), (bytearray([0x08, 0x08, 0x00, 0x00]), 0.8256, 0), - (bytearray([0x08, 0x08, 0x02, 0x01]), 0.8256, 258), + (bytearray([0x08, 0x08, 0x02, 0x01]), 0.8256, 0.0258), ], ) def test_bytes_to_meters_and_speed(raw: bytearray, height: float, speed: int):