From 9ede782360b39fde8a94533eb5925692871efdae Mon Sep 17 00:00:00 2001 From: Liron Date: Tue, 18 Feb 2025 11:41:21 +0200 Subject: [PATCH 1/9] simplify package structure and remove backends specific implementations --- Readme.md | 6 +- examples/{example_unix.py => basic.py} | 25 +++-- examples/example_win.py | 81 -------------- tapsdk/TapSDK.py | 49 --------- tapsdk/__init__.py | 17 +-- tapsdk/__version__.py | 2 +- tapsdk/backends/__init__.py | 0 tapsdk/backends/dotnet/TAPWin.dll | Bin 49664 -> 0 bytes tapsdk/backends/dotnet/TapSDK.py | 68 ------------ tapsdk/backends/dotnet/__init__.py | 0 tapsdk/backends/dotnet/inputmodes.py | 39 ------- tapsdk/backends/posix/__init__.py | 0 tapsdk/{models => }/enumerations.py | 0 tapsdk/{backends/posix => }/inputmodes.py | 2 +- tapsdk/models/__init__.py | 2 - tapsdk/models/uuids.py | 9 -- tapsdk/parsers.py | 111 ++++++++++---------- tapsdk/{backends/posix/TapSDK.py => tap.py} | 67 +++++++----- 18 files changed, 121 insertions(+), 357 deletions(-) rename examples/{example_unix.py => basic.py} (75%) delete mode 100644 examples/example_win.py delete mode 100644 tapsdk/TapSDK.py delete mode 100644 tapsdk/backends/__init__.py delete mode 100644 tapsdk/backends/dotnet/TAPWin.dll delete mode 100644 tapsdk/backends/dotnet/TapSDK.py delete mode 100644 tapsdk/backends/dotnet/__init__.py delete mode 100644 tapsdk/backends/dotnet/inputmodes.py delete mode 100644 tapsdk/backends/posix/__init__.py rename tapsdk/{models => }/enumerations.py (100%) rename tapsdk/{backends/posix => }/inputmodes.py (97%) delete mode 100644 tapsdk/models/__init__.py delete mode 100644 tapsdk/models/uuids.py rename tapsdk/{backends/posix/TapSDK.py => tap.py} (82%) diff --git a/Readme.md b/Readme.md index 9e494c0..8160d54 100644 --- a/Readme.md +++ b/Readme.md @@ -10,7 +10,7 @@ The library is developed with Python >= 3.7 and is **currently in beta**. This package supports the following platforms: * MacOS (tested on 10.15.2) - using Apple's CoreBluetooth library. The library depends on PyObjC which Apple includes with their Python version on OSX. Note that if you're using a different Python, be sure to install PyObjC for that version of Python. * Windows 10 - by wrapping the dynamic library (DLL) generated by [tap-standalonewin-sdk](https://github.com/TapWithUs/tap-standalonewin-sdk). -* Linux (testerd on Ubuntu 18.04) - need to install libbluetooth-dev and bluez-tools +* Linux (tested on Ubuntu 18.04) - need to install libbluetooth-dev and bluez-tools ``` sudo apt-get install bluez-tools libbluetooth-dev ``` @@ -166,7 +166,7 @@ Resgister callback to raw sensors data packet received event. 6. ```register_air_gesture_events(self, listener:Callable):``` Resgister callback to air gesture events. ```python - from tapsdk.models import AirGestures + from tapsdk import AirGestures def on_airgesture(identifier, gesture): print(identifier + " - gesture: " + str(AirGestures(gesture))) @@ -239,7 +239,7 @@ The dynamic range of the sensors is determined with the ```set_input_mode``` met ### Examples -You can find OS specific examples on the [examples folder](examples). +You can find some examples in the [examples folder](examples). ### Known Issues An up-to-date list of known issues is available [here](History.md). diff --git a/examples/example_unix.py b/examples/basic.py similarity index 75% rename from examples/example_unix.py rename to examples/basic.py index e2ac80f..9c6183d 100644 --- a/examples/example_unix.py +++ b/examples/basic.py @@ -1,8 +1,15 @@ import asyncio import time -from tapsdk import TapInputMode, TapSDK, InputType -from tapsdk.models import AirGestures +from tapsdk import TapInputMode, TapSDK, InputType, AirGestures + + +def OnDisconnection(identifier): + print("Disconnected. ", identifier) + + +def OnConnection(identifier): + print("Connected. ", identifier) def OnMouseModeChange(identifier, mouse_mode): @@ -28,15 +35,17 @@ def OnRawData(identifier, packets): async def run(loop): client = TapSDK(loop=loop) + + client.register_disconnection_events(OnDisconnection) + client.register_connection_events(OnConnection) + client.register_air_gesture_events(OnGesture) + client.register_tap_events(OnTapped) + client.register_raw_data_events(OnRawData) + client.register_mouse_events(OnMoused) + client.register_air_gesture_state_events(OnMouseModeChange) await client.run() print("Connected: {0}".format(client.client.is_connected)) - await client.register_air_gesture_events(OnGesture) - await client.register_tap_events(OnTapped) - await client.register_raw_data_events(OnRawData) - await client.register_mouse_events(OnMoused) - await client.register_air_gesture_state_events(OnMouseModeChange) - print("Set Controller Mode for 5 seconds") await client.set_input_mode(TapInputMode("controller")) await asyncio.sleep(5) diff --git a/examples/example_win.py b/examples/example_win.py deleted file mode 100644 index 8497f19..0000000 --- a/examples/example_win.py +++ /dev/null @@ -1,81 +0,0 @@ -from tapsdk import TapSDK, TapInputMode -from tapsdk.models import AirGestures - -tap_instance = [] -tap_identifiers = [] - - -def on_connect(identifier, name, fw): - print(identifier + " Tap: " + str(name), " FW Version: ", fw) - if identifier not in tap_identifiers: - tap_identifiers.append(identifier) - print("Connected taps:") - for identifier in tap_identifiers: - print(identifier) - - -def on_disconnect(identifier): - print("Tap has disconnected") - if identifier in tap_identifiers: - tap_identifiers.remove(identifier) - for identifier in tap_identifiers: - print(identifier) - - -def on_mouse_event(identifier, dx, dy, isMouse): - if isMouse: - print(str(dx), str(dy)) - else: - pass - # print("Air: ", str(dx), str(dy)) - - -def on_tap_event(identifier, tapcode): - print(identifier, str(tapcode)) - if int(tapcode) == 17: - sequence = [500, 200, 500, 500, 500, 200] - tap_instance.send_vibration_sequence(sequence, identifier) - - -def on_air_gesture_event(identifier, air_gesture): - print(" Air gesture: ", AirGestures(int(air_gesture)).name) - if air_gesture == AirGestures.UP_ONE_FINGER.value: - tap_instance.set_input_mode(TapInputMode("raw"), identifier) - if air_gesture == AirGestures.DOWN_ONE_FINGER.value: - tap_instance.set_input_mode(TapInputMode("text"), identifier) - if air_gesture == AirGestures.LEFT_ONE_FINGER.value: - tap_instance.set_input_mode(TapInputMode("controller"), identifier) - - -def on_air_gesture_state_event(identifier: str, air_gesture_state: bool): - if air_gesture_state: - print("Entered air mouse mode") - else: - print("Left air mouse mode") - - -def on_raw_sensor_data(identifier, raw_sensor_data): - # print(raw_sensor_data) - if raw_sensor_data.GetPoint(1).z > 2000 and raw_sensor_data.GetPoint(2).z > 2000 and raw_sensor_data.GetPoint(3).z > 2000 and raw_sensor_data.GetPoint(4).z > 2000: - tap_instance.set_input_mode(TapInputMode("controller"), identifier) - - -def main(): - global tap_instance - tap_instance = TapSDK() - tap_instance.run() - tap_instance.register_connection_events(on_connect) - tap_instance.register_disconnection_events(on_disconnect) - tap_instance.register_mouse_events(on_mouse_event) - tap_instance.register_tap_events(on_tap_event) - tap_instance.register_raw_data_events(on_raw_sensor_data) - tap_instance.register_air_gesture_events(on_air_gesture_event) - tap_instance.register_air_gesture_state_events(on_air_gesture_state_event) - tap_instance.set_input_mode(TapInputMode("controller")) - - while True: - pass - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/tapsdk/TapSDK.py b/tapsdk/TapSDK.py deleted file mode 100644 index f93aa62..0000000 --- a/tapsdk/TapSDK.py +++ /dev/null @@ -1,49 +0,0 @@ -import abc -from enum import Enum, IntEnum - - -class TapSDKBase(abc.ABC): - def __init__(self): - pass - - @abc.abstractmethod - def register_connection_events(self, listener): - raise NotImplementedError() - - @abc.abstractmethod - def register_disconnection_events(self, listener): - raise NotImplementedError() - - @abc.abstractmethod - def register_tap_events(self, listener): - raise NotImplementedError() - - @abc.abstractmethod - def register_mouse_events(self, listener): - raise NotImplementedError() - - @abc.abstractmethod - def register_raw_data_events(self, listener): - raise NotImplementedError() - - @abc.abstractmethod - def register_air_gesture_events(self, listener): - raise NotImplementedError - - @abc.abstractmethod - def register_air_gesture_state_events(self, listener): - raise NotImplementedError - - @abc.abstractmethod - def set_input_mode(self, mode, tap_identifier): - raise NotImplementedError() - - @abc.abstractmethod - def send_vibration_sequence(self, sequence, identifier): - raise NotImplementedError - - @abc.abstractmethod - def run(self): - raise NotImplementedError - - diff --git a/tapsdk/__init__.py b/tapsdk/__init__.py index 2665025..52d005d 100644 --- a/tapsdk/__init__.py +++ b/tapsdk/__init__.py @@ -1,14 +1,3 @@ -import platform -from .models.enumerations import InputType - -this_platform = platform.system() - -if this_platform == "Windows": - from tapsdk.backends.dotnet.TapSDK import TapWindowsSDK as TapSDK - from tapsdk.backends.dotnet.inputmodes import TapInputMode -elif this_platform in ["Darwin", "Linux"]: - from tapsdk.backends.posix.TapSDK import TapPosixSDK as TapSDK - from tapsdk.backends.posix.inputmodes import TapInputMode - -else: - raise ValueError("Value for platfrom is unknown: {}".format(this_platform)) +from tapsdk.enumerations import InputType, AirGestures # noqa: F401 +from tapsdk.inputmodes import TapInputMode # noqa: F401 +from tapsdk.tap import TapSDK # noqa: F401 diff --git a/tapsdk/__version__.py b/tapsdk/__version__.py index da74604..906d362 100644 --- a/tapsdk/__version__.py +++ b/tapsdk/__version__.py @@ -1 +1 @@ -__version__ = "0.6.0" \ No newline at end of file +__version__ = "0.6.0" diff --git a/tapsdk/backends/__init__.py b/tapsdk/backends/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tapsdk/backends/dotnet/TAPWin.dll b/tapsdk/backends/dotnet/TAPWin.dll deleted file mode 100644 index 32711e1b930007833b363ec8b35fcbced27f06ec..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 49664 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 diff --git a/tapsdk/backends/dotnet/TapSDK.py b/tapsdk/backends/dotnet/TapSDK.py deleted file mode 100644 index b218c19..0000000 --- a/tapsdk/backends/dotnet/TapSDK.py +++ /dev/null @@ -1,68 +0,0 @@ -import clr -from ...TapSDK import TapSDKBase -from .inputmodes import TapInputMode -import System - -clr.AddReference(r"tapsdk/backends/dotnet/TAPWin") -from TAPWin import TAPManager -from TAPWin import TAPManagerLog -from TAPWin import TAPInputMode -from TAPWin import RawSensorSensitivity -from TAPWin import TAPAirGesture -from TAPWin import RawSensorData - - -class TapWindowsSDK(TapSDKBase): - def __init__(self, *args): - super().__init__() - TAPManagerLog.Instance.OnLineLogged += print - - def register_tap_events(self, listener=None): - if listener is not None: - TAPManager.Instance.OnTapped += listener - - def register_mouse_events(self, listener=None): - if listener is not None: - TAPManager.Instance.OnMoused += listener - - def register_connection_events(self, listener=None): - if listener is not None: - TAPManager.Instance.OnTapConnected += listener - - def register_disconnection_events(self, listener=None): - if listener is not None: - TAPManager.Instance.OnTapDisconnected += listener - - def register_raw_data_events(self, listener=None): - if listener is not None: - TAPManager.Instance.OnRawSensorDataReceieved += listener - - def register_air_gesture_events(self, listener=None): - if listener is not None: - TAPManager.Instance.OnAirGestured += listener - - def register_air_gesture_state_events(self, listener=None): - if listener is not None: - TAPManager.Instance.OnChangedAirGestureState += listener - - def set_input_mode(self, mode:TapInputMode, tap_identifier=""): - print("input mode: " + mode.get_name()) - TAPManager.Instance.SetTapInputMode(mode.get_object(), tap_identifier) - - def set_default_input_mode(self, mode, identifier=""): - set_all = False - if identifier == "": - set_all = True - mode_obj = TapInputMode(mode).get_object() - TAPManager.Instance.SetDefaultInputMode(mode_obj, set_all) - - def send_vibration_sequence(self, sequence:list, identifier): - vibrations_array = System.Array[int](sequence) - TAPManager.Instance.Vibrate(vibrations_array, identifier) - - def run(self): - self.set_default_input_mode("controller") - TAPManager.Instance.Start() - - - diff --git a/tapsdk/backends/dotnet/__init__.py b/tapsdk/backends/dotnet/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tapsdk/backends/dotnet/inputmodes.py b/tapsdk/backends/dotnet/inputmodes.py deleted file mode 100644 index a7b449c..0000000 --- a/tapsdk/backends/dotnet/inputmodes.py +++ /dev/null @@ -1,39 +0,0 @@ -import logging -import clr -import System - -clr.AddReference(r"tapsdk/backends/dotnet/TAPWin") -from TAPWin import TAPInputMode -from TAPWin import RawSensorSensitivity - - -class TapInputMode: - def __init__(self, mode, sensitivity: list=[0, 0, 0]): - self._modes = { - "text" : {"name": "Text Mode", "code": TAPInputMode.Text()}, - "controller" : {"name": "Controller Mode", "code": TAPInputMode.Controller()}, - "controller_text" : {"name": "Controller and Text Mode", "code": TAPInputMode.ControllerWithMouseHID()}, - "raw" : {"name": "Raw sensors Mode", "code": TAPInputMode.RawSensor(RawSensorSensitivity(System.Byte(0), System.Byte(0), System.Byte(0)))} - } - self.sensitivity = sensitivity - if mode in self._modes.keys(): - self.mode = mode - if mode == "raw": - self._register_sensitivity(sensitivity) - else: - logging.warning("Invalid mode \"%s\". Set to \"text\"" % mode) - self.mode = "text" - - def _register_sensitivity(self, sensitivity): - if isinstance(sensitivity, list) and len(sensitivity) == 3: - sensitivity[0] = max(0, min(4,sensitivity[0])) # fingers accelerometers - sensitivity[1] = max(0, min(5,sensitivity[1])) # imu gyro - sensitivity[2] = max(0, min(4,sensitivity[2])) # imu accelerometer - self.sensitivity = sensitivity - self._modes["raw"]["code"] = TAPInputMode.RawSensor(RawSensorSensitivity(System.Byte(sensitivity[0]), System.Byte(sensitivity[1]), System.Byte(sensitivity[2]))) - - def get_object(self): - return self._modes[self.mode]["code"] - - def get_name(self): - return self._modes[self.mode]["name"] diff --git a/tapsdk/backends/posix/__init__.py b/tapsdk/backends/posix/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tapsdk/models/enumerations.py b/tapsdk/enumerations.py similarity index 100% rename from tapsdk/models/enumerations.py rename to tapsdk/enumerations.py diff --git a/tapsdk/backends/posix/inputmodes.py b/tapsdk/inputmodes.py similarity index 97% rename from tapsdk/backends/posix/inputmodes.py rename to tapsdk/inputmodes.py index f1d1dbb..889a1e8 100644 --- a/tapsdk/backends/posix/inputmodes.py +++ b/tapsdk/inputmodes.py @@ -1,5 +1,5 @@ import logging -from ...models.enumerations import InputType +from .enumerations import InputType class TapInputMode: diff --git a/tapsdk/models/__init__.py b/tapsdk/models/__init__.py deleted file mode 100644 index dfd29cc..0000000 --- a/tapsdk/models/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .uuids import TapUUID -from .enumerations import AirGestures, MouseModes \ No newline at end of file diff --git a/tapsdk/models/uuids.py b/tapsdk/models/uuids.py deleted file mode 100644 index 4538cea..0000000 --- a/tapsdk/models/uuids.py +++ /dev/null @@ -1,9 +0,0 @@ -class TapUUID(): - tap_service = 'c3ff0001-1d8b-40fd-a56f-c7bd5d0f3370' - nus_service = '6e400001-b5a3-f393-e0a9-e50e24dcca9e' - tap_data_characteristic = 'c3ff0005-1d8b-40fd-a56f-c7bd5d0f3370' - mouse_data_characteristic = 'c3ff0006-1d8b-40fd-a56f-c7bd5d0f3370' - ui_cmd_characteristic = 'c3ff0009-1d8b-40fd-a56f-c7bd5d0f3370' - air_gesture_data_characteristic = 'c3ff000a-1d8b-40fd-a56f-c7bd5d0f3370' - tap_mode_characteristic = '6e400002-b5a3-f393-e0a9-e50e24dcca9e' # nus rx - raw_sensors_characteristic = '6e400003-b5a3-f393-e0a9-e50e24dcca9e' # nus tx diff --git a/tapsdk/parsers.py b/tapsdk/parsers.py index 164a1fb..fb83829 100644 --- a/tapsdk/parsers.py +++ b/tapsdk/parsers.py @@ -1,61 +1,66 @@ -def tapcode_to_fingers(tapcode:int): - return '{0:05b}'.format(1)[::-1] +def tapcode_to_fingers(tapcode: int): + return '{0:05b}'.format(1)[::-1] + def mouse_data_msg(data: bytearray): - vx = int.from_bytes(data[1:3],"little", signed=True) - vy = int.from_bytes(data[3:5],"little", signed=True) - prox = data[9] == 1 - return vx, vy, prox + vx = int.from_bytes(data[1:3], "little", signed=True) + vy = int.from_bytes(data[3:5], "little", signed=True) + prox = data[9] == 1 + return vx, vy, prox + def air_gesture_data_msg(data: bytearray): - return [data[0]] - + return [data[0]] + + def tap_data_msg(data: bytearray): - return [data[0]] + return [data[0]] + def raw_data_msg(data: bytearray): - ''' - raw data is packed into messages with the following structure: - [msg_type (1 bit)][timestamp (31 bit)][payload (12 - 30 bytes)] - * msg type - '0' for imu message - - '1' for accelerometers message - * timestamp - unsigned int, given in milliseconds - * payload - for imu message is 12 bytes - composed by a series of 6 uint16 numbers - representing [g_x, g_y, g_z, xl_x, xl_y, xl_z] - - for accelerometers message is 30 bytes - composed by a series of 15 uint16 numbers - representing [xl_x_thumb , xl_y_thumb, xl_z_thumb, - xl_x_finger, xl_y_finger, xl_z_finger, - ...] - ''' - - L = len(data) - ptr = 0 - messages = [] - while ptr <= L: - # decode timestamp and message type - ts = int.from_bytes(data[ptr:ptr+4],"little", signed=False) - if ts == 0: - break - ptr += 4 - - # resolve message type - if ts > raw_data_msg.msg_type_value: - msg = "accl" - ts -= raw_data_msg.msg_type_value - num_of_samples = 15 - else: - msg = "imu" - num_of_samples = 6 - - # parse payload - payload = [] - for i in range(num_of_samples): - payload.append(int.from_bytes(data[ptr:ptr+2],"little", signed=True)) - ptr += 2 - - messages.append({"type":msg, "ts":ts, "payload":payload}) - - return messages + ''' + raw data is packed into messages with the following structure: + [msg_type (1 bit)][timestamp (31 bit)][payload (12 - 30 bytes)] + * msg type - '0' for imu message + - '1' for accelerometers message + * timestamp - unsigned int, given in milliseconds + * payload - for imu message is 12 bytes + composed by a series of 6 uint16 numbers + representing [g_x, g_y, g_z, xl_x, xl_y, xl_z] + - for accelerometers message is 30 bytes + composed by a series of 15 uint16 numbers + representing [xl_x_thumb , xl_y_thumb, xl_z_thumb, + xl_x_finger, xl_y_finger, xl_z_finger, + ...] + + ''' + L = len(data) + ptr = 0 + messages = [] + while ptr <= L: + # decode timestamp and message type + ts = int.from_bytes(data[ptr:ptr+4], "little", signed=False) + if ts == 0: + break + ptr += 4 + + # resolve message type + if ts > raw_data_msg.msg_type_value: + msg = "accl" + ts -= raw_data_msg.msg_type_value + num_of_samples = 15 + else: + msg = "imu" + num_of_samples = 6 + + # parse payload + payload = [] + for i in range(num_of_samples): + payload.append(int.from_bytes(data[ptr:ptr+2], "little", signed=True)) + ptr += 2 + + messages.append({"type": msg, "ts": ts, "payload": payload}) + return messages + + raw_data_msg.msg_type_value = 2**31 diff --git a/tapsdk/backends/posix/TapSDK.py b/tapsdk/tap.py similarity index 82% rename from tapsdk/backends/posix/TapSDK.py rename to tapsdk/tap.py index 101f254..e6a2fca 100644 --- a/tapsdk/backends/posix/TapSDK.py +++ b/tapsdk/tap.py @@ -7,12 +7,21 @@ from bleak import _logger as logger from bleak import discover -from ... import parsers -from ...models import TapUUID -from ...models.enumerations import InputType, MouseModes -from ...TapSDK import TapSDKBase +from . import parsers +from .enumerations import InputType, MouseModes from .inputmodes import TapInputMode, input_type_command + +tap_service = 'c3ff0001-1d8b-40fd-a56f-c7bd5d0f3370' +nus_service = '6e400001-b5a3-f393-e0a9-e50e24dcca9e' +tap_data_characteristic = 'c3ff0005-1d8b-40fd-a56f-c7bd5d0f3370' +mouse_data_characteristic = 'c3ff0006-1d8b-40fd-a56f-c7bd5d0f3370' +ui_cmd_characteristic = 'c3ff0009-1d8b-40fd-a56f-c7bd5d0f3370' +air_gesture_data_characteristic = 'c3ff000a-1d8b-40fd-a56f-c7bd5d0f3370' +tap_mode_characteristic = '6e400002-b5a3-f393-e0a9-e50e24dcca9e' # nus rx +raw_sensors_characteristic = '6e400003-b5a3-f393-e0a9-e50e24dcca9e' # nus tx + + if platform.system() == "Darwin": from bleak.backends.corebluetooth.CentralManagerDelegate import ( CBUUID, CentralManagerDelegate) @@ -39,7 +48,7 @@ async def connect_retrieved(self, **kwargs) -> bool: def get_paired_taps(self): paired_taps = self._central_manager_delegate.central_manager.retrieveConnectedPeripheralsWithServices_( - [string2uuid(TapUUID.tap_service)]) + [string2uuid(tap_service)]) logger.debug("Found connected Taps @ {}".format(paired_taps)) return paired_taps @@ -108,9 +117,8 @@ def get_mac_addr() -> str: raise e -class TapPosixSDK(TapSDKBase): +class TapSDK(): def __init__(self, loop: AbstractEventLoop = None, **kwargs): - super(TapPosixSDK, self).__init__() self.client = TapClient(loop=loop, address=kwargs.get("address")) self.loop = loop self.mouse_event_cb = None @@ -118,47 +126,37 @@ def __init__(self, loop: AbstractEventLoop = None, **kwargs): self.air_gesture_event_cb = None self.raw_data_event_cb = None self.air_gesture_state_event_cb = None + self.connection_cb = None self.input_mode_refresh = InputModeAutoRefresh(self._refresh_input_mode, timeout=10) self.mouse_mode = MouseModes.STDBY self.input_mode = TapInputMode("text") self.input_type = InputType.AUTO - async def register_tap_events(self, cb: Callable): + def register_tap_events(self, cb: Callable): if cb: - await self.client.start_notify(TapUUID.tap_data_characteristic, self.on_tapped) self.tap_event_cb = cb - async def register_mouse_events(self, cb: Callable): + def register_mouse_events(self, cb: Callable): if cb: - await self.client.start_notify(TapUUID.mouse_data_characteristic, self.on_moused) self.mouse_event_cb = cb - async def register_air_gesture_events(self, cb: Callable): + def register_air_gesture_events(self, cb: Callable): if cb: - try: - await self.client.start_notify(TapUUID.air_gesture_data_characteristic, self.on_air_gesture) - except Exception as e: - logger.warning("Failed to start notify for air gesture state: " + str(e)) self.air_gesture_event_cb = cb - async def register_air_gesture_state_events(self, cb: Callable): + def register_air_gesture_state_events(self, cb: Callable): if cb: - try: - await self.client.start_notify(TapUUID.air_gesture_data_characteristic, self.on_air_gesture) - except Exception as e: - logger.warning("Failed to start notify for air gesture state: " + str(e)) self.air_gesture_state_event_cb = cb - async def register_raw_data_events(self, cb: Callable): + def register_raw_data_events(self, cb: Callable): if cb: - await self.client.start_notify(TapUUID.raw_sensors_characteristic, self.on_raw_data) self.raw_data_event_cb = cb - async def register_connection_events(self, cb: Callable): - pass + def register_connection_events(self, cb: Callable): + self.connection_cb = cb - async def register_disconnection_events(self, cb: Callable): - pass + def register_disconnection_events(self, cb: Callable): + self.client.set_disconnected_callback(cb) def on_moused(self, identifier, data): if self.mouse_event_cb: @@ -195,7 +193,7 @@ async def send_vibration_sequence(self, sequence, identifier=None): sequence[i] = max(0, min(255, d // 10)) write_value = bytearray([0x0, 0x2] + sequence) - await self.client.write_gatt_char(TapUUID.ui_cmd_characteristic, write_value) + await self.client.write_gatt_char(ui_cmd_characteristic, write_value) async def set_input_mode(self, input_mode: TapInputMode, identifier=None): if (input_mode.mode == "raw" and self.input_mode.mode == "raw" and @@ -228,7 +226,7 @@ async def _refresh_input_mode(self): logger.debug(f"Input Type Refreshed: {self.input_type}") async def _write_input_mode(self, value): - await self.client.write_gatt_char(TapUUID.tap_mode_characteristic, value) + await self.client.write_gatt_char(tap_mode_characteristic, value) async def list_connected_taps(self): devices = await discover(loop=self.loop) @@ -236,6 +234,17 @@ async def list_connected_taps(self): async def run(self): await self.client.connect_retrieved() + if self.client.is_connected(): + for ch, cb in [(tap_data_characteristic, self.on_tapped), + (mouse_data_characteristic, self.on_moused), + (air_gesture_data_characteristic, self.on_air_gesture), + (raw_sensors_characteristic, self.on_raw_data)]: + try: + await self.client.start_notify(ch, cb) + except Exception as e: + logger.warning("Failed to start notify for air gesture state: " + str(e)) + if self.connection_cb: + self.connection_cb(self) class InputModeAutoRefresh: From c25f53851e968d3b4e95b9fbff500a688ba94277 Mon Sep 17 00:00:00 2001 From: Liron Date: Tue, 18 Feb 2025 14:25:39 +0200 Subject: [PATCH 2/9] scan for taps of didn't found any paired --- History.md | 1 - tapsdk/tap.py | 56 ++++++++++++++++++++++++++++++++++----------------- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/History.md b/History.md index a781216..9383c50 100644 --- a/History.md +++ b/History.md @@ -13,7 +13,6 @@ ______________________ * Spatial features are still not available for Windows backend. * MacOS & Linux backends - * Doesn't support multiple Tap strap connections. - * OnConnect and OnDisconnect events are not implemented * Raw sensor data is given unscaled (i.e. unitless), thereforein order to scale to physical units need to multiply by the relevant scale factor ## 0.5.1 (2024-01-01) diff --git a/tapsdk/tap.py b/tapsdk/tap.py index e6a2fca..0c2297a 100644 --- a/tapsdk/tap.py +++ b/tapsdk/tap.py @@ -3,9 +3,8 @@ from asyncio.events import AbstractEventLoop from typing import Callable -from bleak import BleakClient +from bleak import BleakClient, BleakScanner from bleak import _logger as logger -from bleak import discover from . import parsers from .enumerations import InputType, MouseModes @@ -37,6 +36,8 @@ def __init__(self, address="", loop=None, **kwargs): async def connect_retrieved(self, **kwargs) -> bool: self._central_manager_delegate = CentralManagerDelegate.alloc().init() paired_taps = self.get_paired_taps() + if len(paired_taps) == 0: + return False self._peripheral = paired_taps[0] logger.debug("Connecting to Tap device @ {}".format(self._peripheral)) await self.connect() @@ -52,6 +53,14 @@ def get_paired_taps(self): logger.debug("Found connected Taps @ {}".format(paired_taps)) return paired_taps +elif platform.system() == "Windows": + class TapClient(BleakClient): + def __init__(self, address="", loop=None, **kwargs): + super().__init__(address, loop=loop, **kwargs) + + async def connect_retrieved(self, **kwargs) -> bool: + return False + elif platform.system() == "Linux": class TapClient(BleakClient): def __init__(self, address=None, loop=None, **kwargs): @@ -60,7 +69,7 @@ def __init__(self, address=None, loop=None, **kwargs): async def connect_retrieved(self, **kwargs) -> bool: await self.connect() - connected = await self.is_connected() + connected = self.is_connected() if connected: logger.info("Connected to {0}".format(self.address)) await self.__debug() @@ -133,24 +142,19 @@ def __init__(self, loop: AbstractEventLoop = None, **kwargs): self.input_type = InputType.AUTO def register_tap_events(self, cb: Callable): - if cb: - self.tap_event_cb = cb + self.tap_event_cb = cb def register_mouse_events(self, cb: Callable): - if cb: - self.mouse_event_cb = cb + self.mouse_event_cb = cb def register_air_gesture_events(self, cb: Callable): - if cb: - self.air_gesture_event_cb = cb + self.air_gesture_event_cb = cb def register_air_gesture_state_events(self, cb: Callable): - if cb: - self.air_gesture_state_event_cb = cb + self.air_gesture_state_event_cb = cb def register_raw_data_events(self, cb: Callable): - if cb: - self.raw_data_event_cb = cb + self.raw_data_event_cb = cb def register_connection_events(self, cb: Callable): self.connection_cb = cb @@ -228,13 +232,29 @@ async def _refresh_input_mode(self): async def _write_input_mode(self, value): await self.client.write_gatt_char(tap_mode_characteristic, value) - async def list_connected_taps(self): - devices = await discover(loop=self.loop) - return devices async def run(self): - await self.client.connect_retrieved() - if self.client.is_connected(): + stop_event = asyncio.Event() + devices = [] + + async def detection_cb(device, adv_data): + print("detected ", device, adv_data) + if tap_service.lower() in adv_data.service_uuids: + if device.address not in [d.address for d in devices]: + devices.append(device) + print("detected ", device, adv_data) + stop_event.set() + + connected = await self.client.connect_retrieved() + if not connected: + print("Couldn't find connected Tap device. Scanning for Tap devices...") + async with BleakScanner(detection_callback=detection_cb) as _: + await stop_event.wait() + + self.client = TapClient(devices[0]) + await self.client.connect() + await self.client.pair() + if self.client.is_connected: for ch, cb in [(tap_data_characteristic, self.on_tapped), (mouse_data_characteristic, self.on_moused), (air_gesture_data_characteristic, self.on_air_gesture), From 04a4893c1fd26ffced29110825b46fdc55b54313 Mon Sep 17 00:00:00 2001 From: Liron Ilouz <43831550+ilouzl@users.noreply.github.com> Date: Tue, 20 May 2025 10:57:40 +0300 Subject: [PATCH 3/9] Add cross-platform testing suite --- .github/workflows/test.yml | 28 +++++++++++++++++++ Readme.md | 4 +++ TESTING.md | 15 ++++++++++ requirements-dev.txt | 1 + requirements.txt | 2 +- tests/__init__.py | 0 tests/conftest.py | 28 +++++++++++++++++++ tests/test_cross_platform.py | 53 ++++++++++++++++++++++++++++++++++++ tests/test_inputmodes.py | 18 ++++++++++++ tests/test_parsers.py | 22 +++++++++++++++ 10 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test.yml create mode 100644 TESTING.md create mode 100644 requirements-dev.txt create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_cross_platform.py create mode 100644 tests/test_inputmodes.py create mode 100644 tests/test_parsers.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..8b9e79d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,28 @@ +name: CI + +on: + pull_request: + push: + branches: [ main, master ] + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.8", "3.9", "3.10", "3.11"] + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt + pip install -e . + - name: Run tests + run: pytest -v diff --git a/Readme.md b/Readme.md index 8160d54..6bb1b82 100644 --- a/Readme.md +++ b/Readme.md @@ -240,6 +240,10 @@ The dynamic range of the sensors is determined with the ```set_input_mode``` met ### Examples You can find some examples in the [examples folder](examples). +### Testing + +Install test requirements and run `pytest`. + ### Known Issues An up-to-date list of known issues is available [here](History.md). diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..6231781 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,15 @@ +# Testing Suite + +This SDK interfaces with Bluetooth hardware, so running tests on machines without the accessory requires mocking the BLE backend. The tests in `tests/` rely on small stubs that replace the `bleak` library and patch the detected platform. This allows the package to be imported and exercised without real hardware. + +## Running Tests Locally + +```bash +pip install -r requirements.txt +pip install -r requirements-dev.txt +pytest -v +``` + +## Continuous Integration + +A GitHub Actions workflow (`.github/workflows/test.yml`) is provided. It runs the test suite on Windows, macOS and Linux against multiple Python versions. The workflow installs the package in editable mode and executes `pytest`. diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..e079f8a --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1 @@ +pytest diff --git a/requirements.txt b/requirements.txt index 40d8264..845a550 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ bleak -setuptools \ No newline at end of file +setuptools diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ba16bdc --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,28 @@ +import sys +import types + + +def pytest_configure(config): + bleak_stub = types.ModuleType("bleak") + + class Dummy: + def __init__(self, *args, **kwargs): + pass + + bleak_stub.BleakClient = Dummy + bleak_stub.BleakScanner = Dummy + bleak_stub._logger = types.SimpleNamespace( + debug=lambda *a, **k: None, + info=lambda *a, **k: None, + error=lambda *a, **k: None, + ) + sys.modules.setdefault("bleak", bleak_stub) + + core_mod = types.ModuleType("bleak.backends.corebluetooth.CentralManagerDelegate") + core_mod.CBUUID = type("CBUUID", (), {"UUIDWithString_": staticmethod(lambda x: x)}) + core_mod.CentralManagerDelegate = type( + "CentralManagerDelegate", + (), + {"alloc": classmethod(lambda cls: type("Obj", (), {"init": lambda self: None})())}, + ) + sys.modules.setdefault("bleak.backends.corebluetooth.CentralManagerDelegate", core_mod) diff --git a/tests/test_cross_platform.py b/tests/test_cross_platform.py new file mode 100644 index 0000000..b91ed7b --- /dev/null +++ b/tests/test_cross_platform.py @@ -0,0 +1,53 @@ +import importlib +import sys +import types +from unittest.mock import patch + + +def _make_bleak_stub(): + bleak_stub = types.ModuleType("bleak") + + class Dummy: + def __init__(self, *args, **kwargs): + pass + + bleak_stub.BleakClient = Dummy + bleak_stub.BleakScanner = Dummy + bleak_stub._logger = types.SimpleNamespace( + debug=lambda *a, **k: None, + info=lambda *a, **k: None, + error=lambda *a, **k: None, + ) + core_mod = types.ModuleType( + "bleak.backends.corebluetooth.CentralManagerDelegate" + ) + core_mod.CBUUID = type("CBUUID", (), {"UUIDWithString_": staticmethod(lambda x: x)}) + core_mod.CentralManagerDelegate = type( + "CentralManagerDelegate", + (), + {"alloc": classmethod(lambda cls: type("Obj", (), {"init": lambda self: None})())}, + ) + return bleak_stub, core_mod + + +def _load_tap(platform_name: str): + bleak_stub, core_stub = _make_bleak_stub() + with patch.dict( + sys.modules, + { + "bleak": bleak_stub, + "bleak.backends.corebluetooth.CentralManagerDelegate": core_stub, + }, + ): + with patch("platform.system", return_value=platform_name): + if "tapsdk.tap" in sys.modules: + module = importlib.reload(sys.modules["tapsdk.tap"]) + else: + module = importlib.import_module("tapsdk.tap") + return module + + +def test_tapclient_defined_for_all_platforms(): + for name in ["Linux", "Windows", "Darwin"]: + module = _load_tap(name) + assert hasattr(module, "TapClient") diff --git a/tests/test_inputmodes.py b/tests/test_inputmodes.py new file mode 100644 index 0000000..18c2ce1 --- /dev/null +++ b/tests/test_inputmodes.py @@ -0,0 +1,18 @@ +import pytest +from tapsdk.inputmodes import TapInputMode, input_type_command +from tapsdk.enumerations import InputType + + +def test_input_mode_basic(): + assert TapInputMode("text").get_command() == bytearray([0x3, 0xc, 0x0, 0x0]) + assert TapInputMode("controller").get_command() == bytearray([0x3, 0xc, 0x0, 0x1]) + assert TapInputMode("controller_text").get_command() == bytearray([0x3, 0xc, 0x0, 0x3]) + + +def test_input_mode_raw_with_sensitivity(): + mode = TapInputMode("raw", sensitivity=[1, 2, 3]) + assert mode.get_command() == bytearray([0x3, 0xc, 0x0, 0xa, 1, 2, 3]) + + +def test_input_type_command(): + assert input_type_command(InputType.MOUSE) == bytearray([0x3, 0xd, 0x0, InputType.MOUSE.value]) diff --git a/tests/test_parsers.py b/tests/test_parsers.py new file mode 100644 index 0000000..9be0999 --- /dev/null +++ b/tests/test_parsers.py @@ -0,0 +1,22 @@ +import tapsdk.parsers as parsers + + +def test_mouse_data_msg(): + data = bytearray([0, 1, 0, 2, 0, 0, 0, 0, 0, 1]) + assert parsers.mouse_data_msg(data) == (1, 2, True) + + +def test_tap_data_msg(): + data = bytearray([5]) + assert parsers.tap_data_msg(data) == [5] + + +def test_raw_data_msg(): + ts = 42 + payload = [1, 2, 3, 4, 5, 6] + msg = bytearray() + msg += ts.to_bytes(4, "little") + for v in payload: + msg += v.to_bytes(2, "little", signed=True) + msg += bytearray([0, 0, 0, 0]) + assert parsers.raw_data_msg(msg) == [{"type": "imu", "ts": ts, "payload": payload}] From fad6c90affcf96d30653b5f66bd68481091bb33d Mon Sep 17 00:00:00 2001 From: Liron Date: Wed, 21 May 2025 13:56:41 +0300 Subject: [PATCH 4/9] some CI workflow refactoring --- .github/workflows/test.yml | 8 +++---- Readme.md | 13 +++++++++- TESTING.md | 15 ------------ requirements-dev.txt | 1 - setup.py | 10 ++++---- tests/test_parsers.py | 49 +++++++++++++++++++++++++++++++------- 6 files changed, 61 insertions(+), 35 deletions(-) delete mode 100644 TESTING.md delete mode 100644 requirements-dev.txt diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8b9e79d..08f7f76 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,7 +3,7 @@ name: CI on: pull_request: push: - branches: [ main, master ] + branches: [master] jobs: build: @@ -11,7 +11,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v3 - name: Set up Python @@ -21,8 +21,6 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt - pip install -r requirements-dev.txt - pip install -e . + pip install -e .[dev] - name: Run tests run: pytest -v diff --git a/Readme.md b/Readme.md index 6bb1b82..d36fc30 100644 --- a/Readme.md +++ b/Readme.md @@ -240,9 +240,20 @@ The dynamic range of the sensors is determined with the ```set_input_mode``` met ### Examples You can find some examples in the [examples folder](examples). + ### Testing -Install test requirements and run `pytest`. +To run the tests, first install the development dependencies: + +```bash +pip install .[dev] +``` + +Then run the tests using pytest: + +```bash +pytest +``` ### Known Issues diff --git a/TESTING.md b/TESTING.md deleted file mode 100644 index 6231781..0000000 --- a/TESTING.md +++ /dev/null @@ -1,15 +0,0 @@ -# Testing Suite - -This SDK interfaces with Bluetooth hardware, so running tests on machines without the accessory requires mocking the BLE backend. The tests in `tests/` rely on small stubs that replace the `bleak` library and patch the detected platform. This allows the package to be imported and exercised without real hardware. - -## Running Tests Locally - -```bash -pip install -r requirements.txt -pip install -r requirements-dev.txt -pytest -v -``` - -## Continuous Integration - -A GitHub Actions workflow (`.github/workflows/test.yml`) is provided. It runs the test suite on Windows, macOS and Linux against multiple Python versions. The workflow installs the package in editable mode and executes `pytest`. diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index e079f8a..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1 +0,0 @@ -pytest diff --git a/setup.py b/setup.py index 98d9dd0..e28933e 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ REQUIRED = [ # linux reqs 'bleak==0.6.4;platform_system=="Linux"', - # macOS reqs + # macOS reqs 'bleak==0.12.1;platform_system=="Darwin"', # Windows reqs 'pythonnet;platform_system=="Windows"' @@ -82,13 +82,13 @@ def run(self): author_email=EMAIL, url=URL, packages=find_packages(exclude=("tests", "examples", "docs")), - # package_data={"tapsdk.backends.dotnet": ["*.dll"]}, install_requires=REQUIRED, - # test_suite="tests", - # tests_require=TEST_REQUIRED, include_package_data=True, license="MIT", - python_requires='>=3.7' + python_requires='>=3.9', + extras_require={ + "dev": ["pytest"] + }, # classifiers=[ # # Trove classifiers # # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers diff --git a/tests/test_parsers.py b/tests/test_parsers.py index 9be0999..4a189c6 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -12,11 +12,44 @@ def test_tap_data_msg(): def test_raw_data_msg(): - ts = 42 - payload = [1, 2, 3, 4, 5, 6] - msg = bytearray() - msg += ts.to_bytes(4, "little") - for v in payload: - msg += v.to_bytes(2, "little", signed=True) - msg += bytearray([0, 0, 0, 0]) - assert parsers.raw_data_msg(msg) == [{"type": "imu", "ts": ts, "payload": payload}] + # 1. packet with one imu message + # IMU message: type=0, timestamp=123, 6 samples (12 bytes) + ts = 123 + imu_ts = ts # type bit is 0, so ts stays 123 + imu_bytes = imu_ts.to_bytes(4, 'little', signed=False) + imu_payload = b'' + imu_samples = [100, -100, 200, -200, 300, -300] + for v in imu_samples: + imu_payload += v.to_bytes(2, 'little', signed=True) + imu_packet = bytearray(imu_bytes + imu_payload) + result = parsers.raw_data_msg(imu_packet) + assert result == [{ + 'type': 'imu', + 'ts': 123, + 'payload': imu_samples + }] + + # 2. packet with one accl message + # Accl message: type=1, timestamp=456, 15 samples (30 bytes) + accl_ts = (1 << 31) + 456 # set MSB for accl + accl_bytes = accl_ts.to_bytes(4, 'little', signed=False) + accl_samples = list(range(1, 16)) + accl_payload = b'' + for v in accl_samples: + accl_payload += v.to_bytes(2, 'little', signed=True) + accl_packet = bytearray(accl_bytes + accl_payload) + result = parsers.raw_data_msg(accl_packet) + assert result == [{ + 'type': 'accl', + 'ts': 456, + 'payload': accl_samples + }] + + # 3. packet with imu message and accl message + # imu first, then accl + combo_packet = bytearray(imu_bytes + imu_payload + accl_bytes + accl_payload) + result = parsers.raw_data_msg(combo_packet) + assert result == [ + {'type': 'imu', 'ts': 123, 'payload': imu_samples}, + {'type': 'accl', 'ts': 456, 'payload': accl_samples} + ] From 0048e2bd303d8dabdbc425fca9764b24f31145f9 Mon Sep 17 00:00:00 2001 From: Liron Date: Wed, 21 May 2025 14:02:24 +0300 Subject: [PATCH 5/9] add lint to CI workflow --- .flake8 | 2 ++ .github/workflows/test.yml | 3 +++ 2 files changed, 5 insertions(+) create mode 100644 .flake8 diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..6deafc2 --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 120 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 08f7f76..562e48d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,5 +22,8 @@ jobs: run: | python -m pip install --upgrade pip pip install -e .[dev] + - name: Lint with flake8 + run: | + flake8 *.py - name: Run tests run: pytest -v From 8d2acd6c9598232a75f14e32d5b9f3daf2283ae9 Mon Sep 17 00:00:00 2001 From: Liron Date: Wed, 21 May 2025 14:04:02 +0300 Subject: [PATCH 6/9] leftover --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e28933e..3ed784b 100644 --- a/setup.py +++ b/setup.py @@ -87,7 +87,7 @@ def run(self): license="MIT", python_requires='>=3.9', extras_require={ - "dev": ["pytest"] + "dev": ["pytest", "flake8"] }, # classifiers=[ # # Trove classifiers From 5a1353801ad59fcdd7013632716fa0f5cdcb7002 Mon Sep 17 00:00:00 2001 From: Liron Date: Wed, 21 May 2025 14:10:28 +0300 Subject: [PATCH 7/9] fix lint errors --- .github/workflows/test.yml | 3 ++- examples/basic.py | 2 +- tapsdk/tap.py | 1 - tests/test_inputmodes.py | 1 - 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 562e48d..5e54ff5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,6 +24,7 @@ jobs: pip install -e .[dev] - name: Lint with flake8 run: | - flake8 *.py + pip install flake8 + flake8 examples tapsdk tests - name: Run tests run: pytest -v diff --git a/examples/basic.py b/examples/basic.py index 9c6183d..4301472 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -53,7 +53,7 @@ async def run(loop): print("Force Mouse Mode for 5 seconds") await client.set_input_type(InputType.MOUSE) await asyncio.sleep(5) - + print("Force keyboard Mode for 5 seconds") await client.set_input_type(InputType.KEYBOARD) await asyncio.sleep(5) diff --git a/tapsdk/tap.py b/tapsdk/tap.py index 0c2297a..c1d681c 100644 --- a/tapsdk/tap.py +++ b/tapsdk/tap.py @@ -232,7 +232,6 @@ async def _refresh_input_mode(self): async def _write_input_mode(self, value): await self.client.write_gatt_char(tap_mode_characteristic, value) - async def run(self): stop_event = asyncio.Event() devices = [] diff --git a/tests/test_inputmodes.py b/tests/test_inputmodes.py index 18c2ce1..1bedc78 100644 --- a/tests/test_inputmodes.py +++ b/tests/test_inputmodes.py @@ -1,4 +1,3 @@ -import pytest from tapsdk.inputmodes import TapInputMode, input_type_command from tapsdk.enumerations import InputType From 4c90d2abe1102210923483c465a17ed1d5377587 Mon Sep 17 00:00:00 2001 From: Liron Ilouz <43831550+ilouzl@users.noreply.github.com> Date: Wed, 21 May 2025 23:15:57 +0300 Subject: [PATCH 8/9] adding missing event loop argument Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tapsdk/tap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tapsdk/tap.py b/tapsdk/tap.py index c1d681c..f04f35b 100644 --- a/tapsdk/tap.py +++ b/tapsdk/tap.py @@ -250,7 +250,7 @@ async def detection_cb(device, adv_data): async with BleakScanner(detection_callback=detection_cb) as _: await stop_event.wait() - self.client = TapClient(devices[0]) + self.client = TapClient(devices[0], loop=self.loop) await self.client.connect() await self.client.pair() if self.client.is_connected: From e33be6db83f767ed6b5154b5ec4a09fba58ecf46 Mon Sep 17 00:00:00 2001 From: Liron Date: Wed, 21 May 2025 23:18:07 +0300 Subject: [PATCH 9/9] update Python version requirement to 3.9 in README --- Readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Readme.md b/Readme.md index d36fc30..f684931 100644 --- a/Readme.md +++ b/Readme.md @@ -3,7 +3,7 @@ ### What Is This ? TAP python SDK allows you to build python app that can establish BLE connection with Tap Strap and TapXR, send commands and receive events and data - Thus allowing TAP to act as a controller for your app! -The library is developed with Python >= 3.7 and is **currently in beta**. +The library is developed with Python >= 3.9 and is **currently in beta**. ### Supported Platforms