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 new file mode 100644 index 0000000..5e54ff5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,30 @@ +name: CI + +on: + pull_request: + push: + branches: [master] + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["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 -e .[dev] + - name: Lint with flake8 + run: | + pip install flake8 + flake8 examples tapsdk tests + - name: Run tests + run: pytest -v 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/Readme.md b/Readme.md index 9e494c0..f684931 100644 --- a/Readme.md +++ b/Readme.md @@ -3,14 +3,14 @@ ### 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 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,22 @@ 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). + +### Testing + +To run the tests, first install the development dependencies: + +```bash +pip install .[dev] +``` + +Then run the tests using pytest: + +```bash +pytest +``` + ### 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..4301472 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) @@ -44,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/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/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/setup.py b/setup.py index 98d9dd0..3ed784b 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", "flake8"] + }, # classifiers=[ # # Trove classifiers # # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers 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/dotnet/TAPWin.dll b/tapsdk/backends/dotnet/TAPWin.dll deleted file mode 100644 index 32711e1..0000000 Binary files a/tapsdk/backends/dotnet/TAPWin.dll and /dev/null differ 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 71% rename from tapsdk/backends/posix/TapSDK.py rename to tapsdk/tap.py index 101f254..f04f35b 100644 --- a/tapsdk/backends/posix/TapSDK.py +++ b/tapsdk/tap.py @@ -3,16 +3,24 @@ 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 ...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) @@ -28,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() @@ -39,10 +49,18 @@ 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 +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): @@ -51,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() @@ -108,9 +126,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 +135,32 @@ 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): - 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): - 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): - 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): - 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): - 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 - - async def register_disconnection_events(self, cb: Callable): - pass + def register_tap_events(self, cb: Callable): + self.tap_event_cb = cb + + def register_mouse_events(self, cb: Callable): + self.mouse_event_cb = cb + + def register_air_gesture_events(self, cb: Callable): + self.air_gesture_event_cb = cb + + def register_air_gesture_state_events(self, cb: Callable): + self.air_gesture_state_event_cb = cb + + def register_raw_data_events(self, cb: Callable): + self.raw_data_event_cb = cb + + def register_connection_events(self, cb: Callable): + self.connection_cb = cb + + 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 +197,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,14 +230,40 @@ 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) - - async def list_connected_taps(self): - devices = await discover(loop=self.loop) - return devices + await self.client.write_gatt_char(tap_mode_characteristic, value) async def run(self): - await self.client.connect_retrieved() + 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], loop=self.loop) + 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), + (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: diff --git a/tapsdk/backends/__init__.py b/tests/__init__.py similarity index 100% rename from tapsdk/backends/__init__.py rename to tests/__init__.py 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..1bedc78 --- /dev/null +++ b/tests/test_inputmodes.py @@ -0,0 +1,17 @@ +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..4a189c6 --- /dev/null +++ b/tests/test_parsers.py @@ -0,0 +1,55 @@ +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(): + # 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} + ]