tap-python-sdk 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tap_python_sdk-0.7.0.dist-info/METADATA +403 -0
- tap_python_sdk-0.7.0.dist-info/RECORD +11 -0
- tap_python_sdk-0.7.0.dist-info/WHEEL +5 -0
- tap_python_sdk-0.7.0.dist-info/licenses/LICENSE +11 -0
- tap_python_sdk-0.7.0.dist-info/top_level.txt +1 -0
- tapsdk/__init__.py +10 -0
- tapsdk/__version__.py +1 -0
- tapsdk/enumerations.py +58 -0
- tapsdk/inputmodes.py +71 -0
- tapsdk/parsers.py +74 -0
- tapsdk/tap.py +469 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tap-python-sdk
|
|
3
|
+
Version: 0.7.0
|
|
4
|
+
Summary: Tap strap python sdk
|
|
5
|
+
Home-page: https://github.com/TapWithUs/tap-python-sdk
|
|
6
|
+
Author: Tap systems Inc.
|
|
7
|
+
Author-email: support@tapwithus.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: bleak==0.6.4; platform_system == "Linux"
|
|
13
|
+
Requires-Dist: bleak==0.12.1; platform_system == "Darwin"
|
|
14
|
+
Requires-Dist: bleak==0.22.3; platform_system == "Windows"
|
|
15
|
+
Requires-Dist: bleak-winrt==1.2.0; platform_system == "Windows"
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest; extra == "dev"
|
|
18
|
+
Requires-Dist: flake8; extra == "dev"
|
|
19
|
+
Dynamic: author
|
|
20
|
+
Dynamic: author-email
|
|
21
|
+
Dynamic: description
|
|
22
|
+
Dynamic: description-content-type
|
|
23
|
+
Dynamic: home-page
|
|
24
|
+
Dynamic: license
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
Dynamic: provides-extra
|
|
27
|
+
Dynamic: requires-dist
|
|
28
|
+
Dynamic: requires-python
|
|
29
|
+
Dynamic: summary
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
## TapStrap Python SDK (beta)
|
|
33
|
+
|
|
34
|
+
### What Is This ?
|
|
35
|
+
|
|
36
|
+
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!
|
|
37
|
+
The library is developed with Python >= 3.9 and is **currently in beta**.
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
### Supported Platforms
|
|
41
|
+
This package supports the following platforms:
|
|
42
|
+
* 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.
|
|
43
|
+
* Windows 10 - using [Bleak](https://github.com/hbldh/bleak) with WinRT for BLE connectivity (no external DLL required).
|
|
44
|
+
* Linux (tested on Ubuntu 18.04) - need to install libbluetooth-dev and bluez-tools
|
|
45
|
+
```
|
|
46
|
+
sudo apt-get install bluez-tools libbluetooth-dev
|
|
47
|
+
```
|
|
48
|
+
also the user needs to be in the bluetooth group:
|
|
49
|
+
```
|
|
50
|
+
sudo usermod -G bluetooth -a <username>
|
|
51
|
+
#and can reload groups in this shell by running the following command or by logging out and back in:
|
|
52
|
+
su - $USER
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
### Installation
|
|
57
|
+
|
|
58
|
+
Install the package from PyPI:
|
|
59
|
+
```console
|
|
60
|
+
pip install tap-python-sdk
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or clone this repo and install the package locally:
|
|
64
|
+
```console
|
|
65
|
+
git clone https://github.com/TapWithUs/tap-python-sdk
|
|
66
|
+
cd tap-python-sdk
|
|
67
|
+
pip install .
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
The SDK is asyncio-based. Pair your Tap device with the OS first, then connect and register callbacks inside an async entry point:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
import asyncio
|
|
75
|
+
from tapsdk import TapSDK
|
|
76
|
+
|
|
77
|
+
async def main():
|
|
78
|
+
tap_device = TapSDK()
|
|
79
|
+
tap_device.register_tap_events(lambda identifier, tapcode: print(identifier, tapcode))
|
|
80
|
+
await tap_device.run() # connects to a paired Tap, or scans until one is found
|
|
81
|
+
|
|
82
|
+
asyncio.run(main())
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
If no Tap is already connected at the OS level, `run()` will scan and wait for a device. On Windows and MacOS it also polls for already-paired devices that reconnect without advertising.
|
|
86
|
+
|
|
87
|
+
Also make sure that you have updated your Tap device to the latest version.
|
|
88
|
+
|
|
89
|
+
### Features
|
|
90
|
+
|
|
91
|
+
This SDK implements two basic interfaces with a Tap device.
|
|
92
|
+
|
|
93
|
+
First is setting the operation mode:
|
|
94
|
+
|
|
95
|
+
1. *Text mode* - the Tap device will operate normally, with no events being sent to the SDK
|
|
96
|
+
2. *Controller mode* - the Tap device will send events to the SDK
|
|
97
|
+
3. *Controller and Text mode* - the Tap device will operate normally, in parallel with sending events to the SDK
|
|
98
|
+
4. *Raw data mode* - the Tap device will stream raw sensors data to the SDK.
|
|
99
|
+
|
|
100
|
+
Second, subscribing to the following events:
|
|
101
|
+
|
|
102
|
+
1. *Tap event* - whenever a tap event has occurred
|
|
103
|
+
2. *Mouse event* - whenever a mouse movement has occurred
|
|
104
|
+
3. *AirGesture event* - whenever one of the gestures is detected
|
|
105
|
+
4. *Raw data* - whenever new raw data sample is being made.
|
|
106
|
+
|
|
107
|
+
Additional to these functional events, there are also some state events, such as connection and disconnection of Tap devices to the SDK backend.
|
|
108
|
+
|
|
109
|
+
#### Spatial Control - NEW
|
|
110
|
+
Authorized developers can gain access to the experimental Spatial Control features:
|
|
111
|
+
1. Extended AirGesture state - enabling aggregation for pinch, drag and swipe gestures.
|
|
112
|
+
2. Select input type - enabling the selection of input type to be activated - i.e. AirMouse/Tapping.
|
|
113
|
+
|
|
114
|
+
These features are only available on TapXR and only for qualified developers. Request access [here](https://www.tapwithus.com/contact-us/)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
### Migration from 0.6.x
|
|
118
|
+
|
|
119
|
+
If you are upgrading from an earlier release, note these breaking changes:
|
|
120
|
+
|
|
121
|
+
* `TapInputMode("controller")` → `InputModeController()` (and similarly for other modes). Import from `tapsdk`.
|
|
122
|
+
* `TapInputMode("raw", sensitivity=[2, 1, 4])` → `InputModeRaw(finger_accl_sens=..., imu_gyro_sens=..., imu_accl_sens=...)`. Use the typed enums in `tapsdk.enumerations`.
|
|
123
|
+
* `from tapsdk.models import AirGestures` → `from tapsdk import AirGestures`
|
|
124
|
+
* The `loop=` constructor argument has been removed.
|
|
125
|
+
* Event registration (`register_*`) is synchronous; call it before `await tap_device.run()`.
|
|
126
|
+
* Commands (`set_input_mode`, `set_input_type`, `send_vibration_sequence`) are async and must be awaited.
|
|
127
|
+
* OS-specific examples (`example_unix.py`, `example_win.py`) have been replaced by a single cross-platform `examples/basic.py`.
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
### High level API
|
|
131
|
+
The SDK uses callbacks to implement user functions on the various events. To register a callback, you just have to instance a TapSDK object and:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
def on_tap_event(identifier, tapcode):
|
|
135
|
+
print(identifier + " tapped " + str(tapcode))
|
|
136
|
+
|
|
137
|
+
tap_device.register_tap_events(on_tap_event)
|
|
138
|
+
```
|
|
139
|
+
#### Commands list
|
|
140
|
+
1. ```set_input_mode(self, input_mode:InputMode, identifier=None):```
|
|
141
|
+
This function sends a mode selection command. It accepts an object of type ```InputMode``` such as ```InputModeText```, ```InputModeController```, ```InputModeControllerText```, or ```InputModeRaw```.
|
|
142
|
+
For example:
|
|
143
|
+
```python
|
|
144
|
+
from tapsdk import InputModeController
|
|
145
|
+
await tap_device.set_input_mode(InputModeController())
|
|
146
|
+
```
|
|
147
|
+
For raw sensors mode, you can specify sensitivity and scaling:
|
|
148
|
+
```python
|
|
149
|
+
from tapsdk import InputModeRaw
|
|
150
|
+
from tapsdk.enumerations import FingerAcclSensitivity, ImuGyroSensitivity, ImuAcclSensitivity
|
|
151
|
+
await tap_device.set_input_mode(InputModeRaw(
|
|
152
|
+
scaled=True,
|
|
153
|
+
finger_accl_sens=FingerAcclSensitivity.G4,
|
|
154
|
+
imu_gyro_sens=ImuGyroSensitivity.DPS250,
|
|
155
|
+
imu_accl_sens=ImuAcclSensitivity.G4
|
|
156
|
+
))
|
|
157
|
+
```
|
|
158
|
+
2. ```set_input_type(self, input_type:InputType, identifier=None):```
|
|
159
|
+
> **Only for TapXR and with Spatial Control experimental firmware**
|
|
160
|
+
|
|
161
|
+
This function sends a command to force input type. It accepts an enum of type ```InputType``` initialized with any of the types ```InputType.MOUSE```, ```InputType.KEYBOARD```, or ```InputType.AUTO```.
|
|
162
|
+
For example:
|
|
163
|
+
```python
|
|
164
|
+
from tapsdk import InputType
|
|
165
|
+
await tap_device.set_input_type(InputType.AUTO)
|
|
166
|
+
```
|
|
167
|
+
This will set the input to be automatically selected by the Tap device, based on hand posture.
|
|
168
|
+
|
|
169
|
+
3. ```send_vibration_sequence(self, sequence:list, identifier=None):```
|
|
170
|
+
This function sends a series of haptic activations. ```sequence``` is a list of integers indicating the activation and delay periods one after another. The periods are in millisecond units, in the range of [10,2550] and in resolution of 10ms. Each haptic command supports up to 18 period definitions (i.e. 9 haptics + delay pairs).
|
|
171
|
+
For example:
|
|
172
|
+
```python
|
|
173
|
+
await tap_device.send_vibration_sequence(sequence=[1000,300,200])
|
|
174
|
+
```
|
|
175
|
+
will trigger a 1s haptic, followed by 300ms delay, followed by 200ms haptic.
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
#### Events list
|
|
179
|
+
1. ```register_connection_events(self, listener:Callable):```
|
|
180
|
+
Register callback to a Tap strap connection event.
|
|
181
|
+
```python
|
|
182
|
+
def on_connect(tap_sdk_instance):
|
|
183
|
+
print("Connected to Tap device")
|
|
184
|
+
|
|
185
|
+
tap_device.register_connection_events(on_connect)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
2. ```register_disconnection_events(self, listener:Callable):```
|
|
189
|
+
Register callback to a Tap strap disconnection event.
|
|
190
|
+
```python
|
|
191
|
+
def on_disconnect(client):
|
|
192
|
+
print("Tap device disconnected")
|
|
193
|
+
|
|
194
|
+
tap_device.register_disconnection_events(on_disconnect)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
3. ```register_tap_events(self, listener:Callable):```
|
|
198
|
+
Register callback to a tap event.
|
|
199
|
+
```python
|
|
200
|
+
def on_tap_event(identifier, tapcode):
|
|
201
|
+
print(identifier + " - tapped " + str(tapcode))
|
|
202
|
+
|
|
203
|
+
tap_device.register_tap_events(on_tap_event)
|
|
204
|
+
```
|
|
205
|
+
```tapcode``` is an 8-bit unsigned number, between 1 and 31 which is formed by a binary representation of the fingers that are tapped.
|
|
206
|
+
The LSb is thumb finger, the MSb is the pinky finger.
|
|
207
|
+
For example: if combination equals 5 - its binary form is 10100 - means that the thumb and the middle fingers were tapped.
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
4. ```register_mouse_events(self, listener:Callable):```
|
|
211
|
+
Register callback to a mouse or air mouse movement event.
|
|
212
|
+
```python
|
|
213
|
+
def on_mouse_event(identifier, vx, vy, proximity):
|
|
214
|
+
print(identifier + " - moused: %d, %d" %(vx, vy))
|
|
215
|
+
|
|
216
|
+
tap_device.register_mouse_events(on_mouse_event)
|
|
217
|
+
```
|
|
218
|
+
```vx``` and ```vy``` are the horizontal and vertical velocities of the mouse movement respectively.
|
|
219
|
+
```proximity``` is a boolean that indicates proximity with a surface.
|
|
220
|
+
5. ```register_raw_data_events(self, listener:Callable):```
|
|
221
|
+
Register callback to raw sensors data packet received event.
|
|
222
|
+
```python
|
|
223
|
+
def on_raw_sensor_data(identifier, packets):
|
|
224
|
+
for packet in packets:
|
|
225
|
+
print(identifier, packet["type"], packet["ts"], packet["payload"])
|
|
226
|
+
|
|
227
|
+
tap_device.register_raw_data_events(on_raw_sensor_data)
|
|
228
|
+
```
|
|
229
|
+
The callback receives a list of dicts, each with keys `type` (`"imu"` or `"accl"`), `ts` (millisecond timestamp), and `payload` (list of sample values). When `InputModeRaw(scaled=True)` is active, values are in mg and mdps.
|
|
230
|
+
You'll find more information on that mode in the dedicated section below or [here](https://tapwithus.atlassian.net/wiki/spaces/TD/pages/792002574/Tap+Strap+Raw+Sensors+Mode).
|
|
231
|
+
|
|
232
|
+
6. ```register_air_gesture_events(self, listener:Callable):```
|
|
233
|
+
Register callback to air gesture events.
|
|
234
|
+
```python
|
|
235
|
+
from tapsdk import AirGestures
|
|
236
|
+
|
|
237
|
+
def on_airgesture(identifier, gesture):
|
|
238
|
+
print(identifier + " - gesture: " + str(AirGestures(gesture)))
|
|
239
|
+
|
|
240
|
+
tap_device.register_air_gesture_events(on_airgesture)
|
|
241
|
+
```
|
|
242
|
+
```gesture``` is an integer code of the air gesture detected. The air gesture values are enumerated in the ```AirGestures``` class, including directional gestures (`UP_ONE_FINGER`, `PINCH`, etc.), thumb gestures (`THUMB_FINGER`, `THUMB_MIDDLE`), and spatial state gestures (`STATE_OPEN`, `STATE_FIST`, etc.).
|
|
243
|
+
|
|
244
|
+
7. ```register_air_gesture_state_events(self, listener:Callable):```
|
|
245
|
+
Register callback to mouse-mode state changes (e.g. air mouse, optical mouse).
|
|
246
|
+
```python
|
|
247
|
+
from tapsdk.enumerations import MouseModes
|
|
248
|
+
|
|
249
|
+
def on_mouse_mode_change(identifier, mouse_mode):
|
|
250
|
+
print(identifier + " - mode: " + str(mouse_mode))
|
|
251
|
+
|
|
252
|
+
tap_device.register_air_gesture_state_events(on_mouse_mode_change)
|
|
253
|
+
```
|
|
254
|
+
```mouse_mode``` is a ```MouseModes``` enum value: `STDBY`, `AIR_MOUSE`, `OPTICAL1`, or `OPTICAL2`.
|
|
255
|
+
|
|
256
|
+
### Raw sensors mode
|
|
257
|
+
|
|
258
|
+
**Make sure that "Developer mode" is enabled on TapManager app for this mode to work properly**
|
|
259
|
+
|
|
260
|
+
In raw sensors mode, the Tap device continuously sends raw data from the following sensors:
|
|
261
|
+
1. Five 3-axis accelerometers - one per each finger (**available only on TAP Strap and Tap Strap 2**).
|
|
262
|
+
* sampled at 200Hz
|
|
263
|
+
* allows dynamic range configuration (±2G, ±4G, ±8G, ±16G)
|
|
264
|
+
2. IMU (3-axis accelerometer + gyro) located on the thumb (**available on TAP Strap 2 and TapXR**).
|
|
265
|
+
* sampled at 208Hz.
|
|
266
|
+
* allows dynamic range configuration for the accelerometer (±2G, ±4G, ±8G, ±16G) and for the gyro (±125dps, ±250dps, ±500dps, ±1000dps, ±2000dps).
|
|
267
|
+
|
|
268
|
+
The sensors measurements are given with respect to the reference system below.
|
|
269
|
+
|
|
270
|
+

|
|
271
|
+

|
|
272
|
+
|
|
273
|
+
Each sample (of accelerometer or imu) is preambled with a millisecond timestamp, referenced to an internal Tap clock.
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
The dynamic range of the sensors is determined with the ```set_input_mode``` method by passing an ```InputModeRaw``` instance with the desired sensitivity enums, and a boolean flag indicating if the data should be scaled to mg and mdps for the accelerometer and gyro respectively:
|
|
277
|
+
```python
|
|
278
|
+
from tapsdk import InputModeRaw
|
|
279
|
+
from tapsdk.enumerations import FingerAcclSensitivity, ImuGyroSensitivity, ImuAcclSensitivity
|
|
280
|
+
|
|
281
|
+
await tap_device.set_input_mode(InputModeRaw(
|
|
282
|
+
scaled=True,
|
|
283
|
+
finger_accl_sens=FingerAcclSensitivity.G4,
|
|
284
|
+
imu_gyro_sens=ImuGyroSensitivity.DPS250,
|
|
285
|
+
imu_accl_sens=ImuAcclSensitivity.G4
|
|
286
|
+
))
|
|
287
|
+
```
|
|
288
|
+
Refer to `FingerAcclSensitivity`, `ImuGyroSensitivity`, and `ImuAcclSensitivity` in [`tapsdk.enumerations`](tapsdk/enumerations.py) for the available sensitivity values.
|
|
289
|
+
|
|
290
|
+
### Examples
|
|
291
|
+
|
|
292
|
+
You can find some examples in the [examples folder](examples).
|
|
293
|
+
|
|
294
|
+
### Testing
|
|
295
|
+
|
|
296
|
+
To run the tests, first install the development dependencies:
|
|
297
|
+
|
|
298
|
+
```bash
|
|
299
|
+
pip install .[dev]
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Then run the tests using pytest:
|
|
303
|
+
|
|
304
|
+
```bash
|
|
305
|
+
pytest
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
### Known Issues
|
|
310
|
+
See [History.md](History.md) for release notes. No known issues at 0.7.0.
|
|
311
|
+
|
|
312
|
+
### Support
|
|
313
|
+
|
|
314
|
+
Please refer to the issues tab! :)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# History
|
|
318
|
+
|
|
319
|
+
## Releasing
|
|
320
|
+
|
|
321
|
+
PyPI releases are published automatically when a version tag is pushed to GitHub.
|
|
322
|
+
|
|
323
|
+
1. Bump `__version__` in `tapsdk/__version__.py` and update this file.
|
|
324
|
+
2. Merge the release changes into `develop`, then into `master` as needed.
|
|
325
|
+
3. Create and push an annotated tag whose name matches the package version (for example `v0.7.0`):
|
|
326
|
+
|
|
327
|
+
```bash
|
|
328
|
+
git tag -a v0.7.0 -m "Release 0.7.0"
|
|
329
|
+
git push origin v0.7.0
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
The `Publish to PyPI` workflow runs the same lint and test matrix as CI, verifies that the tag (without the `v` prefix) matches `tapsdk.__version__`, builds the package with `python -m build`, and uploads it to PyPI using Trusted Publishing.
|
|
333
|
+
|
|
334
|
+
Maintainers must configure PyPI Trusted Publishing for the `tap-python-sdk` project name, the `TapWithUs/tap-python-sdk` repository, and a GitHub `pypi` environment before the first automated release.
|
|
335
|
+
|
|
336
|
+
## 0.7.0 (2026-06-09)
|
|
337
|
+
______________________
|
|
338
|
+
### Main features
|
|
339
|
+
|
|
340
|
+
* Unified cross-platform implementation in `tapsdk/tap.py` (removed separate posix/dotnet backends).
|
|
341
|
+
* Windows rewritten to use Bleak/WinRT instead of TAPWin.dll.
|
|
342
|
+
* `InputMode` API: `TapInputMode("…")` replaced by `InputModeText`, `InputModeController`, `InputModeControllerText`, `InputModeRaw`.
|
|
343
|
+
* Raw mode: typed sensitivity enums and optional scaling to mg/mdps (`scaled=True`).
|
|
344
|
+
* Connection and disconnection events implemented on all platforms.
|
|
345
|
+
* Windows: BLE scan and reconnect polling for paired devices.
|
|
346
|
+
* Python requirement raised to 3.9+.
|
|
347
|
+
* CI: cross-platform pytest and flake8.
|
|
348
|
+
* New `AirGestures` values (thumb and state gestures).
|
|
349
|
+
|
|
350
|
+
### Breaking changes
|
|
351
|
+
|
|
352
|
+
* Removed `TapInputMode`, `loop` constructor argument, `tapsdk.models`, and OS-specific examples.
|
|
353
|
+
* Windows no longer uses bundled TAPWin.dll.
|
|
354
|
+
|
|
355
|
+
## 0.6.0 (2024-07-04)
|
|
356
|
+
______________________
|
|
357
|
+
### Main features
|
|
358
|
+
|
|
359
|
+
* Added Spatial features for TapXR.
|
|
360
|
+
* Mac and Linux backends unified to posix backend.
|
|
361
|
+
|
|
362
|
+
### Known Issues
|
|
363
|
+
* Windows backend -
|
|
364
|
+
* Raw sensor data rate might be lower than expected.
|
|
365
|
+
* Sometimes a Tap strap wouldn't be detected upon connection. In this case try restarting your Tap and/or the Python application. In worst case scenario re-pair your Tap.
|
|
366
|
+
* Spatial features are still not available for Windows backend.
|
|
367
|
+
* MacOS & Linux backends -
|
|
368
|
+
* Doesn't support multiple Tap strap connections.
|
|
369
|
+
* OnConnect and OnDisconnect events are not implemented
|
|
370
|
+
* Raw sensor data is given unscaled (i.e. unitless), therefore in order to scale to physical units need to multiply by the relevant scale factor
|
|
371
|
+
|
|
372
|
+
## 0.5.1 (2024-01-01)
|
|
373
|
+
______________________
|
|
374
|
+
### Main features
|
|
375
|
+
|
|
376
|
+
* Support TapXR Air Gesture pinch
|
|
377
|
+
|
|
378
|
+
## 0.5.0 (2021-08-03)
|
|
379
|
+
______________________
|
|
380
|
+
### Main features
|
|
381
|
+
|
|
382
|
+
* Support Bleak 0.12.1 for mac
|
|
383
|
+
|
|
384
|
+
## 0.3.0 (2020-09-07)
|
|
385
|
+
______________________
|
|
386
|
+
### Main features
|
|
387
|
+
|
|
388
|
+
* Linux support
|
|
389
|
+
* Some bug fixes
|
|
390
|
+
|
|
391
|
+
## 0.2.0 (2020-02-22)
|
|
392
|
+
______________________
|
|
393
|
+
### Main features
|
|
394
|
+
|
|
395
|
+
* Added dll to enable windows backend.
|
|
396
|
+
* fix parsers output types on gesture and tap messages
|
|
397
|
+
|
|
398
|
+
## 0.1.0 (2020-02-20)
|
|
399
|
+
______________________
|
|
400
|
+
### Main features
|
|
401
|
+
|
|
402
|
+
* SDK created.
|
|
403
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
tap_python_sdk-0.7.0.dist-info/licenses/LICENSE,sha256=eti4ME038zjryrDblce9H1mdbHYvJWcAWzO_YijslMQ,1076
|
|
2
|
+
tapsdk/__init__.py,sha256=KKp2hx7wbS004eFz9NSncfRedc-EA-xXBMF7esZUuzc,373
|
|
3
|
+
tapsdk/__version__.py,sha256=RaANGbRu5e-vehwXI1-Qe2ggPPfs1TQaZj072JdbLk4,22
|
|
4
|
+
tapsdk/enumerations.py,sha256=GbDjCjnJfVNQ_Tm-xhHntoMVAb3psFKw9pC6_q79NxM,919
|
|
5
|
+
tapsdk/inputmodes.py,sha256=X-wE2xGFY6RWfxOMcFpoaoS3rBAzvu9oCto8YLdsXPE,2443
|
|
6
|
+
tapsdk/parsers.py,sha256=xgm4kof0xfn8RBUv4pZ0T5LKefY6rt8bwBcvMyF7oPc,2529
|
|
7
|
+
tapsdk/tap.py,sha256=w4RfrGtNkeYlS5B6guJTWKyngPH9wHkAU26gOsM9fic,21592
|
|
8
|
+
tap_python_sdk-0.7.0.dist-info/METADATA,sha256=GwYzxzTsesQG6F9025I68TzWwBhT1cGXeb0yJYmPG7k,16360
|
|
9
|
+
tap_python_sdk-0.7.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
10
|
+
tap_python_sdk-0.7.0.dist-info/top_level.txt,sha256=iS4pGTpFcbvz23yP9N21AGqQfCQ2w7uustHmwcI-qTs,7
|
|
11
|
+
tap_python_sdk-0.7.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
|
|
2
|
+
MIT License
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2020, Tap Systems Inc.
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
7
|
+
|
|
8
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
9
|
+
|
|
10
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
11
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tapsdk
|
tapsdk/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from tapsdk.enumerations import InputType, AirGestures # noqa: F401
|
|
2
|
+
from tapsdk.inputmodes import InputModeRaw, InputModeController, InputModeText, InputModeControllerText # noqa: F401
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def __getattr__(name):
|
|
6
|
+
if name == "TapSDK":
|
|
7
|
+
from tapsdk.tap import TapSDK
|
|
8
|
+
|
|
9
|
+
return TapSDK
|
|
10
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
tapsdk/__version__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.7.0"
|
tapsdk/enumerations.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MouseModes(Enum):
|
|
5
|
+
STDBY = 0
|
|
6
|
+
AIR_MOUSE = 1
|
|
7
|
+
OPTICAL1 = 2
|
|
8
|
+
OPTICAL2 = 3
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InputType(Enum):
|
|
12
|
+
MOUSE = 1
|
|
13
|
+
KEYBOARD = 2
|
|
14
|
+
AUTO = 3
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AirGestures(Enum):
|
|
18
|
+
NONE = 0
|
|
19
|
+
GENERAL = 1
|
|
20
|
+
UP_ONE_FINGER = 2
|
|
21
|
+
UP_TWO_FINGERS = 3
|
|
22
|
+
DOWN_ONE_FINGER = 4
|
|
23
|
+
DOWN_TWO_FINGERS = 5
|
|
24
|
+
LEFT_ONE_FINGER = 6
|
|
25
|
+
LEFT_TWO_FINGERS = 7
|
|
26
|
+
RIGHT_ONE_FINGER = 8
|
|
27
|
+
RIGHT_TWO_FINGERS = 9
|
|
28
|
+
PINCH = 10
|
|
29
|
+
THUMB_FINGER = 12
|
|
30
|
+
THUMB_MIDDLE = 14
|
|
31
|
+
STATE_OPEN = 100
|
|
32
|
+
STATE_THUMB_FINGER = 101
|
|
33
|
+
STATE_THUMB_MIDDLE = 102
|
|
34
|
+
STATE_THUMB_RING = 103
|
|
35
|
+
STATE_THUMB_PINKY = 104
|
|
36
|
+
STATE_FIST = 105
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FingerAcclSensitivity(Enum):
|
|
40
|
+
G2 = 1
|
|
41
|
+
G4 = 2
|
|
42
|
+
G8 = 3
|
|
43
|
+
G16 = 4
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ImuGyroSensitivity(Enum):
|
|
47
|
+
DPS125 = 1
|
|
48
|
+
DPS250 = 2
|
|
49
|
+
DPS500 = 3
|
|
50
|
+
DPS1000 = 4
|
|
51
|
+
DPS2000 = 5
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ImuAcclSensitivity(Enum):
|
|
55
|
+
G2 = 1
|
|
56
|
+
G4 = 2
|
|
57
|
+
G8 = 3
|
|
58
|
+
G16 = 4
|
tapsdk/inputmodes.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
import logging
|
|
3
|
+
from .enumerations import InputType, FingerAcclSensitivity, ImuGyroSensitivity, ImuAcclSensitivity
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RawSensorsSensitivity():
|
|
9
|
+
finger_acc_scales = [None, 3.91, 7.81, 15.62, 31.25] # mg/lsb
|
|
10
|
+
imu_gyro_scales = [None, 4.375, 8.75, 17.5, 35, 70] # mdps/lsb
|
|
11
|
+
imu_acc_scales = [None, 0.061, 0.122, 0.244, 0.488] # mg/lsb
|
|
12
|
+
|
|
13
|
+
def __init__(self, finger_accl_sens, imu_gyro_sens, imu_accl_sens):
|
|
14
|
+
assert all(isinstance(s, Enum) for s in [finger_accl_sens, imu_gyro_sens, imu_accl_sens]), \
|
|
15
|
+
"sensitivity values must be of type Enum"
|
|
16
|
+
self.sens_values = [finger_accl_sens.value, imu_gyro_sens.value, imu_accl_sens.value]
|
|
17
|
+
self.scale_factors = [ # in mg, mdps, mg
|
|
18
|
+
self.finger_acc_scales[self.sens_values[0]],
|
|
19
|
+
self.imu_gyro_scales[self.sens_values[1]],
|
|
20
|
+
self.imu_acc_scales[self.sens_values[2]]
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
def tolist(self):
|
|
24
|
+
return self.sens_values
|
|
25
|
+
|
|
26
|
+
def get_scale_factors(self):
|
|
27
|
+
return self.scale_factors
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class InputMode:
|
|
31
|
+
COMMAND_PREFIX = bytearray([0x3, 0xc, 0x0])
|
|
32
|
+
|
|
33
|
+
def get_command(self):
|
|
34
|
+
return self.COMMAND_PREFIX + self.code
|
|
35
|
+
|
|
36
|
+
def __repr__(self):
|
|
37
|
+
return self.name
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class InputModeController(InputMode):
|
|
41
|
+
def __init__(self):
|
|
42
|
+
self.name = "Controller Mode"
|
|
43
|
+
self.code = bytearray([0x1])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class InputModeText(InputMode):
|
|
47
|
+
def __init__(self):
|
|
48
|
+
self.name = "Text Mode"
|
|
49
|
+
self.code = bytearray([0x0])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class InputModeControllerText(InputMode):
|
|
53
|
+
def __init__(self):
|
|
54
|
+
self.name = "Controller and Text Mode"
|
|
55
|
+
self.code = bytearray([0x5])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class InputModeRaw(InputMode):
|
|
59
|
+
def __init__(self, scaled=False, finger_accl_sens=None,
|
|
60
|
+
imu_gyro_sens=None, imu_accl_sens=None):
|
|
61
|
+
self.name = "Raw sensors Mode"
|
|
62
|
+
self.scaled = scaled
|
|
63
|
+
self.sensitivity = RawSensorsSensitivity(finger_accl_sens or FingerAcclSensitivity.G2,
|
|
64
|
+
imu_gyro_sens or ImuGyroSensitivity.DPS125,
|
|
65
|
+
imu_accl_sens or ImuAcclSensitivity.G2)
|
|
66
|
+
self.code = bytearray([0xa]) + bytearray(self.sensitivity.tolist())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def input_type_command(input_type):
|
|
70
|
+
assert isinstance(input_type, InputType), "input_type must be of type InputType"
|
|
71
|
+
return bytearray([0x3, 0xd, 0x0, input_type.value])
|
tapsdk/parsers.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
def tapcode_to_fingers(tapcode: int):
|
|
2
|
+
return '{0:05b}'.format(1)[::-1]
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def mouse_data_msg(data: bytearray):
|
|
6
|
+
vx = int.from_bytes(data[1:3], "little", signed=True)
|
|
7
|
+
vy = int.from_bytes(data[3:5], "little", signed=True)
|
|
8
|
+
prox = data[9] == 1
|
|
9
|
+
return vx, vy, prox
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def air_gesture_data_msg(data: bytearray):
|
|
13
|
+
return [data[0]]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def tap_data_msg(data: bytearray):
|
|
17
|
+
return [data[0]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def raw_data_msg(data: bytearray, scale_factors=None):
|
|
21
|
+
'''
|
|
22
|
+
Parses raw data messages into structured data with optional scaling.
|
|
23
|
+
Raw data is packed into messages with the following structure:
|
|
24
|
+
[msg_type (1 bit)][timestamp (31 bit)][payload (12 - 30 bytes)]
|
|
25
|
+
* msg type - '0' for imu message
|
|
26
|
+
- '1' for accelerometers message
|
|
27
|
+
* timestamp - unsigned int, given in milliseconds
|
|
28
|
+
* payload - for imu message is 12 bytes
|
|
29
|
+
composed by a series of 6 uint16 numbers
|
|
30
|
+
representing [g_x, g_y, g_z, xl_x, xl_y, xl_z]
|
|
31
|
+
- for accelerometers message is 30 bytes
|
|
32
|
+
composed by a series of 15 uint16 numbers
|
|
33
|
+
representing [xl_x_thumb , xl_y_thumb, xl_z_thumb,
|
|
34
|
+
xl_x_finger, xl_y_finger, xl_z_finger,
|
|
35
|
+
...]
|
|
36
|
+
|
|
37
|
+
'''
|
|
38
|
+
L = len(data)
|
|
39
|
+
ptr = 0
|
|
40
|
+
messages = []
|
|
41
|
+
while ptr <= L:
|
|
42
|
+
# decode timestamp and message type
|
|
43
|
+
ts = int.from_bytes(data[ptr:ptr+4], "little", signed=False)
|
|
44
|
+
if ts == 0:
|
|
45
|
+
break
|
|
46
|
+
ptr += 4
|
|
47
|
+
|
|
48
|
+
# resolve message type
|
|
49
|
+
if ts > raw_data_msg.msg_type_value:
|
|
50
|
+
msg = "accl"
|
|
51
|
+
ts -= raw_data_msg.msg_type_value
|
|
52
|
+
num_of_samples = 15
|
|
53
|
+
else:
|
|
54
|
+
msg = "imu"
|
|
55
|
+
num_of_samples = 6
|
|
56
|
+
|
|
57
|
+
# parse payload
|
|
58
|
+
payload = []
|
|
59
|
+
for i in range(num_of_samples):
|
|
60
|
+
val = int.from_bytes(data[ptr:ptr+2], "little", signed=True)
|
|
61
|
+
ptr += 2
|
|
62
|
+
payload.append(val)
|
|
63
|
+
|
|
64
|
+
if scale_factors:
|
|
65
|
+
if msg == "accl":
|
|
66
|
+
payload = [v * scale_factors[0] for v in payload]
|
|
67
|
+
elif msg == "imu":
|
|
68
|
+
payload = [payload[j] * scale_factors[1] if j < 3 else payload[j] * scale_factors[2]
|
|
69
|
+
for j in range(num_of_samples)]
|
|
70
|
+
messages.append({"type": msg, "ts": ts, "payload": payload})
|
|
71
|
+
return messages
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
raw_data_msg.msg_type_value = 2**31
|
tapsdk/tap.py
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import platform
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from bleak import BleakClient, BleakScanner
|
|
7
|
+
|
|
8
|
+
from . import parsers
|
|
9
|
+
from .enumerations import InputType, MouseModes
|
|
10
|
+
from .inputmodes import InputModeText, InputMode, InputModeRaw, input_type_command
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
tap_service = 'c3ff0001-1d8b-40fd-a56f-c7bd5d0f3370'
|
|
15
|
+
nus_service = '6e400001-b5a3-f393-e0a9-e50e24dcca9e'
|
|
16
|
+
tap_data_characteristic = 'c3ff0005-1d8b-40fd-a56f-c7bd5d0f3370'
|
|
17
|
+
mouse_data_characteristic = 'c3ff0006-1d8b-40fd-a56f-c7bd5d0f3370'
|
|
18
|
+
ui_cmd_characteristic = 'c3ff0009-1d8b-40fd-a56f-c7bd5d0f3370'
|
|
19
|
+
air_gesture_data_characteristic = 'c3ff000a-1d8b-40fd-a56f-c7bd5d0f3370'
|
|
20
|
+
tap_mode_characteristic = '6e400002-b5a3-f393-e0a9-e50e24dcca9e' # nus rx
|
|
21
|
+
raw_sensors_characteristic = '6e400003-b5a3-f393-e0a9-e50e24dcca9e' # nus tx
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if platform.system() == "Darwin":
|
|
25
|
+
try:
|
|
26
|
+
from bleak.backends.corebluetooth.CentralManagerDelegate import (
|
|
27
|
+
CBUUID, CentralManagerDelegate)
|
|
28
|
+
except ImportError as e:
|
|
29
|
+
raise ImportError(
|
|
30
|
+
"tapsdk requires bleak==0.12.1 on macOS; the installed bleak version "
|
|
31
|
+
"no longer exposes bleak.backends.corebluetooth.CentralManagerDelegate "
|
|
32
|
+
"at this import path. Reinstall with the pinned dependency from setup.py."
|
|
33
|
+
) from e
|
|
34
|
+
|
|
35
|
+
def string2uuid(uuid_str: str) -> CBUUID:
|
|
36
|
+
"""Convert a string to a uuid"""
|
|
37
|
+
return CBUUID.UUIDWithString_(uuid_str)
|
|
38
|
+
|
|
39
|
+
class TapClient(BleakClient):
|
|
40
|
+
def __init__(self, address="", **kwargs):
|
|
41
|
+
super().__init__(address, **kwargs)
|
|
42
|
+
|
|
43
|
+
async def connect_retrieved(self, **kwargs) -> bool:
|
|
44
|
+
self._central_manager_delegate = CentralManagerDelegate.alloc().init()
|
|
45
|
+
paired_taps = self.get_paired_taps()
|
|
46
|
+
if len(paired_taps) == 0:
|
|
47
|
+
return False
|
|
48
|
+
self._peripheral = paired_taps[0]
|
|
49
|
+
logger.debug("Connecting to Tap device @ {}".format(self._peripheral))
|
|
50
|
+
await self.connect()
|
|
51
|
+
|
|
52
|
+
# Now get services
|
|
53
|
+
await self.get_services()
|
|
54
|
+
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
def get_paired_taps(self):
|
|
58
|
+
paired_taps = self._central_manager_delegate.central_manager.retrieveConnectedPeripheralsWithServices_(
|
|
59
|
+
[string2uuid(tap_service)])
|
|
60
|
+
logger.debug("Found connected Taps @ {}".format(paired_taps))
|
|
61
|
+
return paired_taps
|
|
62
|
+
|
|
63
|
+
elif platform.system() == "Windows":
|
|
64
|
+
try:
|
|
65
|
+
from bleak_winrt.windows.devices.bluetooth import (BluetoothLEDevice, # noqa: F401
|
|
66
|
+
BluetoothConnectionStatus, BluetoothCacheMode)
|
|
67
|
+
from bleak_winrt.windows.devices.bluetooth.genericattributeprofile import GattSession, GattSessionStatus
|
|
68
|
+
from bleak_winrt.windows.devices.enumeration import DeviceInformation, DeviceInformationKind
|
|
69
|
+
except ImportError as e:
|
|
70
|
+
# bleak>=0.22.0 no longer depends on bleak_winrt (see #21), so it must be
|
|
71
|
+
# installed explicitly; setup.py pins bleak==0.22.3 + bleak-winrt==1.2.0
|
|
72
|
+
# for Windows. Fail fast if that pin was not honored, rather than
|
|
73
|
+
# silently disabling the Windows BLE backend at runtime.
|
|
74
|
+
raise ImportError(
|
|
75
|
+
"tapsdk requires bleak==0.22.3 and bleak-winrt==1.2.0 on Windows. "
|
|
76
|
+
"Reinstall with the pinned dependencies from setup.py, or see "
|
|
77
|
+
"https://github.com/TapWithUs/tap-python-sdk/issues/21."
|
|
78
|
+
) from e
|
|
79
|
+
|
|
80
|
+
async def get_connected_taps():
|
|
81
|
+
# use the following device properties: Paired, Connected, Device Address
|
|
82
|
+
request_properties = [
|
|
83
|
+
"System.Devices.Aep.IsPaired",
|
|
84
|
+
"System.Devices.Aep.IsConnected",
|
|
85
|
+
"System.Devices.Aep.DeviceAddress",]
|
|
86
|
+
aqs_filter = BluetoothLEDevice.get_device_selector_from_connection_status(BluetoothConnectionStatus.CONNECTED)
|
|
87
|
+
devices = await DeviceInformation.find_all_async(aqs_filter, request_properties,
|
|
88
|
+
DeviceInformationKind.ASSOCIATION_ENDPOINT)
|
|
89
|
+
taps = []
|
|
90
|
+
for device in devices:
|
|
91
|
+
try:
|
|
92
|
+
# Extract the Bluetooth address from the device id
|
|
93
|
+
# device.id format: "BluetoothLE#BluetoothLExx:xx:xx:xx:xx:xx-yy:yy:yy:yy:yy:yy"
|
|
94
|
+
device_address_str = device.id.split("-")[-1].upper()
|
|
95
|
+
# Convert MAC address string (e.g. "AA:BB:CC:DD:EE:FF") to a uint64
|
|
96
|
+
address_int = int(device_address_str.replace(":", ""), 16)
|
|
97
|
+
ble_device = await BluetoothLEDevice.from_bluetooth_address_async(address_int)
|
|
98
|
+
if ble_device is None:
|
|
99
|
+
logger.error(f"Could not create BLE device for {device.name}")
|
|
100
|
+
continue
|
|
101
|
+
services = await ble_device.get_gatt_services_async()
|
|
102
|
+
logger.info(f"Device {device.name} has the following services:")
|
|
103
|
+
for service in services.services:
|
|
104
|
+
logger.info(f"Service UUID: {service.uuid}")
|
|
105
|
+
if str(service.uuid).lower() == tap_service.lower():
|
|
106
|
+
taps.append(device)
|
|
107
|
+
break
|
|
108
|
+
except Exception as e:
|
|
109
|
+
logger.error(f"Failed to retrieve services for device {device.name}: {e}")
|
|
110
|
+
# taps = [device for device in devices if tap_service.lower() in [x.lower() for x in device.properties.keys()]]
|
|
111
|
+
return taps
|
|
112
|
+
|
|
113
|
+
async def get_tap_device():
|
|
114
|
+
taps = await get_connected_taps()
|
|
115
|
+
if not taps:
|
|
116
|
+
logger.info("No connected Tap devices found.")
|
|
117
|
+
return None
|
|
118
|
+
return taps[0].id # Return the full WinRT device ID for BleakClient
|
|
119
|
+
|
|
120
|
+
class TapClient(BleakClient):
|
|
121
|
+
def __init__(self, address="", **kwargs):
|
|
122
|
+
super().__init__(address, **kwargs)
|
|
123
|
+
|
|
124
|
+
async def connect_retrieved(self, **kwargs) -> bool:
|
|
125
|
+
if not self.address:
|
|
126
|
+
logger.info("No connected Tap devices found.")
|
|
127
|
+
return False
|
|
128
|
+
logger.info(f"Connecting to Tap device @ {self.address}")
|
|
129
|
+
|
|
130
|
+
# Bypass Bleak's connect() entirely because the device is already connected
|
|
131
|
+
# at the OS level. Bleak's connect() waits for a GattSessionStatus.ACTIVE event,
|
|
132
|
+
# but that event has already fired before the handler is attached — so it hangs.
|
|
133
|
+
# Instead, we manually set up _requester and _session on the backend.
|
|
134
|
+
try:
|
|
135
|
+
remote_mac = self.address.split("-")[-1]
|
|
136
|
+
address_int = int(remote_mac.replace(":", ""), 16)
|
|
137
|
+
|
|
138
|
+
backend = self._backend
|
|
139
|
+
|
|
140
|
+
# Get the BluetoothLEDevice for the already-connected device
|
|
141
|
+
backend._requester = await BluetoothLEDevice.from_bluetooth_address_async(address_int)
|
|
142
|
+
if backend._requester is None:
|
|
143
|
+
logger.error(f"Could not get BluetoothLEDevice for {self.address}")
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
# Open the GATT session (already ACTIVE since device is connected)
|
|
147
|
+
backend._session = await GattSession.from_device_id_async(
|
|
148
|
+
backend._requester.bluetooth_device_id
|
|
149
|
+
)
|
|
150
|
+
backend._session.maintain_connection = True
|
|
151
|
+
|
|
152
|
+
# Force uncached GATT discovery so Windows does not serve a
|
|
153
|
+
# stale cached table that may be missing characteristics.
|
|
154
|
+
backend.services = None
|
|
155
|
+
backend.services = await backend.get_services(
|
|
156
|
+
service_cache_mode=BluetoothCacheMode.UNCACHED,
|
|
157
|
+
cache_mode=BluetoothCacheMode.UNCACHED,
|
|
158
|
+
)
|
|
159
|
+
if backend.services:
|
|
160
|
+
for svc in backend.services.services.values():
|
|
161
|
+
char_uuids = [str(c.uuid) for c in svc.characteristics]
|
|
162
|
+
logger.debug("Discovered service %s with characteristics: %s", svc.uuid, char_uuids)
|
|
163
|
+
|
|
164
|
+
is_active = backend._session.session_status == GattSessionStatus.ACTIVE
|
|
165
|
+
logger.info(f"Session status ACTIVE: {is_active}")
|
|
166
|
+
return is_active
|
|
167
|
+
|
|
168
|
+
except Exception as e:
|
|
169
|
+
logger.error(f"connect_retrieved failed: {e}")
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
elif platform.system() == "Linux":
|
|
174
|
+
class TapClient(BleakClient):
|
|
175
|
+
def __init__(self, address=None, **kwargs):
|
|
176
|
+
address = address if address else get_mac_addr()
|
|
177
|
+
super().__init__(address, **kwargs)
|
|
178
|
+
|
|
179
|
+
async def connect_retrieved(self, **kwargs) -> bool:
|
|
180
|
+
await self.connect()
|
|
181
|
+
connected = self.is_connected()
|
|
182
|
+
if connected:
|
|
183
|
+
logger.info("Connected to {0}".format(self.address))
|
|
184
|
+
await self.__debug()
|
|
185
|
+
else:
|
|
186
|
+
logger.error("Failed to connect to {0}".format(self.address))
|
|
187
|
+
return connected
|
|
188
|
+
|
|
189
|
+
async def __debug(self):
|
|
190
|
+
for service in self.services:
|
|
191
|
+
logger.info("[service] {}: {}".format(service.uuid, service.description))
|
|
192
|
+
for char in service.characteristics:
|
|
193
|
+
if "read" in char.properties:
|
|
194
|
+
try:
|
|
195
|
+
value = bytes(await self.read_gatt_char(char.uuid))
|
|
196
|
+
except Exception as e:
|
|
197
|
+
value = str(e).encode
|
|
198
|
+
else:
|
|
199
|
+
value = None
|
|
200
|
+
# if value:
|
|
201
|
+
logger.info(
|
|
202
|
+
"\t[Characteristic] {0}: (Handle: {1}) ({2}) | Name: {3}, Value: {4} ".format(
|
|
203
|
+
char.uuid,
|
|
204
|
+
"<handle geos here>", # char.handle,
|
|
205
|
+
",".join(char.properties),
|
|
206
|
+
char.description,
|
|
207
|
+
value,
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def get_mac_addr() -> str:
|
|
212
|
+
from subprocess import PIPE, Popen
|
|
213
|
+
try:
|
|
214
|
+
with Popen(["bt-device", "--list"], stdout=PIPE, text=True) as btdevice_process:
|
|
215
|
+
exit_code = btdevice_process.wait()
|
|
216
|
+
if exit_code:
|
|
217
|
+
raise ConnectionError("Failed to find any TAP decive")
|
|
218
|
+
connected_bt_devices = btdevice_process.stdout.read().splitlines()
|
|
219
|
+
tap_devices = list(filter(lambda line: line.startswith("Tap"), connected_bt_devices))
|
|
220
|
+
for d in tap_devices:
|
|
221
|
+
logger.info("Found tap device: %s", d)
|
|
222
|
+
if len(tap_devices) > 1:
|
|
223
|
+
logger.info("Found more than 1 Tap device:")
|
|
224
|
+
for i, d in enumerate(tap_devices):
|
|
225
|
+
logger.info("%s. %s", i + 1, d)
|
|
226
|
+
tap_devices = [tap_devices[int(input("Select the device number: ")) - 1]]
|
|
227
|
+
if len(tap_devices) == 0:
|
|
228
|
+
raise ValueError(
|
|
229
|
+
"No Tap device was found. Make sure the device is connected and its human readable name "
|
|
230
|
+
"starts with Tap.")
|
|
231
|
+
device_decs = tap_devices[0]
|
|
232
|
+
tap_mac_address = device_decs[-18:-1] # only the mac_address part of the description.
|
|
233
|
+
return tap_mac_address
|
|
234
|
+
except Exception as e:
|
|
235
|
+
logger.error("Failed to find any TAP device: {}".format(e))
|
|
236
|
+
raise e
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class TapSDK():
|
|
240
|
+
def __init__(self, **kwargs):
|
|
241
|
+
self.client = TapClient(address=kwargs.get("address"))
|
|
242
|
+
self.mouse_event_cb = None
|
|
243
|
+
self.tap_event_cb = None
|
|
244
|
+
self.air_gesture_event_cb = None
|
|
245
|
+
self.raw_data_event_cb = None
|
|
246
|
+
self.air_gesture_state_event_cb = None
|
|
247
|
+
self.connection_cb = None
|
|
248
|
+
self.input_mode_refresh = InputModeAutoRefresh(self._refresh_input_mode, timeout=10)
|
|
249
|
+
self.mouse_mode = MouseModes.STDBY
|
|
250
|
+
self.input_mode = InputModeText() # Default input mode is Text Mode
|
|
251
|
+
self.input_type = InputType.AUTO
|
|
252
|
+
|
|
253
|
+
@staticmethod
|
|
254
|
+
def _client_connected(client) -> bool:
|
|
255
|
+
is_connected = getattr(client, "is_connected", False)
|
|
256
|
+
return is_connected() if callable(is_connected) else is_connected
|
|
257
|
+
|
|
258
|
+
def register_tap_events(self, cb: Callable):
|
|
259
|
+
self.tap_event_cb = cb
|
|
260
|
+
|
|
261
|
+
def register_mouse_events(self, cb: Callable):
|
|
262
|
+
self.mouse_event_cb = cb
|
|
263
|
+
|
|
264
|
+
def register_air_gesture_events(self, cb: Callable):
|
|
265
|
+
self.air_gesture_event_cb = cb
|
|
266
|
+
|
|
267
|
+
def register_air_gesture_state_events(self, cb: Callable):
|
|
268
|
+
self.air_gesture_state_event_cb = cb
|
|
269
|
+
|
|
270
|
+
def register_raw_data_events(self, cb: Callable):
|
|
271
|
+
self.raw_data_event_cb = cb
|
|
272
|
+
|
|
273
|
+
def register_connection_events(self, cb: Callable):
|
|
274
|
+
self.connection_cb = cb
|
|
275
|
+
|
|
276
|
+
def register_disconnection_events(self, cb: Callable):
|
|
277
|
+
self.client.set_disconnected_callback(cb)
|
|
278
|
+
|
|
279
|
+
def on_moused(self, identifier, data):
|
|
280
|
+
if self.mouse_event_cb:
|
|
281
|
+
args = parsers.mouse_data_msg(data)
|
|
282
|
+
self.mouse_event_cb(identifier, *args)
|
|
283
|
+
|
|
284
|
+
def on_tapped(self, identifier, data):
|
|
285
|
+
args = parsers.tap_data_msg(data)
|
|
286
|
+
if self.mouse_mode == MouseModes.AIR_MOUSE:
|
|
287
|
+
tapcode = args[0]
|
|
288
|
+
if tapcode in [2, 4]:
|
|
289
|
+
self.on_air_gesture(identifier, [tapcode + 10])
|
|
290
|
+
elif self.tap_event_cb:
|
|
291
|
+
self.tap_event_cb(identifier, *args)
|
|
292
|
+
|
|
293
|
+
def on_raw_data(self, identifier, data):
|
|
294
|
+
if self.raw_data_event_cb:
|
|
295
|
+
scale_factors = None
|
|
296
|
+
if isinstance(self.input_mode, InputModeRaw):
|
|
297
|
+
if self.input_mode.scaled:
|
|
298
|
+
scale_factors = self.input_mode.sensitivity.get_scale_factors()
|
|
299
|
+
args = parsers.raw_data_msg(data, scale_factors=scale_factors)
|
|
300
|
+
self.raw_data_event_cb(identifier, args)
|
|
301
|
+
|
|
302
|
+
def on_air_gesture(self, identifier, data):
|
|
303
|
+
if data[0] == 0x14: # mouse mode event
|
|
304
|
+
self.mouse_mode = MouseModes(data[1])
|
|
305
|
+
if self.air_gesture_state_event_cb:
|
|
306
|
+
self.air_gesture_state_event_cb(identifier, self.mouse_mode)
|
|
307
|
+
elif self.air_gesture_event_cb:
|
|
308
|
+
args = parsers.air_gesture_data_msg(data)
|
|
309
|
+
self.air_gesture_event_cb(identifier, *args)
|
|
310
|
+
|
|
311
|
+
async def send_vibration_sequence(self, sequence, identifier=None):
|
|
312
|
+
if len(sequence) > 18:
|
|
313
|
+
sequence = sequence[:18]
|
|
314
|
+
for i, d in enumerate(sequence):
|
|
315
|
+
sequence[i] = max(0, min(255, d // 10))
|
|
316
|
+
|
|
317
|
+
write_value = bytearray([0x0, 0x2] + sequence)
|
|
318
|
+
await self.client.write_gatt_char(ui_cmd_characteristic, write_value)
|
|
319
|
+
|
|
320
|
+
async def set_input_mode(self, input_mode: InputMode, identifier=None):
|
|
321
|
+
if (isinstance(input_mode, InputModeRaw) and isinstance(self.input_mode, InputModeRaw) and
|
|
322
|
+
self.input_mode.get_command() != input_mode.get_command()):
|
|
323
|
+
logger.warning("Can't change \"raw\" sensitivities while in \"raw\"")
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
self.input_mode = input_mode
|
|
327
|
+
write_value = input_mode.get_command()
|
|
328
|
+
|
|
329
|
+
if not self.input_mode_refresh.is_running:
|
|
330
|
+
await self.input_mode_refresh.start()
|
|
331
|
+
|
|
332
|
+
await self._write_input_mode(write_value)
|
|
333
|
+
|
|
334
|
+
async def set_input_type(self, input_type: InputType, identifier=None):
|
|
335
|
+
assert isinstance(input_type, InputType), "input_type must be of type InputType"
|
|
336
|
+
self.input_type = input_type
|
|
337
|
+
write_value = input_type_command(self.input_type)
|
|
338
|
+
|
|
339
|
+
if not self.input_mode_refresh.is_running:
|
|
340
|
+
await self.input_mode_refresh.start()
|
|
341
|
+
|
|
342
|
+
await self._write_input_mode(write_value)
|
|
343
|
+
|
|
344
|
+
async def _refresh_input_mode(self):
|
|
345
|
+
await self.set_input_mode(self.input_mode)
|
|
346
|
+
logger.debug(f"Input Mode Refreshed: {self.input_mode}")
|
|
347
|
+
await self.set_input_type(self.input_type)
|
|
348
|
+
logger.debug(f"Input Type Refreshed: {self.input_type}")
|
|
349
|
+
|
|
350
|
+
async def _write_input_mode(self, value):
|
|
351
|
+
await self.client.write_gatt_char(tap_mode_characteristic, value)
|
|
352
|
+
|
|
353
|
+
async def run(self):
|
|
354
|
+
stop_event = asyncio.Event()
|
|
355
|
+
devices = []
|
|
356
|
+
connected = False
|
|
357
|
+
|
|
358
|
+
if platform.system() == "Windows":
|
|
359
|
+
# First, try to attach to an already-connected Tap device
|
|
360
|
+
tap_device = await get_tap_device()
|
|
361
|
+
if tap_device:
|
|
362
|
+
self.client = TapClient(tap_device)
|
|
363
|
+
connected = await self.client.connect_retrieved()
|
|
364
|
+
|
|
365
|
+
if not connected:
|
|
366
|
+
# Run BleakScanner and Windows reconnect-poller concurrently.
|
|
367
|
+
# - BleakScanner finds unpaired/advertising devices and pairs them.
|
|
368
|
+
# - The poller detects already-paired devices reconnecting (not advertising).
|
|
369
|
+
logger.info("No connected Tap found. Scanning and waiting for a Tap device...")
|
|
370
|
+
found_event = asyncio.Event()
|
|
371
|
+
found_device = {} # shared mutable container
|
|
372
|
+
|
|
373
|
+
async def detection_cb(device, adv_data):
|
|
374
|
+
if tap_service.lower() in adv_data.service_uuids:
|
|
375
|
+
logger.info(f"Found advertising Tap via scan: {device.address}")
|
|
376
|
+
found_device["scanned"] = device
|
|
377
|
+
found_event.set()
|
|
378
|
+
|
|
379
|
+
async def windows_reconnect_poller():
|
|
380
|
+
"""Poll Windows for already-paired Tap devices reconnecting."""
|
|
381
|
+
while not found_event.is_set():
|
|
382
|
+
await asyncio.sleep(3)
|
|
383
|
+
tap_id = await get_tap_device()
|
|
384
|
+
if tap_id:
|
|
385
|
+
logger.info(f"Found already-paired Tap reconnected: {tap_id}")
|
|
386
|
+
found_device["winrt"] = tap_id
|
|
387
|
+
found_event.set()
|
|
388
|
+
|
|
389
|
+
async with BleakScanner(detection_callback=detection_cb):
|
|
390
|
+
poller_task = asyncio.create_task(windows_reconnect_poller())
|
|
391
|
+
await found_event.wait()
|
|
392
|
+
poller_task.cancel()
|
|
393
|
+
|
|
394
|
+
if "winrt" in found_device:
|
|
395
|
+
# Already-paired device reconnected — attach via WinRT path
|
|
396
|
+
self.client = TapClient(found_device["winrt"])
|
|
397
|
+
connected = await self.client.connect_retrieved()
|
|
398
|
+
elif "scanned" in found_device:
|
|
399
|
+
# Device was seen advertising. Windows may have already claimed the
|
|
400
|
+
# connection by now, so try the WinRT path first, then fall back to
|
|
401
|
+
# Bleak's connect()+pair() if the device is still advertising.
|
|
402
|
+
await asyncio.sleep(1) # brief wait for Windows to finish pairing
|
|
403
|
+
tap_id = await get_tap_device()
|
|
404
|
+
if tap_id:
|
|
405
|
+
logger.info(f"Scanned device is now connected via Windows: {tap_id}")
|
|
406
|
+
self.client = TapClient(tap_id)
|
|
407
|
+
connected = await self.client.connect_retrieved()
|
|
408
|
+
if not connected:
|
|
409
|
+
logger.info("Falling back to Bleak connect+pair...")
|
|
410
|
+
self.client = TapClient(found_device["scanned"])
|
|
411
|
+
await self.client.connect()
|
|
412
|
+
await self.client.pair(protection_level=2)
|
|
413
|
+
connected = self._client_connected(self.client)
|
|
414
|
+
|
|
415
|
+
else:
|
|
416
|
+
async def detection_cb(device, adv_data):
|
|
417
|
+
logger.debug("detected %s %s", device, adv_data)
|
|
418
|
+
if tap_service.lower() in adv_data.service_uuids:
|
|
419
|
+
if device.address not in [d.address for d in devices]:
|
|
420
|
+
devices.append(device)
|
|
421
|
+
stop_event.set()
|
|
422
|
+
|
|
423
|
+
connected = await self.client.connect_retrieved()
|
|
424
|
+
if not connected:
|
|
425
|
+
logger.info("Couldn't find connected Tap device. Scanning for Tap devices...")
|
|
426
|
+
async with BleakScanner(detection_callback=detection_cb):
|
|
427
|
+
await stop_event.wait()
|
|
428
|
+
|
|
429
|
+
self.client = TapClient(devices[0])
|
|
430
|
+
await self.client.connect()
|
|
431
|
+
if platform.system() != "Darwin":
|
|
432
|
+
await self.client.pair()
|
|
433
|
+
|
|
434
|
+
if self.client.is_connected:
|
|
435
|
+
for ch, cb in [(tap_data_characteristic, self.on_tapped),
|
|
436
|
+
(mouse_data_characteristic, self.on_moused),
|
|
437
|
+
(air_gesture_data_characteristic, self.on_air_gesture),
|
|
438
|
+
(raw_sensors_characteristic, self.on_raw_data)]:
|
|
439
|
+
try:
|
|
440
|
+
await self.client.start_notify(ch, cb)
|
|
441
|
+
except Exception as e:
|
|
442
|
+
logger.warning("Failed to start notify for air gesture state: " + str(e))
|
|
443
|
+
if self.connection_cb:
|
|
444
|
+
self.connection_cb(self)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
class InputModeAutoRefresh:
|
|
448
|
+
def __init__(self, set_function, timeout=10):
|
|
449
|
+
self.set_function = set_function
|
|
450
|
+
self.is_running = False
|
|
451
|
+
self.timeout = timeout
|
|
452
|
+
self.wd_task = None
|
|
453
|
+
|
|
454
|
+
async def start(self):
|
|
455
|
+
if not self.is_running:
|
|
456
|
+
self.wd_task = asyncio.create_task(self.periodic())
|
|
457
|
+
self.is_running = True
|
|
458
|
+
logger.debug("Input Mode Auto Refresh Started")
|
|
459
|
+
|
|
460
|
+
async def stop(self):
|
|
461
|
+
if self.is_running:
|
|
462
|
+
self.wd_task.cancel()
|
|
463
|
+
self.is_running = False
|
|
464
|
+
logger.debug("Input Mode Auto Refresh Stopped")
|
|
465
|
+
|
|
466
|
+
async def periodic(self):
|
|
467
|
+
while True:
|
|
468
|
+
await self.set_function()
|
|
469
|
+
await asyncio.sleep(self.timeout)
|