Skip to content

Commit

Permalink
Merge 29a5dd1 into e2416dd
Browse files Browse the repository at this point in the history
  • Loading branch information
sroebert committed Feb 24, 2024
2 parents e2416dd + 29a5dd1 commit 3a2e70b
Show file tree
Hide file tree
Showing 4 changed files with 140 additions and 25 deletions.
76 changes: 61 additions & 15 deletions idasen/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,18 +29,19 @@


# 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
assert (
raw_len == expected_len
), f"Expected raw value to be {expected_len} bytes long, got {raw_len} bytes"

int_raw, speed = struct.unpack("<Hh", raw)
int_raw, speed_raw = struct.unpack("<Hh", raw)
meters = float(int(int_raw) / 10000) + IdasenDesk.MIN_HEIGHT
speed = float(int(speed_raw) / 10000)

return meters, int(speed)
return meters, speed


def _meters_to_bytes(meters: float) -> bytearray:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -339,13 +359,13 @@ async def do_move() -> None:

data = _meters_to_bytes(target)

while True:
while self._moving:
await self._client.write_gatt_char(_UUID_REFERENCE_INPUT, data)
await asyncio.sleep(0.4)
await asyncio.sleep(0.2)

# 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

Expand Down Expand Up @@ -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)

Expand Down
16 changes: 13 additions & 3 deletions idasen/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.")
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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":
Expand Down
17 changes: 15 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
56 changes: 51 additions & 5 deletions tests/test_idasen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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):
Expand Down

0 comments on commit 3a2e70b

Please sign in to comment.