├── .gitignore ├── .gitlab-ci.yml ├── .pylintrc ├── LICENSE ├── README.md ├── examples ├── basic.py ├── basic_threaded.py ├── custom_data.py ├── magnetometer.py ├── mouse.py ├── osc.py ├── osc_client_server.py ├── pinch_probability.py ├── plotter.py ├── pressure.py ├── raycasting.py ├── request_model.py └── sensors.py ├── py2unity_installer.cfg ├── pyproject.toml └── src └── touch_sdk ├── __init__.py ├── gatt_scanner.py ├── protobuf ├── __init__.py ├── common_pb2.py ├── watch_input_pb2.py └── watch_output_pb2.py ├── stream_watch.py ├── utils.py ├── uuids.py ├── watch.py └── watch_connector.py /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | *venv/ 3 | .env/ 4 | *.pyc 5 | *.spec 6 | build/ 7 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | pylint: 2 | image: python:3.9 3 | script: 4 | - pip install pylint 5 | - pip install . 6 | - pylint src --rcfile=.pylintrc 7 | rules: 8 | - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' 9 | allow_failure: true # only warn about linter errors, no hard enforcement 10 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MAIN] 2 | good-names=x,y 3 | ignore=protobuf 4 | disable=logging-fstring-interpolation 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2022–2023 Doublepoint 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 11 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 14 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15 | PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Touch SDK py 2 | 3 | ![PyPI](https://img.shields.io/pypi/v/touch-sdk) 4 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/touch-sdk) 5 | ![PyPI - License](https://img.shields.io/pypi/l/touch-sdk) 6 | ![Discord](https://img.shields.io/discord/869474617729875998) 7 | 8 | Connects to Doublepoint Touch SDK compatible Bluetooth devices – like [this Wear OS app](https://play.google.com/store/apps/details?id=io.port6.watchbridge). 9 | 10 | There is also a [web SDK](https://www.npmjs.com/package/touch-sdk) and a [Unity SDK](https://openupm.com/packages/io.port6.sdk/). 11 | 12 | See [doublepoint.com/product](https://doublepoint.com/product) for more info. 13 | 14 | ## Installation 15 | 16 | ```sh 17 | pip install touch-sdk 18 | ``` 19 | 20 | ## Example usage 21 | ```python 22 | from touch_sdk import Watch 23 | 24 | class MyWatch(Watch): 25 | def on_tap(self): 26 | print('Tap') 27 | 28 | watch = MyWatch() 29 | watch.start() 30 | ``` 31 | 32 | ## Usage 33 | 34 | All callback functions should be methods in the class that inherits `Watch`, like in the example above. 35 | 36 | An optional name string in the constructor will search only for devices with that name (case insensitive). 37 | 38 | ```python 39 | watch = MyWatch('fvaf') 40 | ``` 41 | 42 | ### Tap gesture 43 | ```python 44 | def on_tap(self): 45 | print('tap') 46 | ``` 47 | 48 | ### Sensors 49 | ```python 50 | def on_sensors(self, sensors): 51 | print(sensors.acceleration) # (x, y, z) 52 | print(sensors.gravity) # (x, y, z) 53 | print(sensors.angular_velocity) # (x, y, z) 54 | print(sensors.orientation) # (x, y, z, w) 55 | print(sensors.magnetic_field) # (x, y, z), or None if unavailable 56 | print(sensors.magnetic_field_calibration) # (x, y, z), or None if unavailable 57 | ``` 58 | 59 | ### Touch screen 60 | ```python 61 | def on_touch_down(self, x, y): 62 | print('touch down', x, y) 63 | 64 | def on_touch_up(self, x, y): 65 | print('touch up', x, y) 66 | 67 | def on_touch_move(self, x, y): 68 | print('touch move', x, y) 69 | 70 | def on_touch_cancel(self, x, y): 71 | print('touch cancel', x, y) 72 | ``` 73 | 74 | ### Rotary dial 75 | ```python 76 | def on_rotary(self, direction): 77 | print('rotary', direction) 78 | ``` 79 | Outputs +1 for clockwise and -1 for counter-clockwise. 80 | 81 | ### Back button 82 | ```python 83 | def on_back_button(self): 84 | print('back button') 85 | ``` 86 | 87 | Called when the back button is pressed and released. Wear OS does not support separate button down and button up events for the back button. 88 | 89 | ### Probability output 90 | ```python 91 | def on_gesture_probability(self, probabilities): 92 | print(f'probabilities: {probabilities}') 93 | ``` 94 | Triggered when a gesture detection model produces an output. See `examples/pinch_probability.py` for a complete example. 95 | 96 | ### Haptics 97 | The `trigger_haptics(intensity, length)` method can be used to initiate one-shot haptic effects on the watch. For example, to drive the haptics motor for 300 ms at 100% intensity on `watch`, call `watch.trigger_haptics(1.0, 300)`. 98 | 99 | ### Miscellaneous 100 | ```python 101 | watch.hand # Hand.NONE, Hand.LEFT or Hand.RIGHT 102 | watch.battery_percentage # 0-100 103 | watch.touch_screen_resolution # (width, height) or None 104 | watch.haptics_available # True if device supports haptic feedback 105 | ``` 106 | 107 | ## Acting as backend for Unity Play Mode 108 | 109 | This package provides the `stream_watch` module, which makes it possible to use touch-sdk-py as the backend for touch-sdk-unity (>=0.12.0) applications in Play Mode. To use this feature, create a virtual environment in which touch-sdk-py is installed, and then set the python path of the `BluetoothWatchProvider` script in your Unity project to the virtual environment's python executable. 110 | 111 | ## Unexplainable bugs 112 | Sometimes turning your device's Bluetooth off and on again fixes problems – this has been observed on Linux, Mac and Windows. This is unideal, but those error states are hard to reproduce and thus hard to fix. 113 | 114 | ## Pylint 115 | ```sh 116 | python3 -m pylint src --rcfile=.pylintrc 117 | ``` 118 | 119 | ### Adding pylint to pre-commit 120 | ```sh 121 | echo 'python3 -m pylint src --rcfile=.pylintrc -sn' > .git/hooks/pre-commit 122 | chmod +x .git/hooks/pre-commit 123 | ``` 124 | -------------------------------------------------------------------------------- /examples/basic.py: -------------------------------------------------------------------------------- 1 | from touch_sdk import Watch, GestureType 2 | import logging 3 | 4 | # Get helpful log info 5 | logging.basicConfig(level=logging.INFO) 6 | 7 | 8 | class MyWatch(Watch): 9 | 10 | # def on_sensors(self, sensors): 11 | # print(sensors) 12 | 13 | def on_gesture(self, gesture): 14 | if gesture != GestureType.NONE: 15 | print("Gesture:", gesture) 16 | 17 | def on_touch_down(self, x, y): 18 | print("touch down", x, y) 19 | 20 | def on_touch_up(self, x, y): 21 | print("touch up", x, y) 22 | 23 | def on_touch_move(self, x, y): 24 | print("touch move", x, y) 25 | 26 | def on_rotary(self, direction): 27 | print("rotary", direction) 28 | 29 | def on_back_button(self): 30 | self.trigger_haptics(1.0, 20) 31 | print("back button") 32 | 33 | def on_connect(self): 34 | print( 35 | "Connected watch has name {}, app id {}, app version {}, manufacturer {}, and battery level {}".format( 36 | self.device_name, 37 | self.app_id, 38 | self.app_version, 39 | self.manufacturer, 40 | self.battery_percentage, 41 | ) 42 | ) 43 | print("Following models are available:") 44 | for i, m in enumerate(self.available_models): 45 | print("{}:".format(i), m) 46 | 47 | 48 | watch = MyWatch() 49 | watch.start() 50 | -------------------------------------------------------------------------------- /examples/basic_threaded.py: -------------------------------------------------------------------------------- 1 | from threading import Thread 2 | from touch_sdk import Watch, GestureType 3 | import logging 4 | 5 | # Get helpful log info 6 | logging.basicConfig(level=logging.INFO) 7 | 8 | 9 | class MyWatch(Watch): 10 | 11 | # def on_sensors(self, sensors): 12 | # print(sensors) 13 | 14 | def on_gesture(self, gesture): 15 | if gesture != GestureType.NONE: 16 | print("Gesture:", gesture) 17 | 18 | def on_touch_down(self, x, y): 19 | print("touch down", x, y) 20 | 21 | def on_touch_up(self, x, y): 22 | print("touch up", x, y) 23 | 24 | def on_touch_move(self, x, y): 25 | print("touch move", x, y) 26 | 27 | def on_rotary(self, direction): 28 | print("rotary", direction) 29 | 30 | def on_back_button(self): 31 | self.trigger_haptics(1.0, 20) 32 | print("back button") 33 | 34 | 35 | watch = MyWatch() 36 | thread = Thread(target=watch.start) 37 | thread.start() 38 | input("Press enter to exit\n") 39 | watch.stop() 40 | thread.join() 41 | -------------------------------------------------------------------------------- /examples/custom_data.py: -------------------------------------------------------------------------------- 1 | from touch_sdk import Watch 2 | import logging 3 | # Get helpful log info 4 | logging.basicConfig(level=logging.INFO) 5 | 6 | class CustomDataWatch(Watch): 7 | 8 | custom_data = { 9 | "4b574af1-72d7-45d2-a1bb-23cd0ec20c57": ">3f" 10 | } 11 | 12 | def on_custom_data(self, uuid, content): 13 | print(content) 14 | 15 | watch = CustomDataWatch() 16 | watch.start() 17 | -------------------------------------------------------------------------------- /examples/magnetometer.py: -------------------------------------------------------------------------------- 1 | from touch_sdk import Watch 2 | import logging 3 | # Get helpful log info 4 | logging.basicConfig(level=logging.INFO) 5 | 6 | class MyWatch(Watch): 7 | 8 | def on_sensors(self, sensors): 9 | print(sensors.magnetic_field, sensors.magnetic_field_calibration) 10 | 11 | def on_tap(self): 12 | print('tap') 13 | 14 | def on_touch_down(self, x, y): 15 | print('touch down', x, y) 16 | 17 | def on_touch_up(self, x, y): 18 | print('touch up', x, y) 19 | 20 | def on_touch_move(self, x, y): 21 | print('touch move', x, y) 22 | 23 | def on_rotary(self, direction): 24 | print('rotary', direction) 25 | 26 | def on_back_button(self): 27 | self.trigger_haptics(1.0, 20) 28 | print('back button') 29 | 30 | watch = MyWatch() 31 | watch.start() 32 | -------------------------------------------------------------------------------- /examples/mouse.py: -------------------------------------------------------------------------------- 1 | # To use this example, make sure to install extra dependencies: 2 | # pip install pyautogui 3 | from touch_sdk import Watch, GestureType 4 | import pyautogui 5 | 6 | 7 | class MouseWatch(Watch): 8 | 9 | scale = 30 10 | pinch_state = False 11 | 12 | def on_gesture_probability(self, probabilities): 13 | prob = probabilities.get( 14 | GestureType.PINCH_TAP, 1 - probabilities.get(GestureType.NONE, 1.0) 15 | ) 16 | if prob >= 0.5 and not self.pinch_state: 17 | self.pinch_state = True 18 | pyautogui.mouseDown(_pause=False) 19 | elif prob < 0.5 and self.pinch_state: 20 | self.pinch_state = False 21 | pyautogui.mouseUp(_pause=False) 22 | 23 | def on_arm_direction_change(self, delta_x: float, delta_y: float): 24 | 25 | pyautogui.moveRel(self.scale * delta_x, self.scale * delta_y, _pause=False) 26 | 27 | 28 | watch = MouseWatch() 29 | watch.start() 30 | -------------------------------------------------------------------------------- /examples/osc.py: -------------------------------------------------------------------------------- 1 | # To use this example, make sure to install python-osc: 2 | # pip install python-osc 3 | 4 | from touch_sdk import Watch 5 | from pythonosc.udp_client import SimpleUDPClient 6 | import logging 7 | # Get helpful log info 8 | logging.basicConfig(level=logging.INFO) 9 | 10 | ip = "127.0.0.1" 11 | port = 6666 12 | 13 | osc_client = SimpleUDPClient(ip, port) 14 | 15 | class MyWatch(Watch): 16 | def on_sensors(self, sensors): 17 | angular_velocity = sensors.angular_velocity 18 | gravity = sensors.gravity 19 | acceleration = sensors.acceleration 20 | orientation = sensors.orientation 21 | osc_client.send_message("/angular-velocity", angular_velocity) 22 | osc_client.send_message("/gravity", gravity) 23 | osc_client.send_message("/acceleration", acceleration) 24 | osc_client.send_message("/orientation", orientation) 25 | 26 | def on_tap(self): 27 | osc_client.send_message("/tap", 1) 28 | print('tap') 29 | 30 | def on_touch_down(self, x, y): 31 | osc_client.send_message("/touch-down", [x, y]) 32 | print('touch down', x, y) 33 | 34 | def on_touch_up(self, x, y): 35 | osc_client.send_message("/touch-up", [x, y]) 36 | print('touch up', x, y) 37 | 38 | def on_touch_move(self, x, y): 39 | osc_client.send_message("/touch-move", [x, y]) 40 | print('touch move', x, y) 41 | 42 | def on_rotary(self, direction): 43 | osc_client.send_message("/rotary", direction) 44 | print('rotary', direction) 45 | 46 | def on_back_button(self): 47 | osc_client.send_message("/back-button", 1) 48 | self.trigger_haptics(1.0, 20) 49 | print('back button') 50 | 51 | watch = MyWatch() 52 | watch.start() 53 | -------------------------------------------------------------------------------- /examples/osc_client_server.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import threading 3 | from pythonosc.dispatcher import Dispatcher 4 | from pythonosc.osc_server import ThreadingOSCUDPServer 5 | from pythonosc.udp_client import SimpleUDPClient 6 | from touch_sdk import Watch 7 | import logging 8 | # Get helpful log info 9 | logging.basicConfig(level=logging.INFO) 10 | 11 | 12 | class MyWatch(Watch): 13 | def __init__(self, ip, client_port, server_port, name_filter=None): 14 | super().__init__(name_filter) 15 | self.osc_client = SimpleUDPClient(ip, client_port) 16 | self.dispatcher = Dispatcher() 17 | self.dispatcher.map("/vib/intensity", self.handle_intensity) 18 | self.dispatcher.map("/vib/duration", self.handle_duration) 19 | 20 | self.server = ThreadingOSCUDPServer((ip, server_port), self.dispatcher) 21 | print(f"OSC server serving on {ip}:{server_port}") 22 | 23 | self.intensity_value = 0 24 | self.duration_value = 0 25 | 26 | def handle_intensity(self, address, *args): 27 | self.intensity_value = args[0] if args else None 28 | if self.intensity_value and self.duration_value: 29 | self.trigger_haptics(self.intensity_value, self.duration_value) 30 | 31 | def handle_duration(self, address, *args): 32 | self.duration_value = args[0] if args else None 33 | if self.intensity_value and self.duration_value: 34 | self.trigger_haptics(self.intensity_value, self.duration_value) 35 | 36 | def start_osc_server(self): 37 | server_thread = threading.Thread(target=self.server.serve_forever) 38 | server_thread.start() 39 | 40 | def stop_osc_server(self): 41 | self.server.shutdown() 42 | 43 | def on_sensors(self, sensors): 44 | self.osc_client.send_message("/angular-velocity", sensors.angular_velocity) 45 | self.osc_client.send_message("/gravity", sensors.gravity) 46 | self.osc_client.send_message("/acceleration", sensors.acceleration) 47 | self.osc_client.send_message("/orientation", sensors.orientation) 48 | 49 | async def send_tap_zero_later(self): 50 | await asyncio.sleep(0.1) 51 | self.osc_client.send_message("/tap", 0) 52 | print('tap 0') 53 | 54 | def on_tap(self): 55 | self.osc_client.send_message("/tap", 1) 56 | print('tap 1') 57 | # Schedule the sending of /tap 0 message half a second later 58 | asyncio.ensure_future(self.send_tap_zero_later()) 59 | 60 | def on_touch_down(self, x, y): 61 | self.osc_client.send_message("/touch-down", [x, y]) 62 | print('touch down', x, y) 63 | 64 | def on_touch_up(self, x, y): 65 | self.osc_client.send_message("/touch-up", [x, y]) 66 | print('touch up', x, y) 67 | 68 | def on_touch_move(self, x, y): 69 | self.osc_client.send_message("/touch-move", [x, y]) 70 | print('touch move', x, y) 71 | 72 | def on_rotary(self, direction): 73 | self.osc_client.send_message("/rotary", direction) 74 | print('rotary', direction) 75 | 76 | async def send_back_button_zero_later(self): 77 | await asyncio.sleep(0.1) 78 | self.osc_client.send_message("/back-button", 0) 79 | print('back button 0') 80 | 81 | def on_back_button(self): 82 | self.osc_client.send_message("/back-button", 1) 83 | self.trigger_haptics(1.0, 20) 84 | print('back button 1') 85 | # Schedule the sending of /back-button 0 message half a second later 86 | asyncio.ensure_future(self.send_back_button_zero_later()) 87 | 88 | # Setup the IP, client port (for sending), and server port (for receiving) 89 | ip = "127.0.0.1" 90 | client_port = 6666 91 | server_port = 6667 92 | 93 | # Create an instance of MyWatch 94 | watch = MyWatch(ip, client_port, server_port) 95 | 96 | # Start the OSC server 97 | watch.start_osc_server() 98 | 99 | async def main(): 100 | try: 101 | # Start the watch 102 | await watch.run() 103 | except KeyboardInterrupt: 104 | print("Exiting...") 105 | 106 | # Run the program using asyncio's event loop 107 | loop = asyncio.get_event_loop() 108 | try: 109 | loop.run_until_complete(main()) 110 | # Optionally, if you need to await the completion of all tasks: 111 | pending = asyncio.all_tasks(loop) 112 | loop.run_until_complete(asyncio.gather(*pending)) 113 | 114 | watch.stop_osc_server() # Stop the OSC server 115 | loop.close() 116 | except KeyboardInterrupt: 117 | pass 118 | finally: 119 | watch.stop() # Stop the Touch SDK Watch 120 | watch.stop_osc_server() # Stop the OSC server 121 | loop.close() 122 | -------------------------------------------------------------------------------- /examples/pinch_probability.py: -------------------------------------------------------------------------------- 1 | from touch_sdk import Watch 2 | 3 | class MyWatch(Watch): 4 | 5 | def on_gesture_probability(self, probs): 6 | print(f'Probabilities: {probs}') 7 | 8 | 9 | watch = MyWatch() 10 | watch.start() 11 | -------------------------------------------------------------------------------- /examples/plotter.py: -------------------------------------------------------------------------------- 1 | # To use this example, make sure to install extra dependencies: 2 | # pip install matplotlib numpy 3 | 4 | from threading import Thread 5 | from queue import Queue, Empty 6 | from collections import deque 7 | 8 | import numpy as np 9 | 10 | import matplotlib.pyplot as plt 11 | from matplotlib.animation import FuncAnimation 12 | 13 | from touch_sdk import Watch 14 | import logging 15 | # Get helpful log info 16 | logging.basicConfig(level=logging.INFO) 17 | 18 | 19 | class MyWatch(Watch): 20 | def __init__(self, name=""): 21 | super().__init__(name) 22 | self.sensor_queue = Queue() 23 | 24 | def on_sensors(self, sensors): 25 | self.sensor_queue.put(sensors) 26 | 27 | 28 | def anim(_, watch, ax, lines, gyro_data): 29 | 30 | while True: 31 | try: 32 | sensors = watch.sensor_queue.get(block=False) 33 | except Empty: 34 | break 35 | 36 | gyro_data.append(sensors.angular_velocity) 37 | 38 | while len(gyro_data) > 100: 39 | gyro_data.popleft() 40 | 41 | if len(gyro_data) == 0: 42 | return (ax,) 43 | 44 | arr = np.array(gyro_data).T 45 | 46 | ymax, ymin = np.max(arr), np.min(arr) 47 | range = max(abs(ymax), abs(ymin)) 48 | ax.set_ylim(range, -range) 49 | 50 | x = np.arange(arr.shape[1]) 51 | for line, data in zip(lines, arr): 52 | line.set_data(x, data) 53 | 54 | return lines 55 | 56 | 57 | if __name__ == "__main__": 58 | fig, ax = plt.subplots() 59 | 60 | ax.set_xlim(0, 100) 61 | lines = ax.plot(np.zeros((0, 3))) 62 | 63 | watch = MyWatch() 64 | thread = Thread(target=watch.start) 65 | thread.start() 66 | 67 | gyro_data = deque() 68 | 69 | _ = FuncAnimation( 70 | fig, anim, fargs=(watch, ax, lines, gyro_data), interval=1, blit=True, 71 | cache_frame_data=False 72 | ) 73 | 74 | plt.show() 75 | watch.stop() 76 | thread.join() 77 | -------------------------------------------------------------------------------- /examples/pressure.py: -------------------------------------------------------------------------------- 1 | from touch_sdk import Watch 2 | import logging 3 | # Get helpful log info 4 | logging.basicConfig(level=logging.INFO) 5 | 6 | 7 | class MyWatch(Watch): 8 | def on_pressure(self, pressure): 9 | print(f"Pressure: {pressure} hPa") 10 | 11 | 12 | watch = MyWatch() 13 | watch.start() 14 | -------------------------------------------------------------------------------- /examples/raycasting.py: -------------------------------------------------------------------------------- 1 | from touch_sdk import Watch 2 | import logging 3 | # Get helpful log info 4 | logging.basicConfig(level=logging.INFO) 5 | 6 | class RayCastingWatch(Watch): 7 | 8 | def __init__(self, name=None): 9 | super().__init__(name) 10 | 11 | self.ray_x = 0 12 | self.ray_y = 0 13 | 14 | def on_arm_direction_change(self, delta_x, delta_y): 15 | speed = 3 16 | 17 | # Integrate 18 | self.ray_x += delta_x * speed 19 | self.ray_y += delta_y * speed 20 | 21 | # Box clamp (optional) 22 | size = 100 23 | self.ray_x = max(-size, min(size, self.ray_x)) 24 | self.ray_y = max(-size, min(size, self.ray_y)) 25 | 26 | # Output 27 | print('raycasting\t{:.1f}\t{:.1f}'.format(self.ray_x, self.ray_y)) 28 | 29 | def on_tap(self): 30 | print('tap') 31 | 32 | 33 | watch = RayCastingWatch() 34 | watch.start() 35 | -------------------------------------------------------------------------------- /examples/request_model.py: -------------------------------------------------------------------------------- 1 | from touch_sdk import Watch, GestureType 2 | import logging 3 | 4 | # Get helpful log info 5 | logging.basicConfig(level=logging.INFO) 6 | 7 | 8 | class MyWatch(Watch): 9 | 10 | def on_gesture(self, gesture): 11 | if gesture != GestureType.NONE: 12 | print("Gesture:", gesture) 13 | 14 | def on_connect(self): 15 | print("Following models are available:") 16 | for i, m in enumerate(self.available_models): 17 | print("{}:".format(i), m) 18 | 19 | print("Requesting last of the list") 20 | # This may take a second or two 21 | self.request_model(self.available_models[-1]) 22 | 23 | def on_info_update(self): 24 | print("Active model:", self.active_model) 25 | 26 | 27 | watch = MyWatch() 28 | watch.start() 29 | -------------------------------------------------------------------------------- /examples/sensors.py: -------------------------------------------------------------------------------- 1 | from touch_sdk import Watch 2 | import logging 3 | # Get helpful log info 4 | logging.basicConfig(level=logging.INFO) 5 | 6 | class MyWatch(Watch): 7 | def on_sensors(self, sensors): 8 | def format_tuple(data): 9 | return ' '.join(format(field, '.3f') for field in data) 10 | 11 | print(format_tuple(sensors.acceleration), end='\t') 12 | print(format_tuple(sensors.gravity), end='\t') 13 | print(format_tuple(sensors.angular_velocity), end='\t') 14 | if sensors.magnetic_field: 15 | print(format_tuple(sensors.magnetic_field), end='\t') 16 | print(sensors.timestamp) 17 | 18 | watch = MyWatch() 19 | watch.start() 20 | -------------------------------------------------------------------------------- /py2unity_installer.cfg: -------------------------------------------------------------------------------- 1 | [Application] 2 | name=Touch SDK py2unity 3 | version=0.8.0 4 | entry_point=touch_sdk.stream_watch:main 5 | console=true 6 | 7 | [Python] 8 | version=3.10.11 9 | 10 | [Include] 11 | packages =touch_sdk 12 | pypi_wheels =bleak==0.22.2 13 | protobuf==5.28.2 14 | asyncio-atexit==1.0.1 15 | async-timeout==4.0.3 16 | bleak-winrt==1.2.0 17 | protobuf==5.28.2 18 | typing_extensions==4.12.2 19 | 20 | [Build] 21 | installer_name=Touch_SDK_py2unity_installer.exe 22 | 23 | [Command touch_sdk_py2unity] 24 | entry_point=touch_sdk.stream_watch:main 25 | console=true 26 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "touch-sdk" 7 | version = "0.8.0" 8 | description = "Doublepoint Touch SDK" 9 | license = {file = "LICENSE"} 10 | authors = [ 11 | { name="Doublepoint", email="developer@doublepoint.com" }, 12 | ] 13 | readme = "README.md" 14 | requires-python = ">=3.9" 15 | keywords = ["bluetooth"] 16 | classifiers = [ 17 | "Development Status :: 3 - Alpha", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: ISC License (ISCL)", 20 | "Operating System :: POSIX :: Linux", 21 | "Operating System :: MacOS :: MacOS X", 22 | "Operating System :: Microsoft :: Windows :: Windows 10", 23 | "Operating System :: Android", 24 | "Topic :: Scientific/Engineering :: Bio-Informatics", 25 | "Topic :: Scientific/Engineering :: Human Machine Interfaces", 26 | ] 27 | dependencies = [ 28 | "bleak~=0.22.2", 29 | "protobuf~=5.28.2", 30 | "asyncio-atexit~=1.0.1", 31 | ] 32 | 33 | [project.urls] 34 | "Homepage" = "https://github.com/doublepointlab/touch-sdk-py#readme" 35 | "Bug Tracker" = "https://github.com/doublepointlab/touch-sdk-py/issues" 36 | -------------------------------------------------------------------------------- /src/touch_sdk/__init__.py: -------------------------------------------------------------------------------- 1 | from touch_sdk.watch import Watch, GestureType 2 | 3 | __version__ = "0.8.0" 4 | 5 | __doc__ = """Connects to Doublepoint Touch SDK compatible Bluetooth devices 6 | 7 | You can download a controller app for Wear OS from 8 | https://play.google.com/store/apps/details?id=io.port6.watchbridge 9 | 10 | This module will connect to the Wear OS app via Bluetooth LE if the in-app Touch SDK mode is enabled. 11 | 12 | See also https://doublepoint.com/product""" 13 | -------------------------------------------------------------------------------- /src/touch_sdk/gatt_scanner.py: -------------------------------------------------------------------------------- 1 | from bleak import BleakScanner 2 | import logging 3 | logger = logging.getLogger(__file__) 4 | 5 | __doc__ = """Scans for Bluetooth devices with a given GATT service UUID.""" 6 | 7 | class GattScanner: 8 | """Scans for Bluetooth devices with service_uuid. 9 | 10 | on_scan_result gets called every time the scanner finds a new device. 11 | It should take parameters device and name. 12 | 13 | If name_filter is present, GattScanner will only find devices which contain 14 | that string in their name.""" 15 | 16 | def __init__(self, on_scan_result, service_uuid, name_filter=None): 17 | """Creates a new instance of GattScanner. Does not start the scanning.""" 18 | self.on_scan_result = on_scan_result 19 | self.service_uuid = service_uuid 20 | self.name_filter = name_filter 21 | self.scanner = None 22 | self._addresses = set() 23 | self._scanning = False 24 | 25 | async def start(self): 26 | """Start the scanner.""" 27 | 28 | scanner = BleakScanner( 29 | self._detection_callback, service_uuids=[self.service_uuid] 30 | ) 31 | 32 | await self.start_scanning() 33 | await scanner.start() 34 | 35 | async def stop_scanning(self): 36 | """Stop scanning.""" 37 | self._scanning = False 38 | 39 | async def start_scanning(self): 40 | """Start scanning. This function should not be called before GattScanner.run 41 | has been called.""" 42 | if not self._scanning: 43 | self._addresses.clear() # Reset found addresses list 44 | self._scanning = True 45 | logger.info("Scanning...") 46 | 47 | def forget_address(self, address): 48 | """Forget address, i.e., act as if the device with that address had 49 | never been discovered.""" 50 | self._addresses.discard(address) 51 | 52 | async def _detection_callback(self, device, advertisement_data): 53 | if not self._scanning: 54 | return 55 | 56 | if device.address in self._addresses: 57 | return 58 | self._addresses.add(device.address) 59 | 60 | try: 61 | name = ( 62 | advertisement_data.manufacturer_data.get(0xFFFF, bytearray()).decode( 63 | "utf-8" 64 | ) 65 | or advertisement_data.local_name 66 | ) 67 | 68 | if self.service_uuid in advertisement_data.service_uuids: 69 | if self.name_filter is not None: 70 | if self.name_filter.lower() not in name.lower(): 71 | return 72 | 73 | logger.info(f"Found {name}") 74 | await self.on_scan_result(device, name) 75 | except KeyboardInterrupt: 76 | raise 77 | except Exception: 78 | logger.debug("Failed connection to %s", device) 79 | -------------------------------------------------------------------------------- /src/touch_sdk/protobuf/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doublepointlab/touch-sdk-py/4049e4cc9a0fba463d17357254161a6b60c831f5/src/touch_sdk/protobuf/__init__.py -------------------------------------------------------------------------------- /src/touch_sdk/protobuf/common_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # NO CHECKED-IN PROTOBUF GENCODE 4 | # source: common.proto 5 | # Protobuf Python Version: 5.28.2 6 | """Generated protocol buffer code.""" 7 | from google.protobuf import descriptor as _descriptor 8 | from google.protobuf import descriptor_pool as _descriptor_pool 9 | from google.protobuf import runtime_version as _runtime_version 10 | from google.protobuf import symbol_database as _symbol_database 11 | from google.protobuf.internal import builder as _builder 12 | _runtime_version.ValidateProtobufRuntimeVersion( 13 | _runtime_version.Domain.PUBLIC, 14 | 5, 15 | 28, 16 | 2, 17 | '', 18 | 'common.proto' 19 | ) 20 | # @@protoc_insertion_point(imports) 21 | 22 | _sym_db = _symbol_database.Default() 23 | 24 | 25 | 26 | 27 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0c\x63ommon.proto\"\x1c\n\x04Vec2\x12\t\n\x01x\x18\x01 \x01(\x02\x12\t\n\x01y\x18\x02 \x01(\x02\"\'\n\x04Vec3\x12\t\n\x01x\x18\x01 \x01(\x02\x12\t\n\x01y\x18\x02 \x01(\x02\x12\t\n\x01z\x18\x03 \x01(\x02\"2\n\x04Quat\x12\t\n\x01w\x18\x01 \x01(\x02\x12\t\n\x01x\x18\x02 \x01(\x02\x12\t\n\x01y\x18\x03 \x01(\x02\x12\t\n\x01z\x18\x04 \x01(\x02\"\'\n\x05Model\x12\x1e\n\x08gestures\x18\x01 \x03(\x0e\x32\x0c.GestureType*\x8e\x01\n\x0bGestureType\x12\x08\n\x04NONE\x10\x00\x12\r\n\tPINCH_TAP\x10\x01\x12\n\n\x06\x43LENCH\x10\x02\x12\x0f\n\x0bSURFACE_TAP\x10\x03\x12\x0e\n\nPINCH_HOLD\x10\x04\x12\r\n\tDPAD_LEFT\x10\x05\x12\x0e\n\nDPAD_RIGHT\x10\x06\x12\x0b\n\x07\x44PAD_UP\x10\x07\x12\r\n\tDPAD_DOWN\x10\x08\x42\r\xaa\x02\nPsix.Protob\x06proto3') 28 | 29 | _globals = globals() 30 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 31 | _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'common_pb2', _globals) 32 | if not _descriptor._USE_C_DESCRIPTORS: 33 | _globals['DESCRIPTOR']._loaded_options = None 34 | _globals['DESCRIPTOR']._serialized_options = b'\252\002\nPsix.Proto' 35 | _globals['_GESTURETYPE']._serialized_start=181 36 | _globals['_GESTURETYPE']._serialized_end=323 37 | _globals['_VEC2']._serialized_start=16 38 | _globals['_VEC2']._serialized_end=44 39 | _globals['_VEC3']._serialized_start=46 40 | _globals['_VEC3']._serialized_end=85 41 | _globals['_QUAT']._serialized_start=87 42 | _globals['_QUAT']._serialized_end=137 43 | _globals['_MODEL']._serialized_start=139 44 | _globals['_MODEL']._serialized_end=178 45 | # @@protoc_insertion_point(module_scope) 46 | -------------------------------------------------------------------------------- /src/touch_sdk/protobuf/watch_input_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # NO CHECKED-IN PROTOBUF GENCODE 4 | # source: watch_input.proto 5 | # Protobuf Python Version: 5.28.2 6 | """Generated protocol buffer code.""" 7 | from google.protobuf import descriptor as _descriptor 8 | from google.protobuf import descriptor_pool as _descriptor_pool 9 | from google.protobuf import runtime_version as _runtime_version 10 | from google.protobuf import symbol_database as _symbol_database 11 | from google.protobuf.internal import builder as _builder 12 | _runtime_version.ValidateProtobufRuntimeVersion( 13 | _runtime_version.Domain.PUBLIC, 14 | 5, 15 | 28, 16 | 2, 17 | '', 18 | 'watch_input.proto' 19 | ) 20 | # @@protoc_insertion_point(imports) 21 | 22 | _sym_db = _symbol_database.Default() 23 | 24 | 25 | import touch_sdk.protobuf.common_pb2 as common__pb2 26 | 27 | 28 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x11watch_input.proto\x1a\x0c\x63ommon.proto\"~\n\x0bHapticEvent\x12%\n\x04type\x18\x01 \x01(\x0e\x32\x17.HapticEvent.HapticType\x12\x11\n\tintensity\x18\x02 \x01(\x02\x12\x0e\n\x06length\x18\x03 \x01(\x05\"%\n\nHapticType\x12\n\n\x06\x43\x41NCEL\x10\x00\x12\x0b\n\x07ONESHOT\x10\x01\"L\n\nClientInfo\x12\x0f\n\x07\x61ppName\x18\x01 \x01(\t\x12\x12\n\ndeviceName\x18\x02 \x01(\t\x12\r\n\x05title\x18\x03 \x01(\t\x12\n\n\x02os\x18\x04 \x01(\t\"o\n\x0bInputUpdate\x12!\n\x0bhapticEvent\x18\x01 \x01(\x0b\x32\x0c.HapticEvent\x12\x1f\n\nclientInfo\x18\x02 \x01(\x0b\x32\x0b.ClientInfo\x12\x1c\n\x0cmodelRequest\x18\x03 \x01(\x0b\x32\x06.ModelB\r\xaa\x02\nPsix.Protob\x06proto3') 29 | 30 | _globals = globals() 31 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 32 | _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'watch_input_pb2', _globals) 33 | if not _descriptor._USE_C_DESCRIPTORS: 34 | _globals['DESCRIPTOR']._loaded_options = None 35 | _globals['DESCRIPTOR']._serialized_options = b'\252\002\nPsix.Proto' 36 | _globals['_HAPTICEVENT']._serialized_start=35 37 | _globals['_HAPTICEVENT']._serialized_end=161 38 | _globals['_HAPTICEVENT_HAPTICTYPE']._serialized_start=124 39 | _globals['_HAPTICEVENT_HAPTICTYPE']._serialized_end=161 40 | _globals['_CLIENTINFO']._serialized_start=163 41 | _globals['_CLIENTINFO']._serialized_end=239 42 | _globals['_INPUTUPDATE']._serialized_start=241 43 | _globals['_INPUTUPDATE']._serialized_end=352 44 | # @@protoc_insertion_point(module_scope) 45 | -------------------------------------------------------------------------------- /src/touch_sdk/protobuf/watch_output_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # NO CHECKED-IN PROTOBUF GENCODE 4 | # source: watch_output.proto 5 | # Protobuf Python Version: 5.28.2 6 | """Generated protocol buffer code.""" 7 | from google.protobuf import descriptor as _descriptor 8 | from google.protobuf import descriptor_pool as _descriptor_pool 9 | from google.protobuf import runtime_version as _runtime_version 10 | from google.protobuf import symbol_database as _symbol_database 11 | from google.protobuf.internal import builder as _builder 12 | _runtime_version.ValidateProtobufRuntimeVersion( 13 | _runtime_version.Domain.PUBLIC, 14 | 5, 15 | 28, 16 | 2, 17 | '', 18 | 'watch_output.proto' 19 | ) 20 | # @@protoc_insertion_point(imports) 21 | 22 | _sym_db = _symbol_database.Default() 23 | 24 | 25 | import touch_sdk.protobuf.common_pb2 as common__pb2 26 | 27 | 28 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x12watch_output.proto\x1a\x0c\x63ommon.proto\"\xc0\x02\n\x04Info\x12\x18\n\x04hand\x18\x01 \x01(\x0e\x32\n.Info.Hand\x12\r\n\x05\x61ppId\x18\x02 \x01(\t\x12\x12\n\nappVersion\x18\x03 \x01(\t\x12\x1f\n\x0f\x61vailableModels\x18\x04 \x03(\x0b\x32\x06.Model\x12\x1b\n\x0b\x61\x63tiveModel\x18\x05 \x01(\x0b\x32\x06.Model\x12\x11\n\tmodelInfo\x18\x06 \x01(\t\x12\x14\n\x0cmanufacturer\x18\x07 \x01(\t\x12\x12\n\ndeviceName\x18\x08 \x01(\t\x12\x19\n\x11\x62\x61tteryPercentage\x18\t \x01(\x05\x12\x18\n\x10hapticsAvailable\x18\n \x01(\x08\x12$\n\x15touchScreenResolution\x18\x0b \x01(\x0b\x32\x05.Vec2\"%\n\x04Hand\x12\x08\n\x04NONE\x10\x00\x12\t\n\x05RIGHT\x10\x01\x12\x08\n\x04LEFT\x10\x02\"\x9e\x01\n\x0bSensorFrame\x12\x13\n\x04gyro\x18\x01 \x01(\x0b\x32\x05.Vec3\x12\x12\n\x03\x61\x63\x63\x18\x02 \x01(\x0b\x32\x05.Vec3\x12\x13\n\x04grav\x18\x03 \x01(\x0b\x32\x05.Vec3\x12\x13\n\x04quat\x18\x04 \x01(\x0b\x32\x05.Quat\x12\x12\n\x03mag\x18\x06 \x01(\x0b\x32\x05.Vec3\x12\x15\n\x06magCal\x18\x07 \x01(\x0b\x32\x05.Vec3\x12\x11\n\tdeltaTime\x18\x05 \x01(\x05\"8\n\x07Gesture\x12\x1a\n\x04type\x18\x01 \x01(\x0e\x32\x0c.GestureType\x12\x11\n\tdeltaTime\x18\x02 \x01(\x05\"\xd4\x01\n\nTouchEvent\x12-\n\teventType\x18\x01 \x01(\x0e\x32\x1a.TouchEvent.TouchEventType\x12\x13\n\x0b\x61\x63tionIndex\x18\x02 \x01(\x05\x12\x12\n\npointerIds\x18\x03 \x03(\x05\x12\x15\n\x06\x63oords\x18\x04 \x03(\x0b\x32\x05.Vec2\x12\x11\n\tdeltaTime\x18\x05 \x01(\x05\"D\n\x0eTouchEventType\x12\x08\n\x04NONE\x10\x00\x12\t\n\x05\x42\x45GIN\x10\x01\x12\x07\n\x03\x45ND\x10\x02\x12\x08\n\x04MOVE\x10\x03\x12\n\n\x06\x43\x41NCEL\x10\x04\".\n\x0bRotaryEvent\x12\x0c\n\x04step\x18\x01 \x01(\x05\x12\x11\n\tdeltaTime\x18\x02 \x01(\x05\",\n\x0b\x42uttonEvent\x12\n\n\x02id\x18\x01 \x01(\x05\x12\x11\n\tdeltaTime\x18\x02 \x01(\x05\"D\n\x10ProbabilityEntry\x12\x1b\n\x05label\x18\x01 \x01(\x0e\x32\x0c.GestureType\x12\x13\n\x0bprobability\x18\x02 \x01(\x02\"\x9b\x03\n\x06Update\x12\"\n\x0csensorFrames\x18\x01 \x03(\x0b\x32\x0c.SensorFrame\x12\x1a\n\x08gestures\x18\x02 \x03(\x0b\x32\x08.Gesture\x12 \n\x0btouchEvents\x18\x03 \x03(\x0b\x32\x0b.TouchEvent\x12\"\n\x0c\x62uttonEvents\x18\x04 \x03(\x0b\x32\x0c.ButtonEvent\x12\"\n\x0crotaryEvents\x18\x05 \x03(\x0b\x32\x0c.RotaryEvent\x12\x1f\n\x07signals\x18\x06 \x03(\x0e\x32\x0e.Update.Signal\x12\x11\n\tdeltaTime\x18\x07 \x01(\x05\x12\x10\n\x08unixTime\x18\x08 \x01(\x03\x12\x13\n\x04info\x18\t \x01(\x0b\x32\x05.Info\x12(\n\rprobabilities\x18\n \x03(\x0b\x32\x11.ProbabilityEntry\x12\x10\n\x08pressure\x18\x10 \x01(\x02\"P\n\x06Signal\x12\x08\n\x04NONE\x10\x00\x12\x0e\n\nDISCONNECT\x10\x01\x12\x14\n\x10\x43ONNECT_APPROVED\x10\x02\x12\x16\n\x12\x44\x45SCRIPTION_UPDATE\x10\x03\x42\r\xaa\x02\nPsix.Protob\x06proto3') 29 | 30 | _globals = globals() 31 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 32 | _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'watch_output_pb2', _globals) 33 | if not _descriptor._USE_C_DESCRIPTORS: 34 | _globals['DESCRIPTOR']._loaded_options = None 35 | _globals['DESCRIPTOR']._serialized_options = b'\252\002\nPsix.Proto' 36 | _globals['_INFO']._serialized_start=37 37 | _globals['_INFO']._serialized_end=357 38 | _globals['_INFO_HAND']._serialized_start=320 39 | _globals['_INFO_HAND']._serialized_end=357 40 | _globals['_SENSORFRAME']._serialized_start=360 41 | _globals['_SENSORFRAME']._serialized_end=518 42 | _globals['_GESTURE']._serialized_start=520 43 | _globals['_GESTURE']._serialized_end=576 44 | _globals['_TOUCHEVENT']._serialized_start=579 45 | _globals['_TOUCHEVENT']._serialized_end=791 46 | _globals['_TOUCHEVENT_TOUCHEVENTTYPE']._serialized_start=723 47 | _globals['_TOUCHEVENT_TOUCHEVENTTYPE']._serialized_end=791 48 | _globals['_ROTARYEVENT']._serialized_start=793 49 | _globals['_ROTARYEVENT']._serialized_end=839 50 | _globals['_BUTTONEVENT']._serialized_start=841 51 | _globals['_BUTTONEVENT']._serialized_end=885 52 | _globals['_PROBABILITYENTRY']._serialized_start=887 53 | _globals['_PROBABILITYENTRY']._serialized_end=955 54 | _globals['_UPDATE']._serialized_start=958 55 | _globals['_UPDATE']._serialized_end=1369 56 | _globals['_UPDATE_SIGNAL']._serialized_start=1289 57 | _globals['_UPDATE_SIGNAL']._serialized_end=1369 58 | # @@protoc_insertion_point(module_scope) 59 | -------------------------------------------------------------------------------- /src/touch_sdk/stream_watch.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from functools import partial 3 | import binascii 4 | import sys 5 | import asyncio 6 | import asyncio_atexit 7 | from time import sleep 8 | 9 | from touch_sdk.uuids import PROTOBUF_OUTPUT, PROTOBUF_INPUT 10 | from touch_sdk.watch_connector import WatchConnector 11 | 12 | from touch_sdk.protobuf.watch_output_pb2 import Update # type: ignore 13 | import logging 14 | 15 | logger = logging.getLogger(__file__) 16 | 17 | 18 | __doc__ = """Protobuffers streamed in base64 through stdin/stdout""" 19 | 20 | 21 | class StreamWatch: 22 | """Scans Touch SDK compatible Bluetooth LE devices and connects to the first one 23 | of them that approves the connection. 24 | 25 | Watch also parses the data that comes over Bluetooth and returns it through 26 | callback methods.""" 27 | 28 | def __init__(self, name_filter=None, disable_input=False): 29 | """Creates a new instance of StreamWatch. Does not start scanning for Bluetooth 30 | devices. Use Watch.start to enter the scanning and connection event loop. 31 | 32 | Optional name_filter connects only to watches with that name (case insensitive). 33 | If disable_input is true, watch listens to no inputs. 34 | """ 35 | self._connector = WatchConnector( 36 | self._on_approved_connection, self._on_protobuf, name_filter 37 | ) 38 | 39 | self._client = None 40 | self._stop_event = None 41 | self._event_loop = None 42 | self._disable_input = disable_input 43 | 44 | def start(self): 45 | """Blocking event loop that starts the Bluetooth scanner 46 | 47 | More handy than Watch.run when only this event loop is needed.""" 48 | try: 49 | asyncio.run(self.run()) 50 | except KeyboardInterrupt: 51 | logger.debug("interrupted") 52 | pass 53 | 54 | def stop(self): 55 | """Stop the watch, disconnecting any connected devices.""" 56 | logger.debug("stop") 57 | if self._stop_event is not None: 58 | self._stop_event.set() 59 | 60 | async def run(self): 61 | """Asynchronous blocking event loop that starts the Bluetooth scanner. 62 | 63 | Makes it possible to run multiple async event loops with e.g. asyncio.gather.""" 64 | 65 | self._event_loop = asyncio.get_running_loop() 66 | self._stop_event = asyncio.Event() 67 | 68 | asyncio_atexit.register(self.stop) 69 | 70 | await self._connector.start() 71 | if not self._disable_input: 72 | task1 = asyncio.create_task(self._input_loop()) # Wrap coroutines in tasks 73 | task2 = asyncio.create_task(self._wait_and_stop()) 74 | _, pending = await asyncio.wait( 75 | [task2, task1], 76 | return_when=asyncio.FIRST_COMPLETED, 77 | ) 78 | for p in pending: 79 | p.cancel() 80 | else: 81 | await self._wait_and_stop() 82 | 83 | async def _wait_and_stop(self): 84 | assert self._stop_event 85 | await self._stop_event.wait() 86 | await self._connector.stop() 87 | 88 | async def _input_loop(self): 89 | loop = asyncio.get_running_loop() 90 | reader = asyncio.StreamReader() 91 | protocol = asyncio.StreamReaderProtocol(reader) 92 | 93 | # Connect the standard input to the StreamReader protocol 94 | await loop.connect_read_pipe(lambda: protocol, sys.stdin) 95 | 96 | # Read lines from stdin asynchronously 97 | while self._stop_event is not None and not self._stop_event.is_set(): 98 | line = await reader.readline() 99 | line = line.strip() 100 | if line: 101 | self._input(line) 102 | 103 | def _input(self, base64data): 104 | """Write protobuf data to input characteristic""" 105 | try: 106 | self._write_input_characteristic(base64.b64decode(base64data), self._client) 107 | except binascii.Error as e: 108 | logger.error("Decode err: %s", e) 109 | 110 | @staticmethod 111 | def _print_data(data): 112 | sys.stdout.buffer.write(data) 113 | sys.stdout.flush() 114 | 115 | async def _output(self, data): 116 | if self._stop_event and not self._stop_event.is_set(): 117 | data = base64.b64encode(data) + b"\n" 118 | await asyncio.to_thread(partial(StreamWatch._print_data, data)) 119 | 120 | async def _on_protobuf(self, pf: Update): 121 | """Bit simpler to let connector parse and serialize protobuf again 122 | than to override connector behaviour. 123 | """ 124 | logger.debug("_on_protobuf") 125 | await self._output(pf.SerializeToString()) 126 | 127 | async def _on_approved_connection(self, client): 128 | logger.debug("_on_approved_connection") 129 | self._client = client 130 | await self._fetch_info(client) 131 | 132 | async def _fetch_info(self, client): 133 | data = await client.read_gatt_char(PROTOBUF_OUTPUT) 134 | await self._output(data) 135 | 136 | def _write_input_characteristic(self, data, client): 137 | if self._event_loop is not None: 138 | self._event_loop.create_task( 139 | self._async_write_input_characteristic(PROTOBUF_INPUT, data, client) 140 | ) 141 | 142 | async def _async_write_input_characteristic(self, characteristic, data, client): 143 | if client: 144 | await client.write_gatt_char(characteristic, data, True) 145 | 146 | 147 | def main(): 148 | from argparse import ArgumentParser 149 | import signal 150 | 151 | def rs(*_): 152 | raise KeyboardInterrupt() 153 | 154 | signal.signal(signal.SIGTERM, rs) 155 | 156 | parser = ArgumentParser() 157 | parser.add_argument("--name-filter", type=str, default=None) 158 | parser.add_argument("--debug-level", type=int, default=logging.CRITICAL + 1) 159 | parser.add_argument("--disable-input", action="store_true") 160 | args = parser.parse_args() 161 | logging.basicConfig(level=args.debug_level) 162 | 163 | try: 164 | StreamWatch(args.name_filter, args.disable_input).start() 165 | except KeyboardInterrupt: 166 | pass 167 | except Exception as e: 168 | # This is so user can see error in executable created with pynsist 169 | print(f"Error: {e}", file=sys.stderr) 170 | sleep(2) 171 | raise e 172 | 173 | 174 | if __name__ == "__main__": 175 | main() 176 | -------------------------------------------------------------------------------- /src/touch_sdk/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | import struct 3 | from itertools import accumulate, chain, tee 4 | 5 | __doc__ = """Miscellaneous utilities.""" 6 | 7 | 8 | def pairwise(iterable): 9 | """Return successive overlapping pairs taken from the input iterable. 10 | 11 | Rougly equivalent to `itertools.pairwise` in Python >=3.10; implemented here 12 | for Python >=3.8 compatibility. 13 | """ 14 | # pairwise("ABCDEFG") --> AB BC CD DE EF FG 15 | first, second = tee(iterable) 16 | next(second, None) 17 | return zip(first, second) 18 | 19 | 20 | def partial_async(func, *args, **kwargs): 21 | """functools.partial, but for async functions""" 22 | async def wrapped_async(*args_, **kwargs_): 23 | return await func(*args, *args_, **kwargs, **kwargs_) 24 | return wrapped_async 25 | 26 | 27 | def unpack_chained(format_string, data): 28 | """ 29 | Unpack struct data with a format string that may contain multiple 30 | endianness tokens. 31 | 32 | For example, when unpacking 8 bytes of data with the 33 | format string ">f Hand: 96 | """Which hand the device is worn on.""" 97 | return self._hand 98 | 99 | @property 100 | def battery_percentage(self) -> int: 101 | """Last known battery percentage of the device.""" 102 | return self._battery_percentage 103 | 104 | @property 105 | def touch_screen_resolution(self) -> Optional[Tuple[int, int]]: 106 | """Resolution of the touch screen (width, height) in pixels. 107 | None if not fetched, or no touch screen is available.""" 108 | return self._screen_resolution 109 | 110 | @property 111 | def haptics_available(self) -> bool: 112 | """Whether the device supports haptic feedback.""" 113 | return self._haptics_available 114 | 115 | @property 116 | def app_version(self) -> str: 117 | """Version of the software running on the device.""" 118 | return self._app_version 119 | 120 | @property 121 | def app_id(self) -> str: 122 | """Identifier of the software running on the device.""" 123 | return self._app_id 124 | 125 | @property 126 | def device_name(self) -> str: 127 | """Type name of the device.""" 128 | return self._device_name 129 | 130 | @property 131 | def manufacturer(self) -> str: 132 | """Manufacturer of the device.""" 133 | return self._manufacturer 134 | 135 | @property 136 | def model_info(self) -> str: 137 | """Miscellaneous info about the gesture detection model.""" 138 | return self._model_info 139 | 140 | @property 141 | def available_models(self) -> list[set[GestureType]]: 142 | """Available models by predicted gestures""" 143 | return self._available_models 144 | 145 | @property 146 | def active_model(self) -> set[GestureType]: 147 | """Active model by predicted gestures""" 148 | return self._active_model 149 | 150 | def start(self): 151 | """Blocking event loop that starts the Bluetooth scanner 152 | 153 | More handy than Watch.run when only this event loop is needed.""" 154 | try: 155 | asyncio.run(self.run()) 156 | except KeyboardInterrupt: 157 | pass 158 | 159 | def stop(self): 160 | """Stop the watch, disconnecting any connected devices.""" 161 | self._stop_event.set() 162 | 163 | async def run(self): 164 | """Asynchronous blocking event loop that starts the Bluetooth scanner. 165 | 166 | Makes it possible to run multiple async event loops with e.g. asyncio.gather.""" 167 | 168 | self._event_loop = asyncio.get_running_loop() 169 | self._stop_event = asyncio.Event() 170 | 171 | asyncio_atexit.register(self.stop) 172 | 173 | await self._connector.start() 174 | await self._stop_event.wait() 175 | await self._connector.stop() 176 | 177 | def on_sensors(self, sensor_frame: SensorFrame): 178 | """Callback when accelerometer, gyroscope, gravity, orientation, and 179 | magnetic field are changed. Guaranteed to have values for everything but 180 | magnetic field information in every update.""" 181 | 182 | def on_arm_direction_change(self, delta_x: float, delta_y: float): 183 | """Gyroscope-based raycasting output. Called after sensor updates.""" 184 | 185 | def on_pressure(self, pressure: float): 186 | """Called when new pressure value (in hectopascals) is received.""" 187 | 188 | def on_gesture_probability(self, probabilities: dict[GestureType, float]): 189 | """Called when gesture probability is received.""" 190 | 191 | def on_tap(self): 192 | """Called when a pinch tap gesture happens.""" 193 | 194 | def on_gesture(self, gesture: GestureType): 195 | """Called when gesture info is received. May receive GestureType.NONE.""" 196 | 197 | def on_touch_down(self, x: float, y: float): 198 | """Touch screen touch starts.""" 199 | 200 | def on_touch_up(self, x: float, y: float): 201 | """Touch screen touch ends.""" 202 | 203 | def on_touch_move(self, x: float, y: float): 204 | """Touch screen touch moves.""" 205 | 206 | def on_touch_cancel(self, x: float, y: float): 207 | """Touch screen touch becomes a swipe gesture that goes to another view.""" 208 | 209 | def on_back_button(self): 210 | """Back button of the watch is pressed and released. 211 | 212 | Wear OS does not support separate button down and button up events.""" 213 | 214 | def on_rotary(self, direction: int): 215 | """Rotary dial around the watch screen is turned. 216 | 217 | direction: +1 for clockwise, -1 for counterclockwise.""" 218 | 219 | def on_custom_data(self, uuid: str, content: Tuple): 220 | """Receive data from custom characteristics""" 221 | 222 | def on_connect(self): 223 | """Called after watch has connected""" 224 | 225 | def on_info_update(self): 226 | """Called if info properties are updated""" 227 | 228 | def trigger_haptics(self, intensity: float, duration_ms: int): 229 | """Trigger vibration haptics on the watch. 230 | 231 | intensity: between 0 and 1 232 | duration_ms: between 0 and 5000""" 233 | input_update = self._create_haptics_update(intensity, duration_ms) 234 | self._write_input_characteristic(input_update.SerializeToString(), self._client) 235 | 236 | def request_model(self, gestures: set[GestureType]): 237 | model = self._create_model_request(gestures) 238 | self._write_input_characteristic(model.SerializeToString(), self._client) 239 | 240 | # IMPLEMENTATION DETAILS 241 | 242 | @staticmethod 243 | def _protovec2_to_tuple(vec): 244 | return (vec.x, vec.y) 245 | 246 | @staticmethod 247 | def _protovec3_to_tuple(vec): 248 | return (vec.x, vec.y, vec.z) 249 | 250 | @staticmethod 251 | def _protoquat_to_tuple(vec): 252 | return (vec.x, vec.y, vec.z, vec.w) 253 | 254 | async def _on_approved_connection(self, client): 255 | self._client = client 256 | 257 | await self._fetch_info(client) 258 | await self._subscribe_to_custom_characteristics(client) 259 | 260 | self.on_connect() 261 | 262 | async def _fetch_info(self, client): 263 | data = await client.read_gatt_char(PROTOBUF_OUTPUT) 264 | update = Update() 265 | update.ParseFromString(bytes(data)) 266 | if update.HasField("info"): 267 | self._proto_on_info(update.info) 268 | 269 | # Custom characteristics 270 | 271 | async def _subscribe_to_custom_characteristics(self, client): 272 | if self.custom_data is None: 273 | return 274 | 275 | subscriptions = [ 276 | client.start_notify(uuid, self._on_custom_data) for uuid in self.custom_data 277 | ] 278 | await asyncio.gather(*subscriptions) 279 | 280 | async def _on_custom_data(self, characteristic, data): 281 | format_string = (self.custom_data or {}).get(characteristic.uuid) 282 | 283 | if format_string is None: 284 | return 285 | 286 | content = unpack_chained(format_string, data) 287 | 288 | self.on_custom_data(characteristic.uuid, content) 289 | 290 | async def _on_protobuf(self, message): 291 | # Main protobuf characteristic 292 | probs = {k.label: k.probability for k in message.probabilities} 293 | self.on_gesture_probability(probs) 294 | 295 | self._proto_on_sensors(message.sensorFrames, message.unixTime) 296 | self._proto_on_gestures(message.gestures) 297 | self._proto_on_touch_events(message.touchEvents) 298 | self._proto_on_button_events(message.buttonEvents) 299 | self._proto_on_rotary_events(message.rotaryEvents) 300 | 301 | if message.HasField("info"): 302 | self._proto_on_info(message.info) 303 | 304 | if message.pressure != 0.0: 305 | self.on_pressure(message.pressure) 306 | 307 | def _proto_on_sensors(self, frames, timestamp): 308 | # Sensor events 309 | frame = frames[-1] 310 | sensor_frame = SensorFrame( 311 | acceleration=Watch._protovec3_to_tuple(frame.acc), 312 | gravity=Watch._protovec3_to_tuple(frame.grav), 313 | angular_velocity=Watch._protovec3_to_tuple(frame.gyro), 314 | orientation=Watch._protoquat_to_tuple(frame.quat), 315 | magnetic_field=( 316 | Watch._protovec3_to_tuple(frame.mag) if frame.HasField("mag") else None 317 | ), 318 | magnetic_field_calibration=( 319 | Watch._protovec3_to_tuple(frame.magCal) 320 | if frame.HasField("magCal") 321 | else None 322 | ), 323 | timestamp=timestamp, 324 | ) 325 | self.on_sensors(sensor_frame) 326 | self._on_arm_direction_change(sensor_frame) 327 | 328 | def _on_arm_direction_change(self, sensor_frame: SensorFrame): 329 | def normalize(vector): 330 | length = sum(x * x for x in vector) ** 0.5 331 | return [x / length for x in vector] 332 | 333 | grav = normalize(sensor_frame.gravity) 334 | 335 | av_x = -sensor_frame.angular_velocity[2] # right = + 336 | av_y = -sensor_frame.angular_velocity[1] # down = + 337 | 338 | handedness_scale = -1 if self._hand == Hand.LEFT else 1 339 | 340 | delta_x = av_x * grav[2] + av_y * grav[1] 341 | delta_y = handedness_scale * (av_y * grav[2] - av_x * grav[1]) 342 | 343 | self.on_arm_direction_change(delta_x, delta_y) 344 | 345 | def _proto_on_gestures(self, gestures): 346 | # Gestures 347 | if not gestures: 348 | self.on_gesture(GestureType.NONE) 349 | else: 350 | for g in gestures: 351 | if g.type == GestureType.PINCH_TAP: 352 | self.on_tap() 353 | 354 | self.on_gesture(GestureType(g.type)) 355 | 356 | def _proto_on_touch_events(self, touch_events): 357 | # Touch screen 358 | for touch in touch_events: 359 | coords = Watch._protovec2_to_tuple(touch.coords[0]) 360 | if touch.eventType == TouchEvent.TouchEventType.BEGIN: 361 | self.on_touch_down(*coords) 362 | elif touch.eventType == TouchEvent.TouchEventType.END: 363 | self.on_touch_up(*coords) 364 | elif touch.eventType == TouchEvent.TouchEventType.MOVE: 365 | self.on_touch_move(*coords) 366 | elif touch.eventType == TouchEvent.TouchEventType.CANCEL: 367 | self.on_touch_cancel(*coords) 368 | 369 | def _proto_on_button_events(self, buttons): 370 | # Button 371 | if any(b.id == 0 for b in buttons): 372 | self.on_back_button() 373 | 374 | def _proto_on_rotary_events(self, rotary_events): 375 | # Rotary 376 | for rotary in rotary_events: 377 | self.on_rotary(-rotary.step) 378 | 379 | def _proto_on_info(self, info): 380 | # Info 381 | if battery_percentage := info.batteryPercentage: 382 | self._battery_percentage = battery_percentage 383 | 384 | if (hand := Hand(info.hand)) != Hand.NONE: 385 | self._hand = hand 386 | else: 387 | # If hand is not present, only battery is updated 388 | return 389 | 390 | if (screen_resolution := info.touchScreenResolution) is not None: 391 | self._screen_resolution = (screen_resolution.x, screen_resolution.y) 392 | 393 | if haptics_available := info.hapticsAvailable: 394 | self._haptics_available = haptics_available 395 | 396 | if app_version := info.appVersion: 397 | self._app_version = app_version 398 | 399 | if app_id := info.appId: 400 | self._app_id = app_id 401 | 402 | if device_name := info.deviceName: 403 | self._device_name = device_name 404 | 405 | if manufacturer := info.manufacturer: 406 | self._manufacturer = manufacturer 407 | 408 | if active_model := info.activeModel: 409 | self._active_model = set( 410 | map(lambda g: GestureType(g), active_model.gestures) 411 | ) 412 | 413 | if available_models := info.availableModels: 414 | self._available_models = [ 415 | set(map(lambda g: GestureType(g), g.gestures)) for g in available_models 416 | ] 417 | 418 | if model_info := info.modelInfo: 419 | self._model_info = model_info 420 | 421 | self.on_info_update() 422 | 423 | @staticmethod 424 | def _create_haptics_update(intensity, length): 425 | # Haptics 426 | clamped_intensity = min(max(intensity, 0.0), 1.0) 427 | clamped_length = min(max(int(length), 0), 5000) 428 | haptic_event = HapticEvent() 429 | haptic_event.type = HapticEvent.HapticType.ONESHOT 430 | haptic_event.length = clamped_length 431 | haptic_event.intensity = clamped_intensity 432 | input_update = InputUpdate() 433 | input_update.hapticEvent.CopyFrom(haptic_event) 434 | return input_update 435 | 436 | @staticmethod 437 | def _create_model_request(gestures: set[GestureType]): 438 | model = Model() 439 | model.gestures.extend(int(g) for g in gestures) 440 | input_update = InputUpdate() 441 | input_update.modelRequest.CopyFrom(model) 442 | return input_update 443 | 444 | def _write_input_characteristic(self, data, client): 445 | if self._event_loop is not None: 446 | self._event_loop.create_task( 447 | self._async_write_input_characteristic(PROTOBUF_INPUT, data, client) 448 | ) 449 | 450 | async def _async_write_input_characteristic(self, characteristic, data, client): 451 | if client: 452 | await client.write_gatt_char(characteristic, data, True) 453 | -------------------------------------------------------------------------------- /src/touch_sdk/watch_connector.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | import platform 4 | import logging 5 | 6 | import bleak 7 | from bleak import BleakClient 8 | 9 | from touch_sdk.utils import partial_async 10 | from touch_sdk.uuids import PROTOBUF_OUTPUT, PROTOBUF_INPUT, INTERACTION_SERVICE 11 | from touch_sdk.gatt_scanner import GattScanner 12 | 13 | # pylint: disable=no-name-in-module 14 | from touch_sdk.protobuf.watch_output_pb2 import Update 15 | from touch_sdk.protobuf.watch_input_pb2 import InputUpdate, ClientInfo 16 | 17 | logger = logging.getLogger(__file__) 18 | 19 | 20 | __doc__ = """Discovering Touch SDK compatible BLE devices and interfacing with them.""" 21 | 22 | 23 | class WatchConnector: 24 | """Manages connections to watches. 25 | 26 | Handles the connection lifecycle of any number of watches, including: 27 | - connecting 28 | - getting data 29 | - handling connection approval 30 | - disconnecting (either soft or hard) 31 | 32 | Passes data from an approved connection to a callback (usually provided to it by a 33 | Watch instance). 34 | 35 | Discovering the Bluetooth devices is delegated to a GattScanner instance. 36 | """ 37 | 38 | def __init__(self, on_approved_connection, on_message, name_filter=None): 39 | """Creates a new instance of WatchConnector. Does not start scanning for Bluetooth 40 | devices. Use WatchConnector.run to enter the scanning and connection event loop. 41 | 42 | Optional name_filter connects only to watches with that name (case insensitive) 43 | """ 44 | self._scanner = GattScanner( 45 | self._on_scan_result, INTERACTION_SERVICE, name_filter 46 | ) 47 | self._approved_addresses = set() 48 | self._informed_addresses = ( 49 | set() 50 | ) # Bluetooth addresses to which client info has successfully been sent 51 | self._tasks = set() 52 | self._clients = {} 53 | self._on_approved_connection = on_approved_connection 54 | self._on_message = on_message 55 | 56 | async def start(self): 57 | """Asynchronous blocking event loop that starts the Bluetooth scanner and connection loop. 58 | 59 | Makes it possible to run multiple async event loops with e.g. asyncio.gather.""" 60 | await self._start_connection_monitor() 61 | await self._scanner.start() 62 | 63 | async def stop(self): 64 | """Stop the connector, disconnecting any connected devices.""" 65 | await self._scanner.stop_scanning() 66 | disconnect_tasks = [ 67 | self._disconnect(address, resume=False) 68 | for address, client in self._clients.items() 69 | ] 70 | await asyncio.gather(*disconnect_tasks) 71 | 72 | async def _start_connection_monitor(self): 73 | loop = asyncio.get_running_loop() 74 | task = loop.create_task(self._monitor_connections()) 75 | self._tasks.add(task) 76 | task.add_done_callback(self._tasks.discard) 77 | 78 | async def _monitor_connections(self): 79 | while True: 80 | # Make sure disconnect is called for all clients for which 81 | # is_connected is False (because of a physical disconnect, for example) 82 | disconnect_tasks = [ 83 | self._disconnect(address) 84 | for address, client in self._clients.items() 85 | if not client.is_connected 86 | ] 87 | 88 | await asyncio.gather(*disconnect_tasks) 89 | await asyncio.sleep(2) 90 | 91 | async def _on_scan_result(self, device, name): 92 | client = BleakClient(device) 93 | address = device.address 94 | 95 | try: 96 | await client.connect() 97 | except asyncio.exceptions.TimeoutError: 98 | await self._disconnect(address) 99 | return 100 | 101 | self._clients[address] = client 102 | 103 | await self._send_client_info(client) 104 | 105 | try: 106 | await client.start_notify( 107 | PROTOBUF_OUTPUT, partial_async(self._on_protobuf, device, name) 108 | ) 109 | except bleak.exc.BleakDBusError: 110 | # [org.bluez.Error.NotConnected] Not Connected 111 | # 112 | # Sometimes (~50%) Bleak thinks the client is connected even though 113 | # BlueZ thinks it's not. We could try to reconnect, but that messes 114 | # up with _monitor_connections. Easier to just give up and try again 115 | # through the scanner, even though it adds a delay and a bit of 116 | # noise to the console. 117 | logger.info("Connecting failed, trying again") 118 | await self._disconnect(address) 119 | 120 | async def _disconnect(self, address, resume=True): 121 | if (client := self._clients.pop(address, None)) is not None: 122 | await client.disconnect() 123 | 124 | self._approved_addresses.discard(address) 125 | self._scanner.forget_address(address) 126 | 127 | if not self._approved_addresses and resume: 128 | await self._scanner.start_scanning() 129 | 130 | async def _on_protobuf(self, device, name, _, data): 131 | message = Update() 132 | message.ParseFromString(bytes(data)) 133 | 134 | # Watch sent a disconnect signal. Might be because the user pressed "no" 135 | # from the connection dialog on the watch (was not connected to begin with), 136 | # or because the watch app is exiting / user pressed "forget devices" 137 | if any(s == Update.Signal.DISCONNECT for s in message.signals): 138 | await self._handle_disconnect_signal(device, name) 139 | 140 | # Watch sent some other data, but no disconnect signal = watch accepted 141 | # the connection 142 | else: 143 | await self._handle_approved_connection(device, name) 144 | await self._on_message(message) 145 | 146 | async def _handle_approved_connection(self, device, name): 147 | if device.address in self._approved_addresses: 148 | return 149 | self._approved_addresses.add(device.address) 150 | 151 | if (client := self._clients.get(device.address)) is not None: 152 | logger.info(f"Connection approved by {name}") 153 | await self._scanner.stop_scanning() 154 | 155 | disconnect_tasks = [ 156 | self._disconnect(address) 157 | for address in self._clients 158 | if address != device.address 159 | ] 160 | 161 | await asyncio.gather(*disconnect_tasks) 162 | 163 | try: 164 | await self._on_approved_connection(client) 165 | except bleak.exc.BleakDBusError as _: 166 | # Catches "Unlikely GATT error" 167 | await self._disconnect(device.address) 168 | 169 | async def _handle_disconnect_signal(self, device, name): 170 | logger.warning(f"Connection declined from {name}") 171 | await self._disconnect(device.address) 172 | 173 | async def _send_client_info(self, client): 174 | if client.address in self._informed_addresses: 175 | return 176 | 177 | client_info = ClientInfo() 178 | client_info.appName = sys.argv[0] 179 | client_info.deviceName = platform.node() 180 | client_info.os = platform.system() 181 | input_update = InputUpdate() 182 | input_update.clientInfo.CopyFrom(client_info) 183 | 184 | try: 185 | await client.write_gatt_char( 186 | PROTOBUF_INPUT, input_update.SerializeToString(), True 187 | ) 188 | except bleak.exc.BleakDBusError: 189 | # [org.bluez.Error.Failed] Operation failed with ATT error: 190 | # 0x01 (Invalid Handle) 191 | # 192 | # This happens if the client is not already approved by the 193 | # watch before this connection (= connection dialog shows up). 194 | # However, the client info seems to still end up to the watch, 195 | # so we don't need to abort the handshake. 196 | pass 197 | except bleak.exc.BleakError: 198 | # macOS says "Failed to write characteristic" but it succeeds 199 | pass 200 | 201 | self._informed_addresses.add(client.address) 202 | --------------------------------------------------------------------------------