diff --git a/Readme.md b/Readme.md index fdcc8a5..ece6bd9 100644 --- a/Readme.md +++ b/Readme.md @@ -34,25 +34,27 @@ Platform notes (BlueZ on Linux, Bleak pins, pairing): [Install the SDK](docs/how ```python import asyncio -from tapsdk import TapSDK, InputModeController +from tapsdk import TapSDK2, connect async def main(): - tap = TapSDK() - tap.register_tap_events(lambda identifier, tapcode: print(identifier, tapcode)) - await tap.run() - await tap.set_input_mode(InputModeController()) + sdk = await connect() # auto-detects v1 / v2 + sdk.register_tap_events(lambda identifier, tapcode: print(identifier, tapcode)) + await sdk.start() + print("Protocol:", "v2" if isinstance(sdk, TapSDK2) else "v1") await asyncio.Event().wait() asyncio.run(main()) ``` -Pair the Tap with the OS first. Update firmware with Tap Manager. More complete flow: [`examples/basic.py`](examples/basic.py). +Pair the Tap with the OS first. Update firmware with Tap Manager. `connect()` picks `TapSDK` (v1) or `TapSDK2` (v2) from GATT. More: [`examples/connect.py`](examples/connect.py). ### Features (summary) -- **Modes:** Text, Controller, Controller+Text, Raw sensors -- **Events:** tap, mouse, air gesture, air-gesture state, raw packets, connect/disconnect -- **Commands:** set mode, set Spatial Control input type (TapXR), haptic sequences +- **Protocols:** v1 (`TapSDK`) and v2 framed (`TapSDK2`); `connect()` auto-detects +- **Modes (v1):** Text, Controller, Controller+Text, Raw sensors +- **Features (v2):** `DeviceFeatures`, vision model/op-mode, IMU motion/raw, standby +- **Events:** tap, mouse, air gesture, raw / IMU packets, connect/disconnect +- **Commands:** set mode / features, Spatial Control input type (TapXR), haptic sequences - **Spatial Control** (authorized TapXR builds): see [Use Spatial Control](docs/how-to/use-spatial-control.md) ### Migrating from 0.6.x diff --git a/docs/explanation/connection-model.md b/docs/explanation/connection-model.md index 15d8e6f..db956c3 100644 --- a/docs/explanation/connection-model.md +++ b/docs/explanation/connection-model.md @@ -1,10 +1,24 @@ # Connection model -The Tap is a Bluetooth Low Energy peripheral. This SDK does not use HID for app control; it opens a GATT session with Bleak and talks to Tap’s proprietary service plus a Nordic UART-style service for mode commands and raw data. +The Tap is a Bluetooth Low Energy peripheral. This SDK does not use HID for app control; it opens a GATT session with Bleak and talks to Tap’s proprietary service. Mode / feature commands and streams differ by protocol generation. + +## Preferred entry: `connect()` then `start()` + +```text +await connect() → register callbacks → await sdk.start() +``` + +1. `connect()` calls shared `connect_tap()` (attach / scan / Windows retrieve), then `detect_protocol()`: if characteristic `c3ff000e` is present, return `TapSDK2`; else `TapSDK`. +2. Notifications are **not** started yet — register callbacks first. +3. `start()` arms protocol-specific notifies and fires the connection callback. + +`TapSDK.run()` / `TapSDK2.run()` still work: connect if needed, then `start()`, in one call. + +Both classes share `tapsdk._transport` (`TapClient`, `connect_tap`) and `tapsdk.device_info` (`get_device_info`). ## Why pair with the OS first -On every platform the most reliable path is: pair in system Bluetooth settings, ensure the device is connected (or connectable), then call `TapSDK.run()`. The SDK then attaches to that session instead of racing a cold advertisement scan. +On every platform the most reliable path is: pair in system Bluetooth settings, ensure the device is connected (or connectable), then call `connect()` (or `run()`). The SDK then attaches to that session instead of racing a cold advertisement scan. Platform differences matter: @@ -12,13 +26,23 @@ Platform differences matter: - **Windows** uses WinRT to find connected Tap devices and opens a GATT session without Bleak’s normal connect wait (which can hang if the session is already active). If nothing is connected, it scans and also polls for paired reconnects that do not advertise. - **Linux** lists BlueZ devices with `bt-device` and connects to names starting with `Tap`. +## v1 vs v2 GATT paths + +| | v1 (`TapSDK`) | v2 (`TapSDK2`) | +|--|---------------|----------------| +| Detect | No `c3ff000e` | Has `c3ff000e` | +| Events | Separate notify chars (tap, mouse, air-gesture, NUS raw) | Single framed notify `c3ff000e` | +| Commands | NUS RX (`set_input_mode` / `set_input_type`) + UI haptics char | Framed writes on `c3ff000f` (`set_feature`, vision, IMU, haptics, standby) | +| Keepalive | Mode refresh task after first mode write | Periodic keepalive after `start()` | +| Connection callback arg | SDK instance | Serial number (bytes) | + ## Single device today Method signatures accept an `identifier` argument on commands, but the SDK currently drives one `TapClient` at a time. Multi-device support is a separate concern from documentation of the present API. ## Notifications vs commands -- **Commands** (mode, input type, haptics) are GATT writes. -- **Events** (tap, mouse, air gesture, raw) are GATT notifications parsed into callback arguments. +- **Commands** are GATT writes (NUS / UI on v1; framed write char on v2). +- **Events** are GATT notifications parsed into callback arguments. -After you set a mode, a background refresh task rewrites mode and input type periodically so a flaky link is less likely to leave the device in the wrong state. +On v1, after you set a mode, a background refresh task rewrites mode and input type periodically so a flaky link is less likely to leave the device in the wrong state. On v2, keepalive writes keep the framed session alive. diff --git a/docs/explanation/input-modes.md b/docs/explanation/input-modes.md index 12c25f8..dfa2f48 100644 --- a/docs/explanation/input-modes.md +++ b/docs/explanation/input-modes.md @@ -2,6 +2,9 @@ Tap hardware always has a “personality” toward the host: it can act as a keyboard/mouse for the operating system, stream structured controller events to an app, stream raw IMU data, or combine some of these. +!!! note "v1 only" + Input modes (`set_input_mode` / NUS) apply to **v1** (`TapSDK`). **v2** (`TapSDK2`) has no Text / Controller / Raw mode write — enable streams with [`DeviceFeatures`](../reference/enumerations.md#devicefeatures). See [Use v2 features](../how-to/use-v2-features.md) and the [connection model](connection-model.md) v1 vs v2 table. + The SDK models that personality as **input modes**: - **Text** — OS-facing HID behavior; your Python callbacks stay quiet for taps. diff --git a/docs/explanation/raw-sensors.md b/docs/explanation/raw-sensors.md index 8a3b859..782d3b7 100644 --- a/docs/explanation/raw-sensors.md +++ b/docs/explanation/raw-sensors.md @@ -1,5 +1,8 @@ # Raw sensors +!!! note "v1 raw mode" + This page describes v1 `InputModeRaw` / NUS streaming. For v2 framed IMU, see [`DeviceFeatures.RAW_IMU_DATA`](../how-to/use-v2-features.md) and [`set_imu_sensitivity`](../reference/tapsdk2.md). + Raw mode exposes the motion sensors behind Tap’s gesture pipeline. That is useful for research, custom gesture models, and XR prototypes — not for everyday typing. ## What is streamed diff --git a/docs/how-to/connect-and-listen.md b/docs/how-to/connect-and-listen.md index 9289094..18843b5 100644 --- a/docs/how-to/connect-and-listen.md +++ b/docs/how-to/connect-and-listen.md @@ -1,41 +1,64 @@ # Connect and listen for events -## Connect +## Preferred: auto-detect protocol Pair the Tap with the OS first. Then: ```python import asyncio -from tapsdk import TapSDK +from tapsdk import connect async def main(): - tap = TapSDK() - await tap.run() + sdk = await connect() + sdk.register_connection_events(lambda id: print("connected", id)) + sdk.register_tap_events(lambda id, tapcode: print("tap", id, tapcode)) + await sdk.start() + await asyncio.Event().wait() asyncio.run(main()) ``` -`run()` attaches to an already-connected Tap when possible. If none is found, it scans (and on Windows also polls for paired devices that reconnect without advertising). +`connect()` attaches to an already-connected Tap when possible (same Windows +retrieve / scan / reconnect-poller path as before), detects v1 vs v2 from GATT +characteristics, and returns `TapSDK` or `TapSDK2`. It does **not** start +notifications — register callbacks, then `await sdk.start()`. -## Connection and disconnection callbacks +Tap callback shape differs by protocol: v1 passes `tapcode` as an `int`; v2 +passes a one-element list (`[tapcode]`). Same finger bitmask either way. + +Register `connection` callbacks before `start()` so they fire. Event callbacks +may also be added after `start()` for later events. + +## Explicit protocol + +If you know the firmware protocol: + +```python +from tapsdk import TapSDK # or TapSDK2 -Register callbacks before `await run()`: +tap = TapSDK() +tap.register_connection_events(on_connect) +await tap.run() # connect_tap + start +``` + +## Connection and disconnection callbacks ```python -def on_connect(sdk): - print("connected", sdk) +def on_connect(sdk_or_serial): + print("connected", sdk_or_serial) def on_disconnect(client): print("disconnected", client) -tap = TapSDK() -tap.register_connection_events(on_connect) -tap.register_disconnection_events(on_disconnect) +sdk.register_connection_events(on_connect) +sdk.register_disconnection_events(on_disconnect) ``` -`on_connect` receives the `TapSDK` instance. `on_disconnect` receives the underlying Bleak client (platform-dependent). +On v1 (`TapSDK`), `on_connect` receives the SDK instance. On v2 (`TapSDK2`), it +receives the device serial number. `on_disconnect` receives the underlying Bleak +client (platform-dependent). -## Subscribe to input events +## Subscribe to input events (v1) ```python from tapsdk import AirGestures @@ -54,13 +77,38 @@ tap.register_raw_data_events(lambda id, packets: print("raw", packets)) Tap and mouse events are only delivered when the device is in a controller-capable mode. See [Switch input modes](switch-input-modes.md). +## Subscribe to input events (v2) + +```python +from tapsdk import DeviceFeatures, UnifiedAirGestures + +# tapcode is [int], e.g. [5] for thumb + middle — not a bare int +sdk.register_tap_events(lambda id, tapcode: print("tap", id, tapcode)) +sdk.register_air_gesture_events( + lambda id, data: print("gesture", UnifiedAirGestures(int(data[0]))) +) +sdk.register_imu_motion_data_events( + lambda id, motion: print("motion", motion) +) +sdk.register_raw_imu_data_events(lambda id, packets: print("raw imu", packets)) +sdk.register_standby_state_events( + lambda id, standby: print("standby" if standby else "active") +) + +await sdk.start() +await sdk.set_feature(DeviceFeatures.MODEL_DETECTION, True) +await sdk.set_feature(DeviceFeatures.IMU_MOTION_DATA, True) +``` + +v2 has no `set_input_mode` — toggle streams with `DeviceFeatures`. Full walkthrough: [Use v2 features](use-v2-features.md). + ## Keep the process alive -`run()` returns after notifications are set up. Keep the event loop running, for example: +`start()` / `run()` return after notifications are set up. Keep the event loop running, for example: ```python -await tap.run() +await sdk.start() await asyncio.Event().wait() ``` -Or follow the pattern in [`examples/basic.py`](https://github.com/TapWithUs/tap-python-sdk/blob/master/examples/basic.py), which sleeps between mode changes. +See also [`examples/connect.py`](https://github.com/TapWithUs/tap-python-sdk/blob/v2/examples/connect.py) and [`examples/basic.py`](https://github.com/TapWithUs/tap-python-sdk/blob/v2/examples/basic.py). diff --git a/docs/how-to/index.md b/docs/how-to/index.md index 99b3778..3112bf5 100644 --- a/docs/how-to/index.md +++ b/docs/how-to/index.md @@ -6,8 +6,9 @@ Problem-oriented recipes for common Tap Python SDK tasks. |------|-------| | Install on macOS, Windows, or Linux | [Install the SDK](install.md) | | Connect and handle connection lifecycle | [Connect and listen](connect-and-listen.md) | -| Choose Text / Controller / Combined / Raw | [Switch input modes](switch-input-modes.md) | -| Stream accelerometer and IMU samples | [Stream raw sensors](stream-raw-sensors.md) | +| Choose Text / Controller / Combined / Raw (v1) | [Switch input modes](switch-input-modes.md) | +| Stream accelerometer and IMU samples (v1) | [Stream raw sensors](stream-raw-sensors.md) | +| Use DeviceFeatures / vision / IMU (v2) | [Use v2 features](use-v2-features.md) | | Play a haptic pattern | [Send haptics](send-haptics.md) | -| Force mouse or keyboard on TapXR | [Use Spatial Control](use-spatial-control.md) | +| Force mouse or keyboard on TapXR (v1) | [Use Spatial Control](use-spatial-control.md) | | Upgrade an app from 0.6.x | [Migrate from 0.6](migrate-from-0.6.md) | diff --git a/docs/how-to/send-haptics.md b/docs/how-to/send-haptics.md index e816e4d..4689108 100644 --- a/docs/how-to/send-haptics.md +++ b/docs/how-to/send-haptics.md @@ -1,7 +1,10 @@ # Send haptic (vibration) sequences +!!! note "v1 and v2" + Both SDKs expose `send_vibration_sequence`. On v2 this aliases `set_haptic_pattern` (framed write). Same period encoding either way. + ```python -await tap.send_vibration_sequence([1000, 300, 200]) +await sdk.send_vibration_sequence([1000, 300, 200]) ``` Periods are in milliseconds, clamped to **10–2550** in **10 ms** steps. Values are stored as `period // 10` on the wire. @@ -11,11 +14,13 @@ The list alternates **on** and **off** durations. The example above vibrates for ## Limits - At most **18** period values (up to 9 on/off pairs). Longer lists are truncated. -- Requires an active BLE connection (`await tap.run()` first). +- Requires an active BLE connection (`await sdk.start()` or `await sdk.run()` first). ## Example pattern ```python # short buzz, pause, short buzz, pause, long buzz -await tap.send_vibration_sequence([100, 200, 100, 200, 500]) +await sdk.send_vibration_sequence([100, 200, 100, 200, 500]) ``` + +Runnable samples: [`examples/connect.py`](https://github.com/TapWithUs/tap-python-sdk/blob/v2/examples/connect.py), [`examples/v2.py`](https://github.com/TapWithUs/tap-python-sdk/blob/v2/examples/v2.py). diff --git a/docs/how-to/stream-raw-sensors.md b/docs/how-to/stream-raw-sensors.md index b4cbf87..7959141 100644 --- a/docs/how-to/stream-raw-sensors.md +++ b/docs/how-to/stream-raw-sensors.md @@ -1,5 +1,8 @@ # Stream raw sensor data +!!! note "v1 protocol" + This guide uses `InputModeRaw` and NUS raw notifications on `TapSDK`. For v2, enable `DeviceFeatures.RAW_IMU_DATA` and call `set_imu_sensitivity` — see [Use v2 features](use-v2-features.md). + Enable Developer mode in the Tap Manager app first. Raw mode is available on Tap Strap / Tap Strap 2 (finger accelerometers) and Tap Strap 2 / TapXR (thumb IMU). ## Enter raw mode diff --git a/docs/how-to/switch-input-modes.md b/docs/how-to/switch-input-modes.md index 720570c..ca35c6b 100644 --- a/docs/how-to/switch-input-modes.md +++ b/docs/how-to/switch-input-modes.md @@ -1,5 +1,8 @@ # Switch input modes +!!! note "v1 protocol" + This guide uses `TapSDK.set_input_mode` (NUS). For v2 framed devices, use [`DeviceFeatures`](use-v2-features.md) instead — there is no Text / Controller mode write. + Input mode controls whether the Tap talks to the OS as a keyboard/mouse, streams events to your app, or both. ## Choose a mode @@ -23,7 +26,7 @@ await tap.set_input_mode(InputModeRaw(...)) # raw sensor stream | Mode | Use when | |------|----------| | Text | You want normal Tap typing; your app does not need tap events | -| Controller | Your app is the sole consumer (games, custom UI) | +| Controller | Your app is the sole consumer (game, custom UI) | | Controller + Text | Users still type while your app also listens | | Raw | You need accelerometer / IMU samples | diff --git a/docs/how-to/use-spatial-control.md b/docs/how-to/use-spatial-control.md index cdf3001..607638c 100644 --- a/docs/how-to/use-spatial-control.md +++ b/docs/how-to/use-spatial-control.md @@ -1,5 +1,8 @@ # Use Spatial Control (TapXR) +!!! note "v1 protocol" + Spatial Control uses `TapSDK.set_input_type` and `register_air_gesture_state_events` (NUS / v1 air-gesture char). Those APIs are **not** on `TapSDK2` returned by `connect()` for framed firmware. For v2 air gestures and streams, use [`DeviceFeatures`](use-v2-features.md) / [`UnifiedAirGestures`](../reference/enumerations.md#unifiedairgestures) instead. + Spatial Control lets authorized apps force input type (air mouse vs tapping) and receive extended air-gesture state. It requires TapXR with experimental Spatial Control firmware and developer access. Request access via [Tap contact](https://www.tapwithus.com/contact-us/). ## Force input type @@ -7,6 +10,7 @@ Spatial Control lets authorized apps force input type (air mouse vs tapping) and ```python from tapsdk import InputType +# Requires a v1 TapSDK instance (not TapSDK2) await tap.set_input_type(InputType.MOUSE) # air / optical mouse await tap.set_input_type(InputType.KEYBOARD) # tapping / keyboard await tap.set_input_type(InputType.AUTO) # posture-based selection diff --git a/docs/how-to/use-v2-features.md b/docs/how-to/use-v2-features.md new file mode 100644 index 0000000..148b0b1 --- /dev/null +++ b/docs/how-to/use-v2-features.md @@ -0,0 +1,79 @@ +# Use v2 features + +Recipes for `TapSDK2` (framed protocol). Prefer `await connect()` so protocol detection picks v2 automatically; or construct `TapSDK2()` when you know the firmware. + +There is no `set_input_mode`. Enable streams with [`DeviceFeatures`](../reference/enumerations.md#devicefeatures). + +## Connect and enable features + +```python +import asyncio +from tapsdk import DeviceFeatures, TapSDK2, connect +from tapsdk.enumerations import ( + ImuAcclSensitivity, + ImuGyroSensitivity, + ModelTypes, + VisionSensorOpModes, +) + +async def main(): + sdk = await connect() + assert isinstance(sdk, TapSDK2) + + sdk.register_tap_events(lambda id, code: print("tap", code)) + sdk.register_air_gesture_events(lambda id, data: print("air", data)) + sdk.register_imu_motion_data_events(lambda id, motion: print("motion", motion)) + sdk.register_raw_imu_data_events(lambda id, packets: print("raw", len(packets))) + sdk.register_standby_state_events(lambda id, s: print("standby", s)) + + await sdk.start() + + # Turn unrelated streams off first (example pattern) + for feature in DeviceFeatures: + await sdk.set_feature(feature, False) + + await sdk.set_feature(DeviceFeatures.MODEL_DETECTION, True) + await sdk.set_vision_sensor_model(ModelTypes.AIR_GESTURE) + await sdk.set_vision_sensor_op_mode(VisionSensorOpModes.STREAM) + + await asyncio.Event().wait() + +asyncio.run(main()) +``` + +## Stream IMU motion + +```python +await sdk.set_feature(DeviceFeatures.IMU_MOTION_DATA, True) +# callback: (identifier, (dx, dy, is_mouse, [roll, pitch, yaw])) +``` + +## Stream raw IMU + +```python +await sdk.set_feature(DeviceFeatures.RAW_IMU_DATA, True) +await sdk.set_imu_sensitivity( + xl_sensitivity=ImuAcclSensitivity.G2, + gyro_sensitivity=ImuGyroSensitivity.DPS125, + scaled=True, +) +``` + +Packet dicts match v1 raw shape (`type` / `ts` / `payload`). See [Events](../reference/events.md#raw-imu-v2). + +## Standby + +```python +await sdk.set_feature(DeviceFeatures.STANDBY_GESTURE_DETECTION, True) +await sdk.set_standby_state(False) +is_standby = await sdk.get_standby_state() +``` + +## Haptics + +```python +await sdk.send_vibration_sequence([500, 200, 500]) +# same as await sdk.set_haptic_pattern([...]) +``` + +Full demo that cycles features: [`examples/v2.py`](https://github.com/TapWithUs/tap-python-sdk/blob/v2/examples/v2.py). API surface: [TapSDK2 reference](../reference/tapsdk2.md). diff --git a/docs/reference/enumerations.md b/docs/reference/enumerations.md index 097ebde..0810395 100644 --- a/docs/reference/enumerations.md +++ b/docs/reference/enumerations.md @@ -1,6 +1,6 @@ # Enumerations -All live in `tapsdk.enumerations`. `InputType` and `AirGestures` are also re-exported from `tapsdk`. +All live in `tapsdk.enumerations`. `InputType`, `AirGestures`, `ImuAcclSensitivity`, `DeviceFeatures`, `UnifiedAirGestures`, `VisionSensorOpModes`, and `ModelTypes` are also re-exported from `tapsdk`. ## `InputType` @@ -14,7 +14,7 @@ Spatial Control input selection. ## `MouseModes` -Reported by air-gesture state events (`0x14` notifications). +Reported by air-gesture state events (`0x14` notifications) on v1. | Member | Value | |--------|-------| @@ -25,6 +25,8 @@ Reported by air-gesture state events (`0x14` notifications). ## `AirGestures` +v1 air-gesture codes (and remapped tap codes in air-mouse). + | Member | Value | |--------|-------| | `NONE` | 0 | @@ -47,6 +49,59 @@ Reported by air-gesture state events (`0x14` notifications). | `STATE_THUMB_PINKY` | 104 | | `STATE_FIST` | 105 | +## `UnifiedAirGestures` + +v2 unified / combined air-gesture codes from `TapSDK2` air-gesture events. + +| Member | Value | +|--------|-------| +| `COMBINED_GESTURE_NONE` | 100 | +| `COMBINED_GESTURE_LEFT` | 101 | +| `COMBINED_GESTURE_RIGHT` | 102 | +| `COMBINED_GESTURE_UP` | 103 | +| `COMBINED_GESTURE_DOWN` | 104 | +| `COMBINED_GESTURE_AB` | 105 | +| `COMBINED_GESTURE_AC` | 106 | +| `COMBINED_GESTURE_AD` | 107 | +| `COMBINED_GESTURE_AE` | 108 | +| `COMBINED_GESTURE_FIST` | 109 | +| `COMBINED_GESTURE_AB_HOLD` | 110 | +| `COMBINED_GESTURE_AC_HOLD` | 111 | +| `COMBINED_GESTURE_AD_HOLD` | 112 | +| `COMBINED_GESTURE_AE_HOLD` | 113 | +| `COMBINED_GESTURE_FIST_HOLD` | 114 | + +## `VisionSensorOpModes` + +v2 vision sensor operating mode (`set_vision_sensor_op_mode`). + +| Member | Value | +|--------|-------| +| `TRIGGER` | 0 | +| `STREAM_ON_TRIGGER` | 1 | +| `STREAM` | 2 | + +## `ModelTypes` + +v2 vision / model selection (`set_vision_sensor_model`). + +| Member | Value | +|--------|-------| +| `TAPPING` | 0 | +| `AIR_GESTURE` | 1 | + +## `DeviceFeatures` + +v2 streams toggled with `TapSDK2.set_feature` / `get_feature`. + +| Member | Value | Notes | +|--------|-------|-------| +| `RAW_IMU_DATA` | 0 | Raw IMU packet stream | +| `MODEL_DETECTION` | 1 | Tap / air-gesture model events | +| `IMU_MOTION_DATA` | 2 | Motion deltas + Euler | +| `TRIGGER_DETECTIONS` | 3 | Reserved; not implemented yet | +| `STANDBY_GESTURE_DETECTION` | 4 | Standby gesture events | + ## `FingerAcclSensitivity` | Member | Approx. range | diff --git a/docs/reference/events.md b/docs/reference/events.md index 8159254..e58dab9 100644 --- a/docs/reference/events.md +++ b/docs/reference/events.md @@ -1,9 +1,11 @@ # Events -Callbacks are registered with `TapSDK.register_*` methods. They run on the asyncio / Bleak notification path — keep them short or schedule work onto another task. +Callbacks are registered with `TapSDK` / `TapSDK2` `register_*` methods. They run on the asyncio / Bleak notification path — keep them short or schedule work onto another task. ## Connection +### v1 (`TapSDK`) + ```text register_connection_events(cb) cb(tap_sdk: TapSDK) -> None @@ -11,6 +13,17 @@ cb(tap_sdk: TapSDK) -> None Called after GATT notifications are started successfully. +### v2 (`TapSDK2`) + +```text +register_connection_events(cb) +cb(serial_number: bytes) -> None +``` + +Called after framed notifications start, serial is read, and keepalive begins. Decode with `serial_number.decode("utf-8")` when printing. + +### Disconnect (both) + ```text register_disconnection_events(cb) cb(client) -> None @@ -22,14 +35,16 @@ Passed through to Bleak’s disconnected callback. ```text register_tap_events(cb) -cb(identifier, tapcode: int) -> None +cb(identifier, tapcode) -> None ``` -`tapcode` is an 8-bit value in **1–31**. Bit 0 (LSb) is the thumb; bit 4 is the pinky. Example: `5` (`0b00101`) = thumb + middle. +On **v1**, `tapcode` is an `int` in **1–31**. Bit 0 (LSb) is the thumb; bit 4 is the pinky. Example: `5` (`0b00101`) = thumb + middle. -While air-mouse mode is active, tapcodes `2` and `4` are remapped into air-gesture handling instead of the tap callback. +On **v2**, the second argument is a one-element list `[tapcode]` from the framed parser (same bitmask meaning). -## Mouse +While air-mouse mode is active on v1, tapcodes `2` and `4` are remapped into air-gesture handling instead of the tap callback. + +## Mouse (v1 only) ```text register_mouse_events(cb) @@ -40,6 +55,8 @@ cb(identifier, vx: int, vy: int, proximity: bool) -> None ## Air gesture +### v1 + ```text register_air_gesture_events(cb) cb(identifier, gesture: int) -> None @@ -54,7 +71,16 @@ cb(identifier, mouse_mode: MouseModes) -> None Fired when the device reports mouse-mode changes (`0x14` payload). -## Raw sensors +### v2 + +```text +register_air_gesture_events(cb) +cb(identifier, gesture_data) -> None +``` + +`gesture_data` is a one-element list; `gesture_data[0]` matches [`UnifiedAirGestures`](enumerations.md#unifiedairgestures). TapSDK2 has no air-gesture **state** register. + +## Raw sensors (v1) ```text register_raw_data_events(cb) @@ -68,3 +94,30 @@ Each dict: | `type` | `str` | `"imu"` or `"accl"` | | `ts` | `int` | Device timestamp (ms) | | `payload` | `list` | Sample values (scaled or raw LSB) | + +## Raw IMU (v2) + +```text +register_raw_imu_data_events(cb) # or register_raw_data_events +cb(identifier, packets: list[dict]) -> None +``` + +Same packet dict shape as v1 raw sensors (`type` / `ts` / `payload`). Enable with `DeviceFeatures.RAW_IMU_DATA` and optionally `set_imu_sensitivity(..., scaled=True)`. + +## IMU motion (v2 only) + +```text +register_imu_motion_data_events(cb) +cb(identifier, motion_data) -> None +``` + +`motion_data` is `(dx, dy, is_mouse, euler_angles)` where `euler_angles` is `[roll, pitch, yaw]` (signed ints). Enable with `DeviceFeatures.IMU_MOTION_DATA`. + +## Standby state (v2 only) + +```text +register_standby_state_events(cb) +cb(identifier, is_standby: bool) -> None +``` + +Also resolved by `get_standby_state()`. Enable related detection with `DeviceFeatures.STANDBY_GESTURE_DETECTION` when needed. diff --git a/docs/reference/index.md b/docs/reference/index.md index c1cd0bc..c925a84 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -4,7 +4,8 @@ Information-oriented descriptions of the public API. For recipes, see [How-to gu | Topic | Page | |-------|------| -| `TapSDK` class | [TapSDK](tapsdk.md) | +| `connect` / `TapSDK` (v1) | [TapSDK](tapsdk.md) | +| `TapSDK2` (v2) | [TapSDK2](tapsdk2.md) | | Input mode classes | [Input modes](input-modes.md) | | Enums | [Enumerations](enumerations.md) | | Event callbacks | [Events](events.md) | diff --git a/docs/reference/input-modes.md b/docs/reference/input-modes.md index f73530f..0877212 100644 --- a/docs/reference/input-modes.md +++ b/docs/reference/input-modes.md @@ -1,6 +1,6 @@ # Input modes -Defined in `tapsdk.inputmodes`. Prefer importing the concrete classes from `tapsdk`. +Defined in `tapsdk.inputmodes`. Prefer importing the concrete classes from `tapsdk`. These classes build **v1** NUS mode commands for `TapSDK.set_input_mode` / `set_input_type`. They are not used by `TapSDK2` (see [`DeviceFeatures`](enumerations.md#devicefeatures)). ## Base: `InputMode` diff --git a/docs/reference/package.md b/docs/reference/package.md index 5bbb784..8e89d2a 100644 --- a/docs/reference/package.md +++ b/docs/reference/package.md @@ -4,14 +4,21 @@ | Name | Kind | |------|------| -| `TapSDK` | Class (lazy import from `tapsdk.tap`) | +| `connect` | Async factory — attach, detect v1/v2, return `TapSDK` or `TapSDK2` (notifies not started) | +| `TapSDK` | Class (lazy import from `tapsdk.tap`) — v1 protocol | +| `TapSDK2` | Class (lazy import from `tapsdk.tap2`) — v2 framed protocol | | `InputModeText` | Class | | `InputModeController` | Class | | `InputModeControllerText` | Class | | `InputModeRaw` | Class | | `InputType` | Enum | | `AirGestures` | Enum | -| `DeviceInfo` | Dataclass (lazy import from `tapsdk.tap`) | +| `ImuAcclSensitivity` | Enum | +| `DeviceFeatures` | Enum | +| `UnifiedAirGestures` | Enum | +| `VisionSensorOpModes` | Enum | +| `ModelTypes` | Enum | +| `DeviceInfo` | Dataclass (lazy import from `tapsdk.device_info`) | Version string: `tapsdk.__version__`. @@ -19,21 +26,28 @@ Version string: `tapsdk.__version__`. | Module | Role | |--------|------| -| `tapsdk.tap` | BLE client, `TapSDK`, GATT UUIDs | +| `tapsdk._transport` | Shared `TapClient`, `connect_tap()` scan/attach | +| `tapsdk._detect` | `detect_protocol(client)` → `"v1"` / `"v2"` | +| `tapsdk.device_info` | Shared `DeviceInfo` / `get_device_info` GATT reads (DIS/BAS) | +| `tapsdk.tap` | `TapSDK` (v1), GATT UUIDs | +| `tapsdk.tap2` | `TapSDK2` (v2), framed-protocol UUIDs | | `tapsdk.inputmodes` | Mode command builders | | `tapsdk.enumerations` | Public enums | | `tapsdk.parsers` | Notification payload parsers | +| `tapsdk.encoder` | v2 outbound framed commands | ## GATT characteristics (SDK-owned) | Constant | UUID | Use | |----------|------|-----| | `tap_service` | `c3ff0001-…` | Tap proprietary service | -| `tap_data_characteristic` | `c3ff0005-…` | Tap events (notify) | -| `mouse_data_characteristic` | `c3ff0006-…` | Mouse events (notify) | -| `ui_cmd_characteristic` | `c3ff0009-…` | Haptics (write) | -| `air_gesture_data_characteristic` | `c3ff000a-…` | Air gestures / mouse mode (notify) | +| `tap_data_characteristic` | `c3ff0005-…` | v1 tap events (notify) | +| `mouse_data_characteristic` | `c3ff0006-…` | v1 mouse events (notify) | +| `ui_cmd_characteristic` | `c3ff0009-…` | v1 haptics (write) | +| `air_gesture_data_characteristic` | `c3ff000a-…` | v1 air gestures / mouse mode (notify) | | `tap_mode_characteristic` | `6e400002-…` | NUS RX — mode / input-type commands (write) | | `raw_sensors_characteristic` | `6e400003-…` | NUS TX — raw stream (notify) | +| `tap_data_read_characteristic` | `c3ff000e-…` | v2 framed notify (also used for protocol detect) | +| `tap_data_write_characteristic` | `c3ff000f-…` | v2 framed write | Lower-level BLE protocol details: [Tap BLE API Documentation](https://tapwithus.atlassian.net/wiki/spaces/FIR/pages/426803201/Tap+BLE+API+Documentation) (internal). diff --git a/docs/reference/tapsdk.md b/docs/reference/tapsdk.md index d1ae3ac..fca6bcd 100644 --- a/docs/reference/tapsdk.md +++ b/docs/reference/tapsdk.md @@ -1,29 +1,50 @@ # TapSDK -Primary entry point. Import with `from tapsdk import TapSDK`. +v1 protocol entry point. Import with `from tapsdk import TapSDK`, or prefer [`connect()`](#connect) which returns `TapSDK` or [`TapSDK2`](tapsdk2.md). Construction imports a platform BLE backend (macOS, Windows, or Linux). Creating `TapSDK` on an unsupported platform, or with the wrong Bleak pin, raises `ImportError`. +## `connect` + +```python +from tapsdk import connect + +sdk = await connect(address=None, **kwargs) +``` + +Attach to a Tap, detect v1 vs v2 (`c3ff000e` present → v2), and return `TapSDK` or `TapSDK2` with an already-connected client. + +| Parameter | Description | +|-----------|-------------| +| `address` | Optional BLE address / platform device id (same rules as the constructor) | +| `**kwargs` | Forwarded to the SDK constructor (for example `keepalive_timeout` on v2) | + +Does **not** start notifications. Register callbacks, then `await sdk.start()`. + ## Constructor ```python -TapSDK(address=None) +TapSDK(client=None, address=None) ``` | Parameter | Description | |-----------|-------------| +| `client` | Optional already-connected `TapClient` (from `connect()`) | | `address` | Optional BLE address / platform device id. On Linux, if omitted, the SDK picks a connected device whose name starts with `Tap`. | ## Connection +### `async start()` + +Start GATT notifications on an already-connected client (for example after `connect()`). Raises `ConnectionError` if the client is not connected. Invokes the connection callback with `self` when notifications are armed. + ### `async run()` -Connect to a Tap and start GATT notifications for tap, mouse, air-gesture, and raw characteristics. +Connect to a Tap (via shared `connect_tap()`) if needed, then call `start()`. - Prefer an already OS-connected / paired device. - Otherwise scan until a Tap advertising the Tap service UUID is found. - On Windows, also polls for paired devices that reconnect without advertising. -- Invokes the connection callback when notifications are armed. Returns when setup finishes; it does not block forever. Keep the asyncio loop alive yourself. @@ -31,7 +52,7 @@ Returns when setup finishes; it does not block forever. Keep the asyncio loop al ### `async set_input_mode(input_mode, identifier=None)` -Write an [input mode](input-modes.md) command to the device. +Write an [input mode](input-modes.md) command to the device (NUS RX). | Parameter | Description | |-----------|-------------| @@ -51,7 +72,7 @@ TapXR Spatial Control only. Force mouse, keyboard, or automatic input selection. ### `async send_vibration_sequence(sequence, identifier=None)` -Send haptic on/off periods. +Send haptic on/off periods via the v1 UI characteristic. | Parameter | Description | |-----------|-------------| diff --git a/docs/reference/tapsdk2.md b/docs/reference/tapsdk2.md new file mode 100644 index 0000000..05a7d80 --- /dev/null +++ b/docs/reference/tapsdk2.md @@ -0,0 +1,113 @@ +# TapSDK2 + +v2 framed-protocol entry point. Import with `from tapsdk import TapSDK2`, or prefer [`connect()`](tapsdk.md#connect) which returns `TapSDK` or `TapSDK2`. + +Commands and events use framed messages on `c3ff000e` (notify) / `c3ff000f` (write). There is no NUS `set_input_mode` path — enable streams with [`DeviceFeatures`](enumerations.md#devicefeatures). + +## Constructor + +```python +TapSDK2(client=None, address=None, *, get_timeout=2.0, keepalive_timeout=10) +``` + +| Parameter | Description | +|-----------|-------------| +| `client` | Optional already-connected `TapClient` (from `connect()`) | +| `address` | Optional BLE address / platform device id | +| `get_timeout` | Seconds to wait for get-request replies (default `2.0`) | +| `keepalive_timeout` | Seconds between keepalive writes after `start()` (default `10`) | + +## Connection + +### `async start()` + +Start notifications on the framed read characteristic, read the serial number, start keepalive, then invoke the connection callback with the serial (bytes). Raises `ConnectionError` if the client is not connected. + +### `async run()` + +Connect via shared `connect_tap()` if needed, then call `start()`. + +## Commands + +### Features + +```python +await sdk.set_feature(DeviceFeatures.RAW_IMU_DATA, True) +enabled = await sdk.get_feature(DeviceFeatures.RAW_IMU_DATA) +``` + +| Method | Description | +|--------|-------------| +| `async set_feature(feature, enable, identifier=None)` | Enable or disable a [`DeviceFeatures`](enumerations.md#devicefeatures) stream | +| `async get_feature(feature, identifier=None) -> bool` | Read current feature enable state | + +### Vision sensor + +| Method | Returns | +|--------|---------| +| `async set_vision_sensor_op_mode(mode: VisionSensorOpModes)` | — | +| `async get_vision_sensor_op_mode() -> VisionSensorOpModes` | Current op mode | +| `async set_vision_sensor_model(model: ModelTypes)` | — | +| `async get_vision_sensor_model() -> ModelTypes` | Current model | + +### IMU sensitivity + +```python +await sdk.set_imu_sensitivity( + xl_sensitivity=ImuAcclSensitivity.G2, + gyro_sensitivity=ImuGyroSensitivity.DPS125, + scaled=True, +) +gyro, xl = await sdk.get_imu_sensitivity() +``` + +| Parameter | Description | +|-----------|-------------| +| `xl_sensitivity` | Thumb IMU accelerometer range | +| `gyro_sensitivity` | Thumb IMU gyroscope range | +| `scaled` | If `True`, raw IMU callbacks use mg/mdps scale factors | +| `finger_accl_sens` | Optional finger accel enum used only for local scaling | + +### Haptics and keepalive + +| Method | Description | +|--------|-------------| +| `async set_haptic_pattern(sequence)` | Periods in ms; each clamped to 0–2550 as `value // 10`; max 18 values | +| `async send_vibration_sequence(sequence)` | Alias for `set_haptic_pattern` | +| `async send_keepalive_message()` | Manual keepalive (also sent periodically after `start()`) | + +### Standby + +| Method | Returns | +|--------|---------| +| `async set_standby_state(standby: bool)` | — | +| `async get_standby_state() -> bool` | `True` if device reports standby | + +### Device info + +`async get_device_info() -> DeviceInfo` — same DIS/BAS reader as [`TapSDK`](tapsdk.md). Shared via `tapsdk.device_info`. + +## Event registration + +See [Events](events.md) for callback shapes. Methods: + +| Method | Notes | +|--------|-------| +| `register_connection_events` | `(serial_number: bytes)` | +| `register_disconnection_events` | `(client)` | +| `register_tap_events` | Tap gesture from model detection | +| `register_air_gesture_events` | Unified air-gesture codes | +| `register_raw_imu_data_events` | Raw IMU packet batches | +| `register_raw_data_events` | Alias for `register_raw_imu_data_events` | +| `register_imu_motion_data_events` | Motion deltas + Euler angles | +| `register_standby_state_events` | Standby boolean | + +TapSDK2 does **not** expose `register_mouse_events` or `register_air_gesture_state_events`. + +## Attributes (runtime) + +| Attribute | Meaning | +|-----------|---------| +| `client` | Underlying `TapClient` / `BleakClient` | +| `device_serial_number` | Serial bytes after `start()`; `None` before | +| `keep_alive_manager` | `KeepAliveManager` instance | diff --git a/docs/release-notes.md b/docs/release-notes.md index 20d59db..9196f8b 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -10,6 +10,9 @@ new version and opens a fresh empty one. ______________________ ### Main features +* Unified v1/v2 entry: `await connect()` auto-detects protocol from GATT (`c3ff000e`), returns `TapSDK` or `TapSDK2`; register callbacks then `await start()` (#36) +* Shared BLE transport (`tapsdk._transport`) so TapSDK2 uses the same Windows retrieve/scan/reconnect path as TapSDK +* Shared `get_device_info()` / `DeviceInfo` on both TapSDK and TapSDK2 via `tapsdk.device_info` (#36) * Versioned docs site (mike) deployed after successful PyPI publish, with a release notes page derived from `docs/release-notes.md` (#47) (#48) * Prep-commit-then-tag release flow: author-written `Unreleased` entries, `scripts/prepare_release.py`, and a verify-only publish pipeline (#47) (#48) * Shared reusable test workflow used by CI and Publish (#39) (#48) diff --git a/docs/tutorial/getting-started.md b/docs/tutorial/getting-started.md index 8d99f1e..6f8a9b7 100644 --- a/docs/tutorial/getting-started.md +++ b/docs/tutorial/getting-started.md @@ -28,24 +28,24 @@ Create `hello_tap.py`: ```python import asyncio -from tapsdk import TapSDK, InputModeController +from tapsdk import TapSDK2, connect def on_tap(identifier, tapcode): print(f"{identifier} tapped {tapcode}") -def on_connect(sdk): - print("Connected to Tap") +def on_connect(identifier): + print("Connected:", identifier) async def main(): - tap = TapSDK() - tap.register_connection_events(on_connect) - tap.register_tap_events(on_tap) + sdk = await connect() + sdk.register_connection_events(on_connect) + sdk.register_tap_events(on_tap) - await tap.run() - await tap.set_input_mode(InputModeController()) + await sdk.start() + print("Protocol:", "v2" if isinstance(sdk, TapSDK2) else "v1") # Keep receiving events await asyncio.Event().wait() @@ -63,26 +63,33 @@ asyncio.run(main()) python hello_tap.py ``` -3. When you see `Connected to Tap`, switch to Controller mode is already requested — tap with one or more fingers. You should see lines like: +3. When you see `Connected: …`, tap with one or more fingers. You should see lines like: ```text +# v1 (TapSDK) — tapcode is an int XX:XX:XX:XX:XX:XX tapped 5 + +# v2 (TapSDK2) — tapcode is a one-element list +b'SERIAL…' tapped [5] ``` -`tapcode` is a bitmask of fingers (bit 0 = thumb … bit 4 = pinky). `5` means thumb + middle. +On both protocols the value is a finger bitmask (bit 0 = thumb … bit 4 = pinky); `5` / `[5]` means thumb + middle. On **v1**, `tapcode` is an `int`; on **v2**, it is `[tapcode]` (see [Events](../reference/events.md)). + +On a **v1** device, enable Controller (or Controller+Text) so taps reach the SDK instead of only the OS keyboard. See [Switch input modes](../how-to/switch-input-modes.md). On **v2**, tap events arrive through the framed protocol without `set_input_mode`. ## 4. What just happened -1. `TapSDK()` creates a BLE client for your platform. -2. `register_*` attaches callbacks (sync; call these before `run()`). -3. `await tap.run()` connects to an already-paired Tap, or scans until one appears. -4. `set_input_mode(InputModeController())` tells the device to send controller events to your app. +1. `await connect()` attaches to an already-paired Tap (or scans), detects v1 vs v2 from GATT (`c3ff000e`), and returns `TapSDK` or `TapSDK2`. Notifications are **not** started yet. +2. `register_*` attaches callbacks (sync; register connection callbacks before `start()`). +3. `await sdk.start()` arms GATT notifications and fires the connection callback. +4. On v1, `on_connect` receives the SDK instance; on v2, it receives the device serial number (bytes). -In Text mode (the default), the Tap behaves like a normal keyboard/mouse for the OS and does not emit tap events to the SDK. +In Text mode (v1 default), the Tap behaves like a normal keyboard/mouse for the OS and does not emit tap events to the SDK until you switch to Controller. ## Next steps - Switch modes, stream sensors, or send haptics: [How-to guides](../how-to/index.md) - Full callback and command signatures: [API reference](../reference/index.md) - Why modes and sensors are designed this way: [Explanation](../explanation/index.md) -- Runnable sample covering more events: [`examples/basic.py`](https://github.com/TapWithUs/tap-python-sdk/blob/master/examples/basic.py) +- Auto-detect sample: [`examples/connect.py`](https://github.com/TapWithUs/tap-python-sdk/blob/v2/examples/connect.py) +- Explicit v1 sample: [`examples/basic.py`](https://github.com/TapWithUs/tap-python-sdk/blob/v2/examples/basic.py) diff --git a/examples/connect.py b/examples/connect.py new file mode 100644 index 0000000..db2409a --- /dev/null +++ b/examples/connect.py @@ -0,0 +1,39 @@ +import asyncio +import logging + +from tapsdk import TapSDK2, connect + +logging.basicConfig(level=logging.INFO) +logging.getLogger("tapsdk").setLevel(logging.DEBUG) +logger = logging.getLogger(__name__) + + +def on_connect(identifier): + logger.info("Connected: %s", identifier) + + +def on_disconnect(client): + logger.info("Disconnected: %s", client) + + +def on_tap(identifier, tapcode): + logger.info("Tap %s: %s", identifier, tapcode) + + +async def main(): + # Two-phase: connect+detect, register callbacks, then start notifies. + sdk = await connect() + sdk.register_connection_events(on_connect) + sdk.register_disconnection_events(on_disconnect) + sdk.register_tap_events(on_tap) + + await sdk.start() + logger.info("Protocol: %s", "v2" if isinstance(sdk, TapSDK2) else "v1") + logger.info("Device info: %s", await sdk.get_device_info()) + + await sdk.send_vibration_sequence([100, 200, 100]) + await asyncio.Event().wait() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/v2.py b/examples/v2.py new file mode 100644 index 0000000..1840aa8 --- /dev/null +++ b/examples/v2.py @@ -0,0 +1,180 @@ +from tapsdk import DeviceFeatures, TapSDK2 +from tapsdk.enumerations import ( # noqa: F401 + ImuAcclSensitivity, + ImuGyroSensitivity, + ModelTypes, + UnifiedAirGestures, + VisionSensorOpModes, +) +import asyncio +import logging +import time + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s.%(msecs)03d %(levelname)s [%(name)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) +logging.getLogger("tapsdk").setLevel(logging.DEBUG) +logger = logging.getLogger(__name__) + +tap_instance = [] +tap_identifiers = [] + + +def on_connect(serial_number): + serial_str = serial_number.decode("utf-8") if isinstance(serial_number, (bytes, bytearray)) else str(serial_number) + logger.info("Connected taps:" + serial_str) + + +def on_disconnect(serial_number): + serial_str = serial_number.decode("utf-8") if isinstance(serial_number, (bytes, bytearray)) else str(serial_number) + logger.info("Tap has disconnected" + serial_str) + + +motion_first_packet_time = 0 +motion_total_packets = 0 +imu_motion_pps = 0.0 + + +def imu_motion_data(identifier, motion_data): + global motion_first_packet_time, motion_total_packets, imu_motion_pps + + if motion_first_packet_time == 0: + motion_first_packet_time = time.time() + + motion_total_packets += 1 + elapsed = time.time() - motion_first_packet_time + imu_motion_pps = (motion_total_packets / elapsed) if elapsed > 0 else 0.0 + + dx, dy, isMouse, euler_angles = motion_data + print_str = "" + if isMouse: + print_str += "Mouse motion: " + "dx: " + str(dx) + " dy: " + str(dy) + print_str += " Euler angles: " + str(euler_angles) + logger.info(print_str + f"({imu_motion_pps:.2f} packets/sec)") + + +def on_standby_state_event(identifier, is_standby): + logger.info(f"Standby state changed: {'STANDBY' if is_standby else 'ACTIVE'}") + + +def on_tap_event(identifier, tapcode): + logger.info("Tap:" + str(tapcode)) + + +air_first_packet_time = 0 +air_total_packets = 0 +air_gesture_pps = 0.0 + + +def on_air_gesture_event(identifier, gesture_data): + global air_first_packet_time, air_total_packets, air_gesture_pps + + if air_first_packet_time == 0: + air_first_packet_time = time.time() + + air_total_packets += 1 + elapsed = time.time() - air_first_packet_time + air_gesture_pps = (air_total_packets / elapsed) if elapsed > 0 else 0.0 + + serial_str = identifier.decode("utf-8") if isinstance(identifier, (bytes, bytearray)) else str(identifier) + logger.info( + f"[{serial_str}] Unified Air Gesture: {UnifiedAirGestures(int(gesture_data[0])).name} ") + if air_total_packets > 14: + air_total_packets = 0 + air_first_packet_time = 0 + logger.info(f"({air_gesture_pps:.2f} packets/sec)") + + +first_raw_imu_packet_time = 0 +total_raw_imu_packets = 0 +raw_imu_pps = 0.0 + + +def on_raw_imu_sensor_data(identifier, raw_sensor_data): + global total_raw_imu_packets + global first_raw_imu_packet_time + global raw_imu_pps + if raw_sensor_data and first_raw_imu_packet_time == 0: + first_raw_imu_packet_time = time.time() + total_raw_imu_packets += len(raw_sensor_data) + elapsed = time.time() - first_raw_imu_packet_time + raw_imu_pps = (total_raw_imu_packets / elapsed) if elapsed > 0 else 0.0 + if elapsed > 0 and total_raw_imu_packets > 400: + logger.info( + "Received %d imu packets in %.2f seconds (%.2f packets/sec)", + total_raw_imu_packets, + elapsed, + raw_imu_pps, + ) + for idx, m in enumerate(raw_sensor_data): + if (total_raw_imu_packets - len(raw_sensor_data) + idx) % 100 == 0: + logger.info("%s, %s, %s", m['type'], time.time(), m['payload']) + + if total_raw_imu_packets > 400: + total_raw_imu_packets = 0 + first_raw_imu_packet_time = 0 + + +async def _setup_vision(): + # await asyncio.sleep(1) + await tap_instance.set_vision_sensor_op_mode(VisionSensorOpModes.STREAM) + await tap_instance.set_vision_sensor_model(ModelTypes.AIR_GESTURE) + + +async def main(): + global tap_instance + tap_instance = TapSDK2() + tap_instance.register_connection_events(on_connect) + tap_instance.register_disconnection_events(on_disconnect) + tap_instance.register_tap_events(on_tap_event) + tap_instance.register_raw_imu_data_events(on_raw_imu_sensor_data) + tap_instance.register_air_gesture_events(on_air_gesture_event) + tap_instance.register_imu_motion_data_events(imu_motion_data) + tap_instance.register_standby_state_events(on_standby_state_event) + await tap_instance.run() + for feature in DeviceFeatures: + await tap_instance.set_feature(feature, False) + + logger.info("Setting model detection to True - Gesture mode - 5 seconds") + await tap_instance.set_feature(DeviceFeatures.MODEL_DETECTION, True) + await tap_instance.set_vision_sensor_model(ModelTypes.AIR_GESTURE) + await tap_instance.set_vision_sensor_op_mode(VisionSensorOpModes.STREAM) + await asyncio.sleep(5) + + logger.info("Setting model detection to False - Tapping mode - 5 seconds") + await tap_instance.set_vision_sensor_model(ModelTypes.TAPPING) + await tap_instance.set_vision_sensor_op_mode(VisionSensorOpModes.TRIGGER) + await asyncio.sleep(5) + await tap_instance.set_feature(DeviceFeatures.MODEL_DETECTION, False) + + logger.info("Setting standby gesture detection - 5 seconds") + await tap_instance.set_feature(DeviceFeatures.STANDBY_GESTURE_DETECTION, True) + await asyncio.sleep(5) + await tap_instance.set_feature(DeviceFeatures.STANDBY_GESTURE_DETECTION, False) + await tap_instance.set_standby_state(False) + + logger.info("Setting IMU motion data - 5 seconds") + await tap_instance.set_feature(DeviceFeatures.IMU_MOTION_DATA, True) + await asyncio.sleep(5) + await tap_instance.set_feature(DeviceFeatures.IMU_MOTION_DATA, False) + + logger.info("Setting RAW IMU data - 5 seconds") + await tap_instance.set_feature(DeviceFeatures.RAW_IMU_DATA, True) + await tap_instance.set_imu_sensitivity( + xl_sensitivity=ImuAcclSensitivity.G2, + gyro_sensitivity=ImuGyroSensitivity.DPS125, + scaled=True, + ) + await asyncio.sleep(5) + await tap_instance.set_feature(DeviceFeatures.RAW_IMU_DATA, False) + + sequence = [500, 200, 500, 500, 500, 200] + await tap_instance.send_vibration_sequence(sequence) + + await asyncio.Event().wait() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/mkdocs.yml b/mkdocs.yml index eb0a76c..e897fb8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -76,12 +76,14 @@ nav: - Connect and listen: how-to/connect-and-listen.md - Switch input modes: how-to/switch-input-modes.md - Stream raw sensors: how-to/stream-raw-sensors.md + - Use v2 features: how-to/use-v2-features.md - Send haptics: how-to/send-haptics.md - Use Spatial Control: how-to/use-spatial-control.md - Migrate from 0.6: how-to/migrate-from-0.6.md - Reference: - Overview: reference/index.md - TapSDK: reference/tapsdk.md + - TapSDK2: reference/tapsdk2.md - Input modes: reference/input-modes.md - Enumerations: reference/enumerations.md - Events: reference/events.md diff --git a/tapsdk/__init__.py b/tapsdk/__init__.py index a1af53d..51e42ec 100644 --- a/tapsdk/__init__.py +++ b/tapsdk/__init__.py @@ -1,11 +1,31 @@ """Tap Strap / TapXR Python BLE SDK. -Public exports: ``TapSDK``, input-mode classes, ``InputType``, and ``AirGestures``. -See the ``docs/`` directory for tutorials, how-to guides, reference, and explanation. +Public exports: ``connect``, ``TapSDK``, ``TapSDK2``, input-mode classes, +``InputType``, and gesture/feature enumerations. See the ``docs/`` directory +for tutorials, how-to guides, reference, and explanation. """ -from tapsdk.enumerations import InputType, AirGestures # noqa: F401 -from tapsdk.inputmodes import InputModeRaw, InputModeController, InputModeText, InputModeControllerText # noqa: F401 +from tapsdk.enumerations import (AirGestures, DeviceFeatures, # noqa: F401 + ImuAcclSensitivity, InputType, ModelTypes, + UnifiedAirGestures, VisionSensorOpModes) +from tapsdk.inputmodes import (InputModeController, InputModeControllerText, # noqa: F401 + InputModeRaw, InputModeText) + + +async def connect(address=None, **kwargs): + """Attach to a Tap, detect v1/v2 protocol, return the matching SDK. + + Does not start notifications. Register callbacks, then ``await sdk.start()``. + """ + from tapsdk._detect import detect_protocol + from tapsdk._transport import connect_tap + from tapsdk.tap import TapSDK + from tapsdk.tap2 import TapSDK2 + + client = await connect_tap(address=address) + if detect_protocol(client) == "v2": + return TapSDK2(client=client, **kwargs) + return TapSDK(client=client, **kwargs) def __getattr__(name): @@ -13,8 +33,12 @@ def __getattr__(name): from tapsdk.tap import TapSDK return TapSDK + if name == "TapSDK2": + from tapsdk.tap2 import TapSDK2 + + return TapSDK2 if name == "DeviceInfo": - from tapsdk.tap import DeviceInfo + from tapsdk.device_info import DeviceInfo return DeviceInfo raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/tapsdk/_detect.py b/tapsdk/_detect.py new file mode 100644 index 0000000..3131453 --- /dev/null +++ b/tapsdk/_detect.py @@ -0,0 +1,15 @@ +from typing import Literal + +V2_READ_CHAR = "c3ff000e-1d8b-40fd-a56f-c7bd5d0f3370" + + +def detect_protocol(client) -> Literal["v1", "v2"]: + """Return ``\"v2\"`` if the framed-protocol read characteristic is present.""" + services = getattr(client, "services", None) + if not services: + return "v1" + for service in services: + for char in service.characteristics: + if str(char.uuid).lower() == V2_READ_CHAR: + return "v2" + return "v1" diff --git a/tapsdk/_transport.py b/tapsdk/_transport.py new file mode 100644 index 0000000..07492be --- /dev/null +++ b/tapsdk/_transport.py @@ -0,0 +1,348 @@ +import asyncio +import logging +import platform + +from bleak import BleakClient, BleakScanner + +logger = logging.getLogger(__name__) + +tap_service = 'c3ff0001-1d8b-40fd-a56f-c7bd5d0f3370' + + +def client_connected(client) -> bool: + """Sync-safe connected check across bleak versions. + + bleak 0.12 returns ``_DeprecatedIsConnectedReturn``: truthy via ``__bool__``, + but also callable — calling it returns a Future (always truthy as an object). + Never call the wrapper; read the bool value only. + """ + val = getattr(client, "is_connected", False) + if isinstance(val, bool): + return val + # bleak 0.12 deprecation wrapper + underlying = getattr(val, "_value", None) + if isinstance(underlying, bool): + return underlying + if callable(val): + result = val() + if asyncio.iscoroutine(result): + result.close() + return False + if asyncio.isfuture(result): + return bool(result.result()) if result.done() else False + return bool(result) + return bool(val) + + +if platform.system() == "Darwin": + try: + from bleak.backends.corebluetooth.CentralManagerDelegate import ( + CBUUID, CentralManagerDelegate) + except ImportError as e: + raise ImportError( + "tapsdk requires bleak==0.12.1 on macOS; the installed bleak version " + "no longer exposes bleak.backends.corebluetooth.CentralManagerDelegate " + "at this import path. Reinstall with the pinned dependency from setup.py." + ) from e + + def string2uuid(uuid_str: str) -> CBUUID: + """Convert a string to a uuid""" + return CBUUID.UUIDWithString_(uuid_str) + + class TapClient(BleakClient): + def __init__(self, address="", **kwargs): + super().__init__(address, **kwargs) + + async def connect_retrieved(self, **kwargs) -> bool: + self._central_manager_delegate = CentralManagerDelegate.alloc().init() + paired_taps = self.get_paired_taps() + if len(paired_taps) == 0: + return False + self._peripheral = paired_taps[0] + logger.debug("Connecting to Tap device @ {}".format(self._peripheral)) + await self.connect() + + # Now get services + await self.get_services() + + return True + + def get_paired_taps(self): + paired_taps = self._central_manager_delegate.central_manager.retrieveConnectedPeripheralsWithServices_( + [string2uuid(tap_service)]) + logger.debug("Found connected Taps @ {}".format(paired_taps)) + return paired_taps + +elif platform.system() == "Windows": + try: + from bleak_winrt.windows.devices.bluetooth import (BluetoothLEDevice, # noqa: F401 + BluetoothConnectionStatus, BluetoothCacheMode) + from bleak_winrt.windows.devices.bluetooth.genericattributeprofile import GattSession, GattSessionStatus + from bleak_winrt.windows.devices.enumeration import DeviceInformation, DeviceInformationKind + except ImportError as e: + # bleak>=0.22.0 no longer depends on bleak_winrt (see #21), so it must be + # installed explicitly; setup.py pins bleak==0.22.3 + bleak-winrt==1.2.0 + # for Windows. Fail fast if that pin was not honored, rather than + # silently disabling the Windows BLE backend at runtime. + raise ImportError( + "tapsdk requires bleak==0.22.3 and bleak-winrt==1.2.0 on Windows. " + "Reinstall with the pinned dependencies from setup.py, or see " + "https://github.com/TapWithUs/tap-python-sdk/issues/21." + ) from e + + async def get_connected_taps(): + # use the following device properties: Paired, Connected, Device Address + request_properties = [ + "System.Devices.Aep.IsPaired", + "System.Devices.Aep.IsConnected", + "System.Devices.Aep.DeviceAddress",] + aqs_filter = BluetoothLEDevice.get_device_selector_from_connection_status(BluetoothConnectionStatus.CONNECTED) + devices = await DeviceInformation.find_all_async(aqs_filter, request_properties, + DeviceInformationKind.ASSOCIATION_ENDPOINT) + taps = [] + for device in devices: + try: + # Extract the Bluetooth address from the device id + # device.id format: "BluetoothLE#BluetoothLExx:xx:xx:xx:xx:xx-yy:yy:yy:yy:yy:yy" + device_address_str = device.id.split("-")[-1].upper() + # Convert MAC address string (e.g. "AA:BB:CC:DD:EE:FF") to a uint64 + address_int = int(device_address_str.replace(":", ""), 16) + ble_device = await BluetoothLEDevice.from_bluetooth_address_async(address_int) + if ble_device is None: + logger.error(f"Could not create BLE device for {device.name}") + continue + services = await ble_device.get_gatt_services_async() + logger.info(f"Device {device.name} has the following services:") + for service in services.services: + logger.info(f"Service UUID: {service.uuid}") + if str(service.uuid).lower() == tap_service.lower(): + taps.append(device) + break + except Exception as e: + logger.error(f"Failed to retrieve services for device {device.name}: {e}") + return taps + + async def get_tap_device(): + taps = await get_connected_taps() + if not taps: + logger.info("No connected Tap devices found.") + return None + return taps[0].id # Return the full WinRT device ID for BleakClient + + class TapClient(BleakClient): + def __init__(self, address="", **kwargs): + super().__init__(address, **kwargs) + + async def connect_retrieved(self, **kwargs) -> bool: + if not self.address: + logger.info("No connected Tap devices found.") + return False + logger.info(f"Connecting to Tap device @ {self.address}") + + # Bypass Bleak's connect() entirely because the device is already connected + # at the OS level. Bleak's connect() waits for a GattSessionStatus.ACTIVE event, + # but that event has already fired before the handler is attached — so it hangs. + # Instead, we manually set up _requester and _session on the backend. + try: + remote_mac = self.address.split("-")[-1] + address_int = int(remote_mac.replace(":", ""), 16) + + backend = self._backend + + # Get the BluetoothLEDevice for the already-connected device + backend._requester = await BluetoothLEDevice.from_bluetooth_address_async(address_int) + if backend._requester is None: + logger.error(f"Could not get BluetoothLEDevice for {self.address}") + return False + + # Open the GATT session (already ACTIVE since device is connected) + backend._session = await GattSession.from_device_id_async( + backend._requester.bluetooth_device_id + ) + backend._session.maintain_connection = True + + # Force uncached GATT discovery so Windows does not serve a + # stale cached table that may be missing characteristics. + backend.services = None + backend.services = await backend.get_services( + service_cache_mode=BluetoothCacheMode.UNCACHED, + cache_mode=BluetoothCacheMode.UNCACHED, + ) + if backend.services: + for svc in backend.services.services.values(): + char_uuids = [str(c.uuid) for c in svc.characteristics] + logger.debug("Discovered service %s with characteristics: %s", svc.uuid, char_uuids) + + is_active = backend._session.session_status == GattSessionStatus.ACTIVE + logger.info(f"Session status ACTIVE: {is_active}") + return is_active + + except Exception as e: + logger.error(f"connect_retrieved failed: {e}") + return False + + +elif platform.system() == "Linux": + class TapClient(BleakClient): + def __init__(self, address=None, **kwargs): + address = address if address else get_mac_addr() + super().__init__(address, **kwargs) + + async def connect_retrieved(self, **kwargs) -> bool: + await self.connect() + connected = client_connected(self) + if connected: + logger.info("Connected to {0}".format(self.address)) + await self.__debug() + else: + logger.error("Failed to connect to {0}".format(self.address)) + return connected + + async def __debug(self): + for service in self.services: + logger.info("[service] {}: {}".format(service.uuid, service.description)) + for char in service.characteristics: + if "read" in char.properties: + try: + value = bytes(await self.read_gatt_char(char.uuid)) + except Exception as e: + value = str(e).encode + else: + value = None + logger.info( + "\t[Characteristic] {0}: (Handle: {1}) ({2}) | Name: {3}, Value: {4} ".format( + char.uuid, + "", # char.handle, + ",".join(char.properties), + char.description, + value, + ) + ) + + def get_mac_addr() -> str: + from subprocess import PIPE, Popen + try: + with Popen(["bt-device", "--list"], stdout=PIPE, text=True) as btdevice_process: + exit_code = btdevice_process.wait() + if exit_code: + raise ConnectionError("Failed to find any TAP decive") + connected_bt_devices = btdevice_process.stdout.read().splitlines() + tap_devices = list(filter(lambda line: line.startswith("Tap"), connected_bt_devices)) + for d in tap_devices: + logger.info("Found tap device: %s", d) + if len(tap_devices) > 1: + logger.info("Found more than 1 Tap device:") + for i, d in enumerate(tap_devices): + logger.info("%s. %s", i + 1, d) + tap_devices = [tap_devices[int(input("Select the device number: ")) - 1]] + if len(tap_devices) == 0: + raise ValueError( + "No Tap device was found. Make sure the device is connected and its human readable name " + "starts with Tap.") + device_decs = tap_devices[0] + tap_mac_address = device_decs[-18:-1] # only the mac_address part of the description. + return tap_mac_address + except Exception as e: + logger.error("Failed to find any TAP device: {}".format(e)) + raise e + + +async def connect_tap(address=None) -> TapClient: + """Scan/attach to a Tap and return a connected client with GATT services. + + Uses the same platform paths as the former TapSDK.run() connect half: + retrieve already-connected devices when possible; otherwise scan (and on + Windows poll for paired reconnects). + """ + if platform.system() == "Windows": + # First, try to attach to an already-connected Tap device + tap_device = address or await get_tap_device() + client = None + connected = False + if tap_device: + client = TapClient(tap_device) + connected = await client.connect_retrieved() + + if not connected: + # Run BleakScanner and Windows reconnect-poller concurrently. + # - BleakScanner finds unpaired/advertising devices and pairs them. + # - The poller detects already-paired devices reconnecting (not advertising). + logger.info("No connected Tap found. Scanning and waiting for a Tap device...") + found_event = asyncio.Event() + found_device = {} # shared mutable container + + async def detection_cb(device, adv_data): + if tap_service.lower() in adv_data.service_uuids: + logger.info(f"Found advertising Tap via scan: {device.address}") + found_device["scanned"] = device + found_event.set() + + async def windows_reconnect_poller(): + """Poll Windows for already-paired Tap devices reconnecting.""" + while not found_event.is_set(): + await asyncio.sleep(3) + tap_id = await get_tap_device() + if tap_id: + logger.info(f"Found already-paired Tap reconnected: {tap_id}") + found_device["winrt"] = tap_id + found_event.set() + + async with BleakScanner(detection_callback=detection_cb): + poller_task = asyncio.create_task(windows_reconnect_poller()) + await found_event.wait() + poller_task.cancel() + + if "winrt" in found_device: + # Already-paired device reconnected — attach via WinRT path + client = TapClient(found_device["winrt"]) + connected = await client.connect_retrieved() + elif "scanned" in found_device: + # Device was seen advertising. Windows may have already claimed the + # connection by now, so try the WinRT path first, then fall back to + # Bleak's connect()+pair() if the device is still advertising. + await asyncio.sleep(1) # brief wait for Windows to finish pairing + tap_id = await get_tap_device() + if tap_id: + logger.info(f"Scanned device is now connected via Windows: {tap_id}") + client = TapClient(tap_id) + connected = await client.connect_retrieved() + if not connected: + logger.info("Falling back to Bleak connect+pair...") + client = TapClient(found_device["scanned"]) + await client.connect() + await client.pair(protection_level=2) + connected = client_connected(client) + + if client is None or not client_connected(client): + raise ConnectionError("Failed to connect to a Tap device on Windows") + return client + + stop_event = asyncio.Event() + devices = [] + + async def detection_cb(device, adv_data): + logger.debug("detected %s %s", device, adv_data) + if tap_service.lower() in adv_data.service_uuids: + if device.address not in [d.address for d in devices]: + devices.append(device) + stop_event.set() + + if platform.system() == "Linux": + client = TapClient(address=address) + else: + client = TapClient(address=address if address is not None else "") + + connected = await client.connect_retrieved() + if not connected: + logger.info("Couldn't find connected Tap device. Scanning for Tap devices...") + async with BleakScanner(detection_callback=detection_cb): + await stop_event.wait() + + client = TapClient(devices[0]) + await client.connect() + if platform.system() != "Darwin": + await client.pair() + + if not client_connected(client): + raise ConnectionError("Failed to connect to a Tap device") + return client diff --git a/tapsdk/device_info.py b/tapsdk/device_info.py new file mode 100644 index 0000000..bbae2cf --- /dev/null +++ b/tapsdk/device_info.py @@ -0,0 +1,103 @@ +"""Shared device metadata reads (DIS/BAS + Tap proprietary fields). + +Not protocol-specific — same GATT UUIDs on v1 and v2 firmware. +""" + +import logging +from dataclasses import dataclass +from typing import Optional + +logger = logging.getLogger(__name__) + +# Standard BLE services exposed by Tap firmware (DIS + BAS). +device_information_service = '0000180a-0000-1000-8000-00805f9b34fb' +battery_service = '0000180f-0000-1000-8000-00805f9b34fb' +manufacturer_name_characteristic = '00002a29-0000-1000-8000-00805f9b34fb' +serial_number_characteristic = '00002a25-0000-1000-8000-00805f9b34fb' +hardware_revision_characteristic = '00002a27-0000-1000-8000-00805f9b34fb' +firmware_revision_characteristic = '00002a26-0000-1000-8000-00805f9b34fb' +software_revision_characteristic = '00002a28-0000-1000-8000-00805f9b34fb' # bootloader on Tap +battery_level_characteristic = '00002a19-0000-1000-8000-00805f9b34fb' +gap_device_name_characteristic = '00002a00-0000-1000-8000-00805f9b34fb' + +# Tap proprietary readable fields (same on v1/v2 devices). +device_name_characteristic = 'c3ff0003-1d8b-40fd-a56f-c7bd5d0f3370' +model_version_characteristic = 'c3ff000c-1d8b-40fd-a56f-c7bd5d0f3370' +fw_version2_characteristic = 'c3ff000d-1d8b-40fd-a56f-c7bd5d0f3370' + + +@dataclass(frozen=True) +class DeviceInfo: + """Public device information from BLE DIS/BAS and Tap service fields.""" + name: Optional[str] = None + fw_version: Optional[str] = None + fw_version2: Optional[str] = None + model_version: Optional[str] = None + hardware_revision: Optional[str] = None + serial_number: Optional[str] = None + manufacturer: Optional[str] = None + software_revision: Optional[str] = None + battery_level: Optional[int] = None + + +def format_model_version_hex(value: Optional[str]) -> Optional[str]: + if value is None: + return None + try: + return f"0x{int(value):X}" + except ValueError: + return value + + +async def _read_gatt_string(client, uuid: str) -> Optional[str]: + try: + raw = await client.read_gatt_char(uuid) + except Exception as e: + logger.debug("Failed to read %s: %s", uuid, e) + return None + if not raw: + return None + return bytes(raw).decode("utf-8", errors="replace").rstrip("\x00").strip() or None + + +async def _read_gatt_uint8(client, uuid: str) -> Optional[int]: + try: + raw = await client.read_gatt_char(uuid) + except Exception as e: + logger.debug("Failed to read %s: %s", uuid, e) + return None + if not raw: + return None + return int(raw[0]) + + +async def resolve_device_name(client) -> Optional[str]: + name = getattr(client, "name", None) or None + if name: + return name + # Tap stores the user-visible name on the proprietary readable char (not GAP 0x2a00). + name = await _read_gatt_string(client, device_name_characteristic) + if name: + return name + return await _read_gatt_string(client, gap_device_name_characteristic) + + +async def read_device_info(client) -> DeviceInfo: + """Read device name, FW versions, battery, and other public device fields. + + Requires a bonded connection (these characteristics are encrypted on Tap + firmware). Missing characteristics yield None for that field. + """ + model_version_raw = await _read_gatt_string(client, model_version_characteristic) + + return DeviceInfo( + name=await resolve_device_name(client), + fw_version=await _read_gatt_string(client, firmware_revision_characteristic), + fw_version2=await _read_gatt_string(client, fw_version2_characteristic), + model_version=format_model_version_hex(model_version_raw), + hardware_revision=await _read_gatt_string(client, hardware_revision_characteristic), + serial_number=await _read_gatt_string(client, serial_number_characteristic), + manufacturer=await _read_gatt_string(client, manufacturer_name_characteristic), + software_revision=await _read_gatt_string(client, software_revision_characteristic), + battery_level=await _read_gatt_uint8(client, battery_level_characteristic), + ) diff --git a/tapsdk/encoder.py b/tapsdk/encoder.py new file mode 100644 index 0000000..a78886b --- /dev/null +++ b/tapsdk/encoder.py @@ -0,0 +1,191 @@ +CMD_BYTE_INDEX = 0 +SUBCMD1_BYTE_INDEX = 1 +SUBCMD2_BYTE_INDEX = 2 +SUBCMD3_BYTE_INDEX = 3 +PAYLOAD_START_INDEX = 4 +METADATA_SIZE_BYTES = PAYLOAD_START_INDEX + +PAYLOAD_FEATURE_NUMBER_INDEX = 0 +PAYLOAD_FEATURE_VALUE_INDEX = 1 + + +class OutCommandType: + FEATURE_COMMAND = 0 + PERIPHERAL_COMMAND = 1 + KEEPALIVE_COMMAND = 2 + STANDBY_STATE_COMMAND = 3 + + +class OutSubCommandType1: + PERIPHERAL_TYPE_VISION_SENSOR = 0 + PERIPHERAL_TYPE_IMU = 1 + PHERIPHERAL_TYPE_HAPTIC = 2 + STANDBY_STATE_GET = 3 + STANDBY_STATE_SET = 4 + SET_FEATURE = 0 + GET_FEATURE = 1 + + +class OutSubCommandType2: + SET_VISUAL_SENSOR_OP_MODE = 0 + SET_VISUAL_SENSOR_MODEL = 1 + SET_IMU_SENSITIVITY = 2 + SET_HAPTIC_PATTERN = 3 + GET_VISUAL_SENSOR_OP_MODE = 10 + GET_VISUAL_SENSOR_MODEL = 11 + GET_IMU_SENSITIVITY = 12 + + +class OutSubCommandType3: + NONE = 0 + + +# UI-command body embedded in external_comm SET haptic payload (tap_ui_commands_packet.h) +HAPTIC_UI_PERIPHERAL_TYPE = 0 +HAPTIC_UI_ACTION_CONSTANT_POWER_SEQUENCE = 2 +HAPTIC_UI_DURATION_SLOT_COUNT = 18 + + +def encode_msg(cmd, subcmd1, subcmd2, subcmd3, payload): + msg = bytearray(METADATA_SIZE_BYTES + len(payload)) + msg[CMD_BYTE_INDEX] = cmd + msg[SUBCMD1_BYTE_INDEX] = subcmd1 + msg[SUBCMD2_BYTE_INDEX] = subcmd2 + msg[SUBCMD3_BYTE_INDEX] = subcmd3 + msg[PAYLOAD_START_INDEX:] = payload + return msg + + +def encode_set_feature(feature_number: int, feature_value: int): + payload = bytearray(2) + payload[PAYLOAD_FEATURE_NUMBER_INDEX] = feature_number + payload[PAYLOAD_FEATURE_VALUE_INDEX] = feature_value + return encode_msg( + OutCommandType.FEATURE_COMMAND, + OutSubCommandType1.SET_FEATURE, + 0, + 0, + payload, + ) + + +def encode_get_feature(feature_number: int): + payload = bytearray(1) + payload[0] = feature_number + return encode_msg( + OutCommandType.FEATURE_COMMAND, + OutSubCommandType1.GET_FEATURE, + 0, + 0, + payload, + ) + + +def encode_set_vision_sensor_op_mode(mode: int): + payload = bytearray(1) + payload[0] = mode + return encode_msg( + OutCommandType.PERIPHERAL_COMMAND, + OutSubCommandType1.PERIPHERAL_TYPE_VISION_SENSOR, + OutSubCommandType2.SET_VISUAL_SENSOR_OP_MODE, + 0, + payload, + ) + + +def encode_set_vision_sensor_model(model: int): + payload = bytearray(1) + payload[0] = model + return encode_msg( + OutCommandType.PERIPHERAL_COMMAND, + OutSubCommandType1.PERIPHERAL_TYPE_VISION_SENSOR, + OutSubCommandType2.SET_VISUAL_SENSOR_MODEL, + 0, + payload, + ) + + +def encode_set_imu_sensitivity(xl_sensitivity: int, gyro_sensitivity: int): + payload = bytearray(2) + payload[0] = gyro_sensitivity + payload[1] = xl_sensitivity + return encode_msg( + OutCommandType.PERIPHERAL_COMMAND, + OutSubCommandType1.PERIPHERAL_TYPE_IMU, + OutSubCommandType2.SET_IMU_SENSITIVITY, + 0, + payload, + ) + + +def encode_set_haptic_pattern(scaled_durations): + """Build SET haptic message. Durations are in 10 ms units (host ms // 10).""" + durations = bytearray(scaled_durations[:HAPTIC_UI_DURATION_SLOT_COUNT]) + if len(durations) < HAPTIC_UI_DURATION_SLOT_COUNT: + durations.extend([0] * (HAPTIC_UI_DURATION_SLOT_COUNT - len(durations))) + payload = bytearray( + [HAPTIC_UI_PERIPHERAL_TYPE, HAPTIC_UI_ACTION_CONSTANT_POWER_SEQUENCE], + ) + durations + return encode_msg( + OutCommandType.PERIPHERAL_COMMAND, + OutSubCommandType1.PHERIPHERAL_TYPE_HAPTIC, + OutSubCommandType2.SET_HAPTIC_PATTERN, + 0, + payload, + ) + + +def encode_keepalive_message(): + return encode_msg(OutCommandType.KEEPALIVE_COMMAND, 0, 0, 0, bytearray()) + + +def encode_get_vision_sensor_op_mode(): + return encode_msg( + OutCommandType.PERIPHERAL_COMMAND, + OutSubCommandType1.PERIPHERAL_TYPE_VISION_SENSOR, + OutSubCommandType2.GET_VISUAL_SENSOR_OP_MODE, + 0, + bytearray(), + ) + + +def encode_get_vision_sensor_model(): + return encode_msg( + OutCommandType.PERIPHERAL_COMMAND, + OutSubCommandType1.PERIPHERAL_TYPE_VISION_SENSOR, + OutSubCommandType2.GET_VISUAL_SENSOR_MODEL, + 0, + bytearray(), + ) + + +def encode_get_imu_sensitivity(): + return encode_msg( + OutCommandType.PERIPHERAL_COMMAND, + OutSubCommandType1.PERIPHERAL_TYPE_IMU, + OutSubCommandType2.GET_IMU_SENSITIVITY, + 0, + bytearray(), + ) + + +def encode_standby_state_get(): + return encode_msg( + OutCommandType.STANDBY_STATE_COMMAND, + OutSubCommandType1.STANDBY_STATE_GET, + 0, + 0, + bytearray(), + ) + + +def encode_standby_state_set(standby: bool): + payload = bytearray(1) + payload[0] = 1 if standby else 0 + return encode_msg( + OutCommandType.STANDBY_STATE_COMMAND, + OutSubCommandType1.STANDBY_STATE_SET, + 0, + 0, + payload, + ) diff --git a/tapsdk/enumerations.py b/tapsdk/enumerations.py index 9848363..e756ea2 100644 --- a/tapsdk/enumerations.py +++ b/tapsdk/enumerations.py @@ -68,3 +68,40 @@ class ImuAcclSensitivity(Enum): G4 = 2 G8 = 3 G16 = 4 + + +class UnifiedAirGestures(Enum): + COMBINED_GESTURE_NONE = 100 + COMBINED_GESTURE_LEFT = 101 + COMBINED_GESTURE_RIGHT = 102 + COMBINED_GESTURE_UP = 103 + COMBINED_GESTURE_DOWN = 104 + COMBINED_GESTURE_AB = 105 + COMBINED_GESTURE_AC = 106 + COMBINED_GESTURE_AD = 107 + COMBINED_GESTURE_AE = 108 + COMBINED_GESTURE_FIST = 109 + COMBINED_GESTURE_AB_HOLD = 110 + COMBINED_GESTURE_AC_HOLD = 111 + COMBINED_GESTURE_AD_HOLD = 112 + COMBINED_GESTURE_AE_HOLD = 113 + COMBINED_GESTURE_FIST_HOLD = 114 + + +class VisionSensorOpModes(Enum): + TRIGGER = 0 + STREAM_ON_TRIGGER = 1 + STREAM = 2 + + +class ModelTypes(Enum): + TAPPING = 0 + AIR_GESTURE = 1 + + +class DeviceFeatures(Enum): + RAW_IMU_DATA = 0 + MODEL_DETECTION = 1 + IMU_MOTION_DATA = 2 + TRIGGER_DETECTIONS = 3 # Not implemented yet + STANDBY_GESTURE_DETECTION = 4 diff --git a/tapsdk/parsers.py b/tapsdk/parsers.py index 6345f7b..f101533 100644 --- a/tapsdk/parsers.py +++ b/tapsdk/parsers.py @@ -2,12 +2,22 @@ def tapcode_to_fingers(tapcode: int): return '{0:05b}'.format(1)[::-1] -def mouse_data_msg(data: bytearray): - """Parse a mouse notification into ``(vx, vy, proximity)``.""" +def mouse_data_msg(data: bytearray, parse_euler_angles=False): + """Parse a mouse notification into ``(vx, vy, proximity)``. + + When ``parse_euler_angles`` is True (v2 IMU motion payloads), also return + ``[roll, pitch, yaw]`` as a fourth element. + """ vx = int.from_bytes(data[1:3], "little", signed=True) vy = int.from_bytes(data[3:5], "little", signed=True) prox = data[9] == 1 - return vx, vy, prox + if not parse_euler_angles: + return vx, vy, prox + euler_angles = [ + int.from_bytes(data[i:i + 2], "little", signed=True) + for i in range(10, 16, 2) + ] + return vx, vy, prox, euler_angles def air_gesture_data_msg(data: bytearray): @@ -82,3 +92,110 @@ def raw_data_msg(data: bytearray, scale_factors=None): raw_data_msg.msg_type_value = 2**31 + + +CMD_BYTE_INDEX = 0 +SUBCMD1_BYTE_INDEX = 1 +SUBCMD2_BYTE_INDEX = 2 +SUBCMD3_BYTE_INDEX = 3 +PAYLOAD_START_INDEX = 4 + + +class IncCommandType: + IMU_DATA = 0 + MODEL_DETECTION = 1 + STANDBY_STATE = 2 + CONFIG_STATE = 3 + + +class IncSubCommandType1: + IMU_MOTION_DATA = 0 + IMU_RAW_DATA = 1 + TAP_GESTURE = 2 + AIR_GESTURE = 3 + + +class IncConfigStateSubCommandType1: + FEATURE = 0 + VISION_OP_MODE = 1 + VISION_MODEL = 2 + IMU_SENSITIVITY = 3 + HAPTIC_PATTERN = 4 + + +def config_state_msg(data: bytearray): + sub_cmd_type = data[SUBCMD1_BYTE_INDEX] + payload = data[PAYLOAD_START_INDEX:] + if sub_cmd_type == IncConfigStateSubCommandType1.FEATURE: + if len(payload) < 2: + return None + return { + "type": "config_feature", + "data": { + "feature_number": payload[0], + "feature_value": payload[1] == 1, + }, + } + if sub_cmd_type == IncConfigStateSubCommandType1.VISION_OP_MODE: + if len(payload) < 1: + return None + return { + "type": "config_vision_op_mode", + "data": payload[0], + } + if sub_cmd_type == IncConfigStateSubCommandType1.VISION_MODEL: + if len(payload) < 1: + return None + return { + "type": "config_vision_model", + "data": payload[0], + } + if sub_cmd_type == IncConfigStateSubCommandType1.IMU_SENSITIVITY: + if len(payload) < 2: + return None + return { + "type": "config_imu_sensitivity", + "data": (payload[0], payload[1]), + } + if sub_cmd_type == IncConfigStateSubCommandType1.HAPTIC_PATTERN: + return { + "type": "config_haptic_pattern", + "data": list(payload), + } + return None + + +def tap_inc_msg(data: bytearray, scale_factors=None): + cmd_type = data[CMD_BYTE_INDEX] + if cmd_type == IncCommandType.IMU_DATA: + sub_cmd_type = data[SUBCMD1_BYTE_INDEX] + if sub_cmd_type == IncSubCommandType1.IMU_MOTION_DATA: + return { + "type": "imu_motion", + "data": mouse_data_msg(data[PAYLOAD_START_INDEX:], parse_euler_angles=True), + } + if sub_cmd_type == IncSubCommandType1.IMU_RAW_DATA: + return { + "type": "imu_raw", + "data": raw_data_msg(data[PAYLOAD_START_INDEX:], scale_factors), + } + elif cmd_type == IncCommandType.MODEL_DETECTION: + sub_cmd_type = data[SUBCMD1_BYTE_INDEX] + if sub_cmd_type == IncSubCommandType1.TAP_GESTURE: + return { + "type": "tap_gesture", + "data": tap_data_msg(data[PAYLOAD_START_INDEX:]), + } + if sub_cmd_type == IncSubCommandType1.AIR_GESTURE: + return { + "type": "air_gesture", + "data": air_gesture_data_msg(data[PAYLOAD_START_INDEX:]), + } + elif cmd_type == IncCommandType.STANDBY_STATE: + return { + "type": "standby_state", + "data": data[PAYLOAD_START_INDEX] == 1, + } + elif cmd_type == IncCommandType.CONFIG_STATE: + return config_state_msg(data) + return None diff --git a/tapsdk/tap.py b/tapsdk/tap.py index c1d03ae..e1138a8 100644 --- a/tapsdk/tap.py +++ b/tapsdk/tap.py @@ -1,303 +1,81 @@ import asyncio import logging -import platform -from dataclasses import dataclass -from typing import Callable, Optional - -from bleak import BleakClient, BleakScanner +from typing import Callable from . import parsers +from ._transport import TapClient, client_connected, connect_tap, tap_service # noqa: F401 +from .device_info import ( # noqa: F401 + DeviceInfo, + battery_level_characteristic, + battery_service, + device_information_service, + device_name_characteristic, + firmware_revision_characteristic, + format_model_version_hex, + fw_version2_characteristic, + gap_device_name_characteristic, + hardware_revision_characteristic, + manufacturer_name_characteristic, + model_version_characteristic, + read_device_info, + serial_number_characteristic, + software_revision_characteristic, +) from .enumerations import InputType, MouseModes from .inputmodes import InputModeText, InputMode, InputModeRaw, input_type_command logger = logging.getLogger(__name__) -tap_service = 'c3ff0001-1d8b-40fd-a56f-c7bd5d0f3370' +# Back-compat alias for tests/callers that imported the private helper name. +_format_model_version_hex = format_model_version_hex + nus_service = '6e400001-b5a3-f393-e0a9-e50e24dcca9e' tap_data_characteristic = 'c3ff0005-1d8b-40fd-a56f-c7bd5d0f3370' mouse_data_characteristic = 'c3ff0006-1d8b-40fd-a56f-c7bd5d0f3370' ui_cmd_characteristic = 'c3ff0009-1d8b-40fd-a56f-c7bd5d0f3370' air_gesture_data_characteristic = 'c3ff000a-1d8b-40fd-a56f-c7bd5d0f3370' -device_name_characteristic = 'c3ff0003-1d8b-40fd-a56f-c7bd5d0f3370' -model_version_characteristic = 'c3ff000c-1d8b-40fd-a56f-c7bd5d0f3370' -fw_version2_characteristic = 'c3ff000d-1d8b-40fd-a56f-c7bd5d0f3370' tap_mode_characteristic = '6e400002-b5a3-f393-e0a9-e50e24dcca9e' # nus rx raw_sensors_characteristic = '6e400003-b5a3-f393-e0a9-e50e24dcca9e' # nus tx -# Standard BLE services exposed by Tap firmware (DIS + BAS). -# See Tap BLE API docs / TAP_XR_develop tap_dis_manager / tap_bas_manager. -device_information_service = '0000180a-0000-1000-8000-00805f9b34fb' -battery_service = '0000180f-0000-1000-8000-00805f9b34fb' -manufacturer_name_characteristic = '00002a29-0000-1000-8000-00805f9b34fb' -serial_number_characteristic = '00002a25-0000-1000-8000-00805f9b34fb' -hardware_revision_characteristic = '00002a27-0000-1000-8000-00805f9b34fb' -firmware_revision_characteristic = '00002a26-0000-1000-8000-00805f9b34fb' -software_revision_characteristic = '00002a28-0000-1000-8000-00805f9b34fb' # bootloader on Tap -battery_level_characteristic = '00002a19-0000-1000-8000-00805f9b34fb' -gap_device_name_characteristic = '00002a00-0000-1000-8000-00805f9b34fb' - - -@dataclass(frozen=True) -class DeviceInfo: - """Public device information from BLE DIS/BAS and Tap service fields.""" - name: Optional[str] = None - fw_version: Optional[str] = None - fw_version2: Optional[str] = None - model_version: Optional[str] = None - hardware_revision: Optional[str] = None - serial_number: Optional[str] = None - manufacturer: Optional[str] = None - software_revision: Optional[str] = None - battery_level: Optional[int] = None - - -if platform.system() == "Darwin": - try: - from bleak.backends.corebluetooth.CentralManagerDelegate import ( - CBUUID, CentralManagerDelegate) - except ImportError as e: - raise ImportError( - "tapsdk requires bleak==0.12.1 on macOS; the installed bleak version " - "no longer exposes bleak.backends.corebluetooth.CentralManagerDelegate " - "at this import path. Reinstall with the pinned dependency from setup.py." - ) from e - - def string2uuid(uuid_str: str) -> CBUUID: - """Convert a string to a uuid""" - return CBUUID.UUIDWithString_(uuid_str) - - class TapClient(BleakClient): - def __init__(self, address="", **kwargs): - super().__init__(address, **kwargs) - - async def connect_retrieved(self, **kwargs) -> bool: - self._central_manager_delegate = CentralManagerDelegate.alloc().init() - paired_taps = self.get_paired_taps() - if len(paired_taps) == 0: - return False - self._peripheral = paired_taps[0] - logger.debug("Connecting to Tap device @ {}".format(self._peripheral)) - await self.connect() - - # Now get services - await self.get_services() - - return True - - def get_paired_taps(self): - paired_taps = self._central_manager_delegate.central_manager.retrieveConnectedPeripheralsWithServices_( - [string2uuid(tap_service)]) - logger.debug("Found connected Taps @ {}".format(paired_taps)) - return paired_taps - -elif platform.system() == "Windows": - try: - from bleak_winrt.windows.devices.bluetooth import (BluetoothLEDevice, # noqa: F401 - BluetoothConnectionStatus, BluetoothCacheMode) - from bleak_winrt.windows.devices.bluetooth.genericattributeprofile import GattSession, GattSessionStatus - from bleak_winrt.windows.devices.enumeration import DeviceInformation, DeviceInformationKind - except ImportError as e: - # bleak>=0.22.0 no longer depends on bleak_winrt (see #21), so it must be - # installed explicitly; setup.py pins bleak==0.22.3 + bleak-winrt==1.2.0 - # for Windows. Fail fast if that pin was not honored, rather than - # silently disabling the Windows BLE backend at runtime. - raise ImportError( - "tapsdk requires bleak==0.22.3 and bleak-winrt==1.2.0 on Windows. " - "Reinstall with the pinned dependencies from setup.py, or see " - "https://github.com/TapWithUs/tap-python-sdk/issues/21." - ) from e - - async def get_connected_taps(): - # use the following device properties: Paired, Connected, Device Address - request_properties = [ - "System.Devices.Aep.IsPaired", - "System.Devices.Aep.IsConnected", - "System.Devices.Aep.DeviceAddress",] - aqs_filter = BluetoothLEDevice.get_device_selector_from_connection_status(BluetoothConnectionStatus.CONNECTED) - devices = await DeviceInformation.find_all_async(aqs_filter, request_properties, - DeviceInformationKind.ASSOCIATION_ENDPOINT) - taps = [] - for device in devices: - try: - # Extract the Bluetooth address from the device id - # device.id format: "BluetoothLE#BluetoothLExx:xx:xx:xx:xx:xx-yy:yy:yy:yy:yy:yy" - device_address_str = device.id.split("-")[-1].upper() - # Convert MAC address string (e.g. "AA:BB:CC:DD:EE:FF") to a uint64 - address_int = int(device_address_str.replace(":", ""), 16) - ble_device = await BluetoothLEDevice.from_bluetooth_address_async(address_int) - if ble_device is None: - logger.error(f"Could not create BLE device for {device.name}") - continue - services = await ble_device.get_gatt_services_async() - logger.info(f"Device {device.name} has the following services:") - for service in services.services: - logger.info(f"Service UUID: {service.uuid}") - if str(service.uuid).lower() == tap_service.lower(): - taps.append(device) - break - except Exception as e: - logger.error(f"Failed to retrieve services for device {device.name}: {e}") - # taps = [device for device in devices if tap_service.lower() in [x.lower() for x in device.properties.keys()]] - return taps - - async def get_tap_device(): - taps = await get_connected_taps() - if not taps: - logger.info("No connected Tap devices found.") - return None - return taps[0].id # Return the full WinRT device ID for BleakClient - - class TapClient(BleakClient): - def __init__(self, address="", **kwargs): - super().__init__(address, **kwargs) - - async def connect_retrieved(self, **kwargs) -> bool: - if not self.address: - logger.info("No connected Tap devices found.") - return False - logger.info(f"Connecting to Tap device @ {self.address}") - - # Bypass Bleak's connect() entirely because the device is already connected - # at the OS level. Bleak's connect() waits for a GattSessionStatus.ACTIVE event, - # but that event has already fired before the handler is attached — so it hangs. - # Instead, we manually set up _requester and _session on the backend. - try: - remote_mac = self.address.split("-")[-1] - address_int = int(remote_mac.replace(":", ""), 16) - - backend = self._backend - - # Get the BluetoothLEDevice for the already-connected device - backend._requester = await BluetoothLEDevice.from_bluetooth_address_async(address_int) - if backend._requester is None: - logger.error(f"Could not get BluetoothLEDevice for {self.address}") - return False - - # Open the GATT session (already ACTIVE since device is connected) - backend._session = await GattSession.from_device_id_async( - backend._requester.bluetooth_device_id - ) - backend._session.maintain_connection = True - - # Force uncached GATT discovery so Windows does not serve a - # stale cached table that may be missing characteristics. - backend.services = None - backend.services = await backend.get_services( - service_cache_mode=BluetoothCacheMode.UNCACHED, - cache_mode=BluetoothCacheMode.UNCACHED, - ) - if backend.services: - for svc in backend.services.services.values(): - char_uuids = [str(c.uuid) for c in svc.characteristics] - logger.debug("Discovered service %s with characteristics: %s", svc.uuid, char_uuids) - - is_active = backend._session.session_status == GattSessionStatus.ACTIVE - logger.info(f"Session status ACTIVE: {is_active}") - return is_active - - except Exception as e: - logger.error(f"connect_retrieved failed: {e}") - return False - - -elif platform.system() == "Linux": - class TapClient(BleakClient): - def __init__(self, address=None, **kwargs): - address = address if address else get_mac_addr() - super().__init__(address, **kwargs) - - async def connect_retrieved(self, **kwargs) -> bool: - await self.connect() - connected = self.is_connected() - if connected: - logger.info("Connected to {0}".format(self.address)) - await self.__debug() - else: - logger.error("Failed to connect to {0}".format(self.address)) - return connected - - async def __debug(self): - for service in self.services: - logger.info("[service] {}: {}".format(service.uuid, service.description)) - for char in service.characteristics: - if "read" in char.properties: - try: - value = bytes(await self.read_gatt_char(char.uuid)) - except Exception as e: - value = str(e).encode - else: - value = None - # if value: - logger.info( - "\t[Characteristic] {0}: (Handle: {1}) ({2}) | Name: {3}, Value: {4} ".format( - char.uuid, - "", # char.handle, - ",".join(char.properties), - char.description, - value, - ) - ) - - def get_mac_addr() -> str: - from subprocess import PIPE, Popen - try: - with Popen(["bt-device", "--list"], stdout=PIPE, text=True) as btdevice_process: - exit_code = btdevice_process.wait() - if exit_code: - raise ConnectionError("Failed to find any TAP decive") - connected_bt_devices = btdevice_process.stdout.read().splitlines() - tap_devices = list(filter(lambda line: line.startswith("Tap"), connected_bt_devices)) - for d in tap_devices: - logger.info("Found tap device: %s", d) - if len(tap_devices) > 1: - logger.info("Found more than 1 Tap device:") - for i, d in enumerate(tap_devices): - logger.info("%s. %s", i + 1, d) - tap_devices = [tap_devices[int(input("Select the device number: ")) - 1]] - if len(tap_devices) == 0: - raise ValueError( - "No Tap device was found. Make sure the device is connected and its human readable name " - "starts with Tap.") - device_decs = tap_devices[0] - tap_mac_address = device_decs[-18:-1] # only the mac_address part of the description. - return tap_mac_address - except Exception as e: - logger.error("Failed to find any TAP device: {}".format(e)) - raise e - - -def _format_model_version_hex(value: Optional[str]) -> Optional[str]: - if value is None: - return None - try: - return f"0x{int(value):X}" - except ValueError: - return value +# Firmware parks NUS commands in one shared 24-byte slot until low-priority +# bt_task drains it. Under IMU load that drain can lag; 50 ms was too short +# and the InputType write (AUTO) was getting overwritten by the mode write. +MODE_COMMAND_SETTLE_SECONDS = 0.2 class TapSDK(): - """High-level async API for one Tap Strap / TapXR over BLE. + """High-level async API for one Tap Strap / TapXR over BLE (v1 protocol). - Register event callbacks, then ``await run()`` to connect and subscribe to - notifications. Issue commands with ``set_input_mode``, ``set_input_type``, - and ``send_vibration_sequence``. + Register event callbacks, then ``await run()`` (or ``await start()`` after + ``connect()``) to subscribe to notifications. Issue commands with + ``set_input_mode``, ``set_input_type``, and ``send_vibration_sequence``. """ - def __init__(self, **kwargs): + def __init__(self, client=None, address=None, **kwargs): """Create an SDK instance. Args: + client: Optional already-connected ``TapClient`` (from ``connect()``). address: Optional BLE address or platform device id. On Linux, if omitted, a connected device whose name starts with ``Tap`` is selected. """ - self.client = TapClient(address=kwargs.get("address")) + if address is None: + address = kwargs.get("address") + self._address = address + if client is not None: + self.client = client + else: + # Darwin TapClient defaults to ""; Linux treats falsy address as auto-detect. + self.client = TapClient(address=address if address is not None else "") self.mouse_event_cb = None self.tap_event_cb = None self.air_gesture_event_cb = None self.raw_data_event_cb = None self.air_gesture_state_event_cb = None self.connection_cb = None + self._disconnect_cb = None + self._mode_write_lock = asyncio.Lock() self.input_mode_refresh = InputModeAutoRefresh(self._refresh_input_mode, timeout=10) self.mouse_mode = MouseModes.STDBY self.input_mode = InputModeText() # Default input mode is Text Mode @@ -305,8 +83,7 @@ def __init__(self, **kwargs): @staticmethod def _client_connected(client) -> bool: - is_connected = getattr(client, "is_connected", False) - return is_connected() if callable(is_connected) else is_connected + return client_connected(client) def register_tap_events(self, cb: Callable): """Register ``cb(identifier, tapcode)`` for tap events.""" @@ -334,6 +111,7 @@ def register_connection_events(self, cb: Callable): def register_disconnection_events(self, cb: Callable): """Register Bleak's disconnected callback ``cb(client)``.""" + self._disconnect_cb = cb self.client.set_disconnected_callback(cb) def on_moused(self, identifier, data): @@ -343,11 +121,14 @@ def on_moused(self, identifier, data): def on_tapped(self, identifier, data): args = parsers.tap_data_msg(data) + # In air-mouse, codes 2/4 are click-like gestures; other taps still + # deliver as tap events (do not drop them on the elif). if self.mouse_mode == MouseModes.AIR_MOUSE: tapcode = args[0] if tapcode in [2, 4]: self.on_air_gesture(identifier, [tapcode + 10]) - elif self.tap_event_cb: + return + if self.tap_event_cb: self.tap_event_cb(identifier, *args) def on_raw_data(self, identifier, data): @@ -368,55 +149,13 @@ def on_air_gesture(self, identifier, data): args = parsers.air_gesture_data_msg(data) self.air_gesture_event_cb(identifier, *args) - async def _read_gatt_string(self, uuid: str) -> Optional[str]: - try: - raw = await self.client.read_gatt_char(uuid) - except Exception as e: - logger.debug("Failed to read %s: %s", uuid, e) - return None - if not raw: - return None - return bytes(raw).decode("utf-8", errors="replace").rstrip("\x00").strip() or None - - async def _read_gatt_uint8(self, uuid: str) -> Optional[int]: - try: - raw = await self.client.read_gatt_char(uuid) - except Exception as e: - logger.debug("Failed to read %s: %s", uuid, e) - return None - if not raw: - return None - return int(raw[0]) - - async def _resolve_device_name(self) -> Optional[str]: - name = getattr(self.client, "name", None) or None - if name: - return name - # Tap stores the user-visible name on the proprietary readable char (not GAP 0x2a00). - name = await self._read_gatt_string(device_name_characteristic) - if name: - return name - return await self._read_gatt_string(gap_device_name_characteristic) - async def get_device_info(self) -> DeviceInfo: """Read device name, FW versions, battery, and other public device fields. Requires a bonded connection (these characteristics are encrypted on Tap firmware). Missing characteristics yield None for that field. """ - model_version_raw = await self._read_gatt_string(model_version_characteristic) - - return DeviceInfo( - name=await self._resolve_device_name(), - fw_version=await self._read_gatt_string(firmware_revision_characteristic), - fw_version2=await self._read_gatt_string(fw_version2_characteristic), - model_version=_format_model_version_hex(model_version_raw), - hardware_revision=await self._read_gatt_string(hardware_revision_characteristic), - serial_number=await self._read_gatt_string(serial_number_characteristic), - manufacturer=await self._read_gatt_string(manufacturer_name_characteristic), - software_revision=await self._read_gatt_string(software_revision_characteristic), - battery_level=await self._read_gatt_uint8(battery_level_characteristic), - ) + return await read_device_info(self.client) async def send_vibration_sequence(self, sequence, identifier=None): """Send a haptic on/off sequence. @@ -447,13 +186,13 @@ async def set_input_mode(self, input_mode: InputMode, identifier=None): return self.input_mode = input_mode - write_value = input_mode.get_command() - + await self._write_input_mode(input_mode.get_command()) + # Re-assert type so Controller starts with AUTO (orientation), not a + # stale forced mouse/keyboard left on the device. + await self._write_input_mode(input_type_command(self.input_type)) if not self.input_mode_refresh.is_running: await self.input_mode_refresh.start() - await self._write_input_mode(write_value) - async def set_input_type(self, input_type: InputType, identifier=None): """Force Spatial Control input type on TapXR (experimental firmware). @@ -463,21 +202,44 @@ async def set_input_type(self, input_type: InputType, identifier=None): """ assert isinstance(input_type, InputType), "input_type must be of type InputType" self.input_type = input_type - write_value = input_type_command(self.input_type) - + await self._write_input_mode(input_type_command(self.input_type)) if not self.input_mode_refresh.is_running: await self.input_mode_refresh.start() - await self._write_input_mode(write_value) - async def _refresh_input_mode(self): - await self.set_input_mode(self.input_mode) - logger.debug(f"Input Mode Refreshed: {self.input_mode}") - await self.set_input_type(self.input_type) - logger.debug(f"Input Type Refreshed: {self.input_type}") + await self._write_input_mode(self.input_mode.get_command()) + logger.debug("Input Mode Refreshed: %s", self.input_mode) + await self._write_input_mode(input_type_command(self.input_type)) + logger.debug("Input Type Refreshed: %s", self.input_type) async def _write_input_mode(self, value): - await self.client.write_gatt_char(tap_mode_characteristic, value) + # Firmware forwards NUS commands through one shared packet slot before + # its low-priority BT task consumes them. Keep writes apart so a second + # command cannot replace the first before that task reads it. + async with self._mode_write_lock: + await self.client.write_gatt_char( + tap_mode_characteristic, + value, + response=True, + ) + await asyncio.sleep(MODE_COMMAND_SETTLE_SECONDS) + + async def start(self): + """Start GATT notifications on an already-connected client.""" + if not client_connected(self.client): + raise ConnectionError("Tap client is not connected; call connect() or run() first") + if self._disconnect_cb: + self.client.set_disconnected_callback(self._disconnect_cb) + for ch, cb in [(tap_data_characteristic, self.on_tapped), + (mouse_data_characteristic, self.on_moused), + (air_gesture_data_characteristic, self.on_air_gesture), + (raw_sensors_characteristic, self.on_raw_data)]: + try: + await self.client.start_notify(ch, cb) + except Exception as e: + logger.warning("Failed to start notify for %s: %s", ch, e) + if self.connection_cb: + self.connection_cb(self) async def run(self): """Connect to a Tap and start GATT notifications. @@ -487,97 +249,9 @@ async def run(self): callback when notifications are armed. Returns after setup — keep the asyncio event loop alive to continue receiving events. """ - stop_event = asyncio.Event() - devices = [] - connected = False - - if platform.system() == "Windows": - # First, try to attach to an already-connected Tap device - tap_device = await get_tap_device() - if tap_device: - self.client = TapClient(tap_device) - connected = await self.client.connect_retrieved() - - if not connected: - # Run BleakScanner and Windows reconnect-poller concurrently. - # - BleakScanner finds unpaired/advertising devices and pairs them. - # - The poller detects already-paired devices reconnecting (not advertising). - logger.info("No connected Tap found. Scanning and waiting for a Tap device...") - found_event = asyncio.Event() - found_device = {} # shared mutable container - - async def detection_cb(device, adv_data): - if tap_service.lower() in adv_data.service_uuids: - logger.info(f"Found advertising Tap via scan: {device.address}") - found_device["scanned"] = device - found_event.set() - - async def windows_reconnect_poller(): - """Poll Windows for already-paired Tap devices reconnecting.""" - while not found_event.is_set(): - await asyncio.sleep(3) - tap_id = await get_tap_device() - if tap_id: - logger.info(f"Found already-paired Tap reconnected: {tap_id}") - found_device["winrt"] = tap_id - found_event.set() - - async with BleakScanner(detection_callback=detection_cb): - poller_task = asyncio.create_task(windows_reconnect_poller()) - await found_event.wait() - poller_task.cancel() - - if "winrt" in found_device: - # Already-paired device reconnected — attach via WinRT path - self.client = TapClient(found_device["winrt"]) - connected = await self.client.connect_retrieved() - elif "scanned" in found_device: - # Device was seen advertising. Windows may have already claimed the - # connection by now, so try the WinRT path first, then fall back to - # Bleak's connect()+pair() if the device is still advertising. - await asyncio.sleep(1) # brief wait for Windows to finish pairing - tap_id = await get_tap_device() - if tap_id: - logger.info(f"Scanned device is now connected via Windows: {tap_id}") - self.client = TapClient(tap_id) - connected = await self.client.connect_retrieved() - if not connected: - logger.info("Falling back to Bleak connect+pair...") - self.client = TapClient(found_device["scanned"]) - await self.client.connect() - await self.client.pair(protection_level=2) - connected = self._client_connected(self.client) - - else: - async def detection_cb(device, adv_data): - logger.debug("detected %s %s", device, adv_data) - if tap_service.lower() in adv_data.service_uuids: - if device.address not in [d.address for d in devices]: - devices.append(device) - stop_event.set() - - connected = await self.client.connect_retrieved() - if not connected: - logger.info("Couldn't find connected Tap device. Scanning for Tap devices...") - async with BleakScanner(detection_callback=detection_cb): - await stop_event.wait() - - self.client = TapClient(devices[0]) - await self.client.connect() - if platform.system() != "Darwin": - await self.client.pair() - - if self.client.is_connected: - for ch, cb in [(tap_data_characteristic, self.on_tapped), - (mouse_data_characteristic, self.on_moused), - (air_gesture_data_characteristic, self.on_air_gesture), - (raw_sensors_characteristic, self.on_raw_data)]: - try: - await self.client.start_notify(ch, cb) - except Exception as e: - logger.warning("Failed to start notify for air gesture state: " + str(e)) - if self.connection_cb: - self.connection_cb(self) + if not client_connected(self.client): + self.client = await connect_tap(address=self._address) + await self.start() class InputModeAutoRefresh: @@ -601,5 +275,5 @@ async def stop(self): async def periodic(self): while True: - await self.set_function() await asyncio.sleep(self.timeout) + await self.set_function() diff --git a/tapsdk/tap2.py b/tapsdk/tap2.py new file mode 100644 index 0000000..0089c01 --- /dev/null +++ b/tapsdk/tap2.py @@ -0,0 +1,302 @@ +import asyncio +import logging +from typing import Callable + +from . import encoder, parsers +from ._transport import TapClient, client_connected, connect_tap +from .device_info import DeviceInfo, read_device_info, serial_number_characteristic +from .enumerations import ( + DeviceFeatures, + FingerAcclSensitivity, + ImuAcclSensitivity, + ImuGyroSensitivity, + ModelTypes, + VisionSensorOpModes, +) +from .inputmodes import RawSensorsSensitivity + +logger = logging.getLogger(__name__) + +DEFAULT_GET_TIMEOUT_SEC = 2.0 + +tap_data_read_characteristic = 'c3ff000e-1d8b-40fd-a56f-c7bd5d0f3370' +tap_data_write_characteristic = 'c3ff000f-1d8b-40fd-a56f-c7bd5d0f3370' + + +class KeepAliveManager: + """Manages periodic keepalive messages to maintain device connection.""" + + def __init__(self, set_function, timeout=10): + self.set_function = set_function + self.is_running = False + self.timeout = timeout + self.wd_task = None + + async def start(self): + if not self.is_running: + self.wd_task = asyncio.create_task(self.periodic()) + self.is_running = True + logger.debug("KeepAliveManager Started") + + async def stop(self): + if self.is_running: + self.wd_task.cancel() + self.is_running = False + logger.debug("KeepAliveManager Stopped") + + async def periodic(self): + while True: + await self.set_function() + await asyncio.sleep(self.timeout) + + +class TapSDK2: + def __init__(self, client=None, address=None, **kwargs): + if address is None: + address = kwargs.get("address") + self._address = address + if client is not None: + self.client = client + else: + # Darwin TapClient defaults to ""; Linux treats falsy address as auto-detect. + self.client = TapClient(address=address if address is not None else "") + self._write_lock = asyncio.Lock() + self.device_serial_number = None + self._scale_factors = None + self._pending_requests = {} + self._get_timeout = kwargs.get("get_timeout", DEFAULT_GET_TIMEOUT_SEC) + + self.tap_event_cb = None + self.air_gesture_event_cb = None + self.raw_data_event_cb = None + self.imu_motion_data_cb = None + self.standby_state_event_cb = None + self.connection_cb = None + self._disconnect_cb = None + + self.keep_alive_manager = KeepAliveManager( + self.send_keepalive_message, + timeout=kwargs.get("keepalive_timeout", 10), + ) + + def _resolve_pending_request(self, key, value): + future = self._pending_requests.get(key) + if future is not None and not future.done(): + future.set_result(value) + + async def _request_and_wait(self, key, write_value): + loop = asyncio.get_running_loop() + future = loop.create_future() + self._pending_requests[key] = future + try: + await self._write_tap_gatt_char(write_value) + return await asyncio.wait_for(future, timeout=self._get_timeout) + finally: + if self._pending_requests.get(key) is future: + self._pending_requests.pop(key, None) + + async def _write_tap_gatt_char(self, write_value: bytearray): + async with self._write_lock: + await self.client.write_gatt_char( + tap_data_write_characteristic, + write_value, + response=True, + ) + + def register_tap_events(self, cb: Callable): + self.tap_event_cb = cb + + def register_air_gesture_events(self, cb: Callable): + self.air_gesture_event_cb = cb + + def register_raw_imu_data_events(self, cb: Callable): + self.raw_data_event_cb = cb + + def register_raw_data_events(self, cb: Callable): + self.register_raw_imu_data_events(cb) + + def register_imu_motion_data_events(self, cb: Callable): + self.imu_motion_data_cb = cb + + def register_standby_state_events(self, cb: Callable): + self.standby_state_event_cb = cb + + def register_connection_events(self, cb: Callable): + self.connection_cb = cb + + def register_disconnection_events(self, cb: Callable): + self._disconnect_cb = cb + self.client.set_disconnected_callback(cb) + + def on_inc_msg(self, sender, data): + if not data: + logger.debug("Received empty notification from %s", sender) + return + + args = parsers.tap_inc_msg(data, scale_factors=self._scale_factors) + if not args: + logger.debug( + "Received unsupported notification payload from %s: %s", + sender, + bytes(data).hex(), + ) + return + + if args['type'] == 'imu_raw': + if self.raw_data_event_cb: + self.raw_data_event_cb(sender, args['data']) + elif args['type'] == 'imu_motion': + if self.imu_motion_data_cb: + self.imu_motion_data_cb(sender, args['data']) + elif args['type'] == 'air_gesture': + if self.air_gesture_event_cb: + self.air_gesture_event_cb(sender, args['data']) + elif args['type'] == 'tap_gesture': + if self.tap_event_cb: + self.tap_event_cb(sender, args['data']) + elif args['type'] == 'standby_state': + self._resolve_pending_request(('standby_state',), args['data']) + if self.standby_state_event_cb: + self.standby_state_event_cb(sender, args['data']) + elif args['type'] == 'config_feature': + feature_data = args['data'] + self._resolve_pending_request( + ('config_feature', feature_data['feature_number']), + feature_data['feature_value'], + ) + elif args['type'] == 'config_vision_op_mode': + self._resolve_pending_request(('config_vision_op_mode',), args['data']) + elif args['type'] == 'config_vision_model': + self._resolve_pending_request(('config_vision_model',), args['data']) + elif args['type'] == 'config_imu_sensitivity': + self._resolve_pending_request(('config_imu_sensitivity',), args['data']) + + async def set_feature(self, feature: DeviceFeatures, enable: bool, identifier=None): + if not isinstance(feature, DeviceFeatures): + raise ValueError("feature must be of type DeviceFeatures") + write_value = encoder.encode_set_feature(feature.value, int(enable)) + await self._write_tap_gatt_char(write_value) + + async def set_vision_sensor_op_mode(self, mode: VisionSensorOpModes, identifier=None): + if not isinstance(mode, VisionSensorOpModes): + raise ValueError("mode must be of type VisionSensorOpModes") + write_value = encoder.encode_set_vision_sensor_op_mode(mode.value) + await self._write_tap_gatt_char(write_value) + + async def set_vision_sensor_model(self, model: ModelTypes, identifier=None): + if not isinstance(model, ModelTypes): + raise ValueError("model must be of type ModelTypes") + write_value = encoder.encode_set_vision_sensor_model(model.value) + await self._write_tap_gatt_char(write_value) + + async def set_imu_sensitivity( + self, + xl_sensitivity: ImuAcclSensitivity, + gyro_sensitivity: ImuGyroSensitivity, + scaled=False, + finger_accl_sens=None, + identifier=None, + ): + if not isinstance(xl_sensitivity, ImuAcclSensitivity): + raise ValueError("xl_sensitivity must be of type ImuAcclSensitivity") + if not isinstance(gyro_sensitivity, ImuGyroSensitivity): + raise ValueError("gyro_sensitivity must be of type ImuGyroSensitivity") + if finger_accl_sens is not None and not isinstance(finger_accl_sens, FingerAcclSensitivity): + raise ValueError("finger_accl_sens must be of type FingerAcclSensitivity") + if scaled: + self._scale_factors = RawSensorsSensitivity( + finger_accl_sens or FingerAcclSensitivity.G2, + gyro_sensitivity, + xl_sensitivity, + ).get_scale_factors() + else: + self._scale_factors = None + write_value = encoder.encode_set_imu_sensitivity( + xl_sensitivity.value, + gyro_sensitivity.value, + ) + await self._write_tap_gatt_char(write_value) + + async def set_haptic_pattern(self, sequence: list, identifier=None): + if not isinstance(sequence, list) or not all(isinstance(i, int) for i in sequence): + raise ValueError("sequence must be a list of integers") + scaled = [max(0, min(255, d // 10)) for d in sequence[:encoder.HAPTIC_UI_DURATION_SLOT_COUNT]] + write_value = encoder.encode_set_haptic_pattern(scaled) + await self._write_tap_gatt_char(write_value) + + async def send_vibration_sequence(self, sequence, identifier=None): + await self.set_haptic_pattern(sequence, identifier=identifier) + + async def send_keepalive_message(self, identifier=None): + write_value = encoder.encode_keepalive_message() + await self._write_tap_gatt_char(write_value) + + async def set_standby_state(self, standby: bool, identifier=None): + write_value = encoder.encode_standby_state_set(standby) + await self._write_tap_gatt_char(write_value) + + async def get_standby_state(self, identifier=None): + return await self._request_and_wait( + ('standby_state',), + encoder.encode_standby_state_get(), + ) + + async def get_feature(self, feature: DeviceFeatures, identifier=None): + if not isinstance(feature, DeviceFeatures): + raise ValueError("feature must be of type DeviceFeatures") + return await self._request_and_wait( + ('config_feature', feature.value), + encoder.encode_get_feature(feature.value), + ) + + async def get_vision_sensor_op_mode(self, identifier=None): + mode_value = await self._request_and_wait( + ('config_vision_op_mode',), + encoder.encode_get_vision_sensor_op_mode(), + ) + return VisionSensorOpModes(mode_value) + + async def get_vision_sensor_model(self, identifier=None): + model_value = await self._request_and_wait( + ('config_vision_model',), + encoder.encode_get_vision_sensor_model(), + ) + return ModelTypes(model_value) + + async def get_imu_sensitivity(self, identifier=None): + gyro_value, xl_value = await self._request_and_wait( + ('config_imu_sensitivity',), + encoder.encode_get_imu_sensitivity(), + ) + return ImuGyroSensitivity(gyro_value), ImuAcclSensitivity(xl_value) + + async def get_device_info(self) -> DeviceInfo: + """Read device name, FW versions, battery, and other public device fields. + + Shared with TapSDK — DIS/BAS and Tap proprietary readable chars, not the + framed v2 command pipe. Missing characteristics yield None. + """ + return await read_device_info(self.client) + + async def start(self): + """Start GATT notifications on an already-connected client.""" + if not client_connected(self.client): + raise ConnectionError("Tap client is not connected; call connect() or run() first") + if self._disconnect_cb: + self.client.set_disconnected_callback(self._disconnect_cb) + await self.client.start_notify(tap_data_read_characteristic, self.on_inc_msg) + self.device_serial_number = await self.client.read_gatt_char( + serial_number_characteristic, + ) + logger.info( + "Device serial number: %s", + self.device_serial_number.decode('utf-8'), + ) + await self.keep_alive_manager.start() + if self.connection_cb: + self.connection_cb(self.device_serial_number) + + async def run(self): + if not client_connected(self.client): + self.client = await connect_tap(address=self._address) + await self.start() diff --git a/tests/test_client_connected.py b/tests/test_client_connected.py new file mode 100644 index 0000000..abcd5b4 --- /dev/null +++ b/tests/test_client_connected.py @@ -0,0 +1,45 @@ +import asyncio +from types import SimpleNamespace + +from tapsdk._transport import client_connected + + +def test_client_connected_plain_bool(): + assert client_connected(SimpleNamespace(is_connected=True)) is True + assert client_connected(SimpleNamespace(is_connected=False)) is False + + +def test_client_connected_bleak012_wrapper_not_called(): + """bleak 0.12 wrapper is callable but must not be called (returns a Future).""" + + class DeprecatedIsConnectedReturn: + def __init__(self, value): + self._value = value + self.calls = 0 + + def __bool__(self): + return self._value + + def __call__(self): + self.calls += 1 + fut = asyncio.get_event_loop().create_future() + fut.set_result(self._value) + return fut + + wrapper = DeprecatedIsConnectedReturn(False) + assert client_connected(SimpleNamespace(is_connected=wrapper)) is False + assert wrapper.calls == 0 + + wrapper_true = DeprecatedIsConnectedReturn(True) + assert client_connected(SimpleNamespace(is_connected=wrapper_true)) is True + assert wrapper_true.calls == 0 + + +def test_client_connected_future_from_callable_not_treated_as_connected(): + def fake_is_connected(): + fut = asyncio.get_event_loop().create_future() + fut.set_result(False) + return fut + + # No _value attr — falls through to callable path; Future.result() is False + assert client_connected(SimpleNamespace(is_connected=fake_is_connected)) is False diff --git a/tests/test_cross_platform.py b/tests/test_cross_platform.py index 0767392..a429c4a 100644 --- a/tests/test_cross_platform.py +++ b/tests/test_cross_platform.py @@ -2,31 +2,31 @@ def test_tapclient_importable(): + import tapsdk._transport as transport import tapsdk.tap as tap + assert hasattr(transport, "TapClient") assert hasattr(tap, "TapClient") + assert tap.TapClient is transport.TapClient def test_platform_ble_backend_is_not_silently_disabled(): """Guard against optional BLE backend imports failing silently. - tapsdk.tap used to swallow ImportError for the platform-specific BLE - backend and fall back to `None` symbols, which let the module import - successfully while every BLE call would later crash with AttributeError. - This asserts the backend symbols used on the running platform were - actually imported. + Platform BLE backends used to be swallowed with ImportError; this asserts + the backend symbols for the running platform were actually imported. """ - import tapsdk.tap as tap + import tapsdk._transport as transport system = platform.system() if system == "Darwin": - assert tap.CBUUID is not None - assert tap.CentralManagerDelegate is not None + assert transport.CBUUID is not None + assert transport.CentralManagerDelegate is not None elif system == "Windows": - assert tap.BluetoothLEDevice is not None - assert tap.BluetoothConnectionStatus is not None - assert tap.BluetoothCacheMode is not None - assert tap.GattSession is not None - assert tap.GattSessionStatus is not None - assert tap.DeviceInformation is not None - assert tap.DeviceInformationKind is not None + assert transport.BluetoothLEDevice is not None + assert transport.BluetoothConnectionStatus is not None + assert transport.BluetoothCacheMode is not None + assert transport.GattSession is not None + assert transport.GattSessionStatus is not None + assert transport.DeviceInformation is not None + assert transport.DeviceInformationKind is not None diff --git a/tests/test_detect.py b/tests/test_detect.py new file mode 100644 index 0000000..2faf4a8 --- /dev/null +++ b/tests/test_detect.py @@ -0,0 +1,36 @@ +from types import SimpleNamespace + +from tapsdk._detect import V2_READ_CHAR, detect_protocol + + +def _fake_client(char_uuids): + chars = [SimpleNamespace(uuid=u) for u in char_uuids] + service = SimpleNamespace(characteristics=chars) + return SimpleNamespace(services=[service]) + + +def test_detect_protocol_v2_when_read_char_present(): + client = _fake_client([ + "c3ff0001-1d8b-40fd-a56f-c7bd5d0f3370", + V2_READ_CHAR, + "c3ff000f-1d8b-40fd-a56f-c7bd5d0f3370", + ]) + assert detect_protocol(client) == "v2" + + +def test_detect_protocol_v1_without_v2_char(): + client = _fake_client([ + "c3ff0005-1d8b-40fd-a56f-c7bd5d0f3370", + "c3ff0006-1d8b-40fd-a56f-c7bd5d0f3370", + ]) + assert detect_protocol(client) == "v1" + + +def test_detect_protocol_v1_when_no_services(): + assert detect_protocol(SimpleNamespace(services=None)) == "v1" + assert detect_protocol(SimpleNamespace(services=[])) == "v1" + + +def test_detect_protocol_case_insensitive(): + client = _fake_client([V2_READ_CHAR.upper()]) + assert detect_protocol(client) == "v2" diff --git a/tests/test_device_info.py b/tests/test_device_info.py index 276305a..9f6af36 100644 --- a/tests/test_device_info.py +++ b/tests/test_device_info.py @@ -1,25 +1,30 @@ import asyncio from unittest.mock import AsyncMock, MagicMock -from tapsdk.tap import DeviceInfo, TapSDK, _format_model_version_hex -from tapsdk.tap import ( +from tapsdk.device_info import ( + DeviceInfo, battery_level_characteristic, device_name_characteristic, firmware_revision_characteristic, + format_model_version_hex, fw_version2_characteristic, gap_device_name_characteristic, hardware_revision_characteristic, manufacturer_name_characteristic, model_version_characteristic, + resolve_device_name, serial_number_characteristic, software_revision_characteristic, ) +from tapsdk.tap import TapSDK, _format_model_version_hex +from tapsdk.tap2 import TapSDK2 def test_format_model_version_hex(): + assert format_model_version_hex("42") == "0x2A" + assert format_model_version_hex("0") == "0x0" + assert format_model_version_hex(None) is None assert _format_model_version_hex("42") == "0x2A" - assert _format_model_version_hex("0") == "0x0" - assert _format_model_version_hex(None) is None def test_get_device_info_reads_dis_and_bas(): @@ -38,14 +43,11 @@ def test_get_device_info_reads_dis_and_bas(): async def read_gatt_char(uuid): return values[uuid] - sdk = TapSDK.__new__(TapSDK) - sdk.client = MagicMock() - sdk.client.name = None - sdk.client.read_gatt_char = AsyncMock(side_effect=read_gatt_char) - - info = asyncio.run(sdk.get_device_info()) + client = MagicMock() + client.name = None + client.read_gatt_char = AsyncMock(side_effect=read_gatt_char) - assert info == DeviceInfo( + expected = DeviceInfo( name="Tap_XR42", fw_version="3.5.24", fw_version2="1.5.24", @@ -57,6 +59,12 @@ async def read_gatt_char(uuid): battery_level=87, ) + for cls in (TapSDK, TapSDK2): + sdk = cls.__new__(cls) + sdk.client = client + info = asyncio.run(sdk.get_device_info()) + assert info == expected + def test_get_device_info_prefers_client_name_and_tolerates_missing_chars(): async def read_gatt_char(uuid): @@ -87,10 +95,9 @@ async def read_gatt_char(uuid): return b"ShouldNotUse" return None - sdk = TapSDK.__new__(TapSDK) - sdk.client = MagicMock() - sdk.client.name = None - sdk.client.read_gatt_char = AsyncMock(side_effect=read_gatt_char) + client = MagicMock() + client.name = None + client.read_gatt_char = AsyncMock(side_effect=read_gatt_char) - name = asyncio.run(sdk._resolve_device_name()) + name = asyncio.run(resolve_device_name(client)) assert name == "Tap_FromGatt" diff --git a/tests/test_encoder.py b/tests/test_encoder.py new file mode 100644 index 0000000..32711f4 --- /dev/null +++ b/tests/test_encoder.py @@ -0,0 +1,14 @@ +from tapsdk import encoder + + +def test_encode_set_haptic_pattern_ui_header_and_zero_padding(): + msg = encoder.encode_set_haptic_pattern([50, 20, 50, 20]) + # external_comm header + 2-byte UI header + 18 duration slots + assert len(msg) == 4 + 2 + encoder.HAPTIC_UI_DURATION_SLOT_COUNT + assert msg[0] == encoder.OutCommandType.PERIPHERAL_COMMAND + assert msg[1] == encoder.OutSubCommandType1.PHERIPHERAL_TYPE_HAPTIC + assert msg[2] == encoder.OutSubCommandType2.SET_HAPTIC_PATTERN + assert msg[4] == encoder.HAPTIC_UI_PERIPHERAL_TYPE + assert msg[5] == encoder.HAPTIC_UI_ACTION_CONSTANT_POWER_SEQUENCE + assert list(msg[6:10]) == [50, 20, 50, 20] + assert all(b == 0 for b in msg[10:]) diff --git a/tests/test_input_mode_writes.py b/tests/test_input_mode_writes.py new file mode 100644 index 0000000..bf5b1a4 --- /dev/null +++ b/tests/test_input_mode_writes.py @@ -0,0 +1,107 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock, call, patch + +from tapsdk.enumerations import InputType +from tapsdk.inputmodes import InputModeController, input_type_command +from tapsdk.tap import TapSDK + + +def test_auto_refresh_waits_before_first_write(): + calls = [] + + async def refresh(): + calls.append("refresh") + + refresh_helper = __import__("tapsdk.tap", fromlist=["InputModeAutoRefresh"]).InputModeAutoRefresh( + refresh, timeout=0.05 + ) + + async def scenario(): + await refresh_helper.start() + await asyncio.sleep(0.02) + assert calls == [] + await asyncio.sleep(0.04) + assert calls == ["refresh"] + await refresh_helper.stop() + + asyncio.run(scenario()) + + +def test_auto_refresh_writes_periodically(): + calls = [] + + async def refresh(): + calls.append("refresh") + + refresh_helper = __import__("tapsdk.tap", fromlist=["InputModeAutoRefresh"]).InputModeAutoRefresh( + refresh, timeout=0.05 + ) + + async def scenario(): + await refresh_helper.start() + await asyncio.sleep(0.02) + assert calls == [] + await asyncio.sleep(0.04) + assert calls == ["refresh"] + await asyncio.sleep(0.06) + assert len(calls) >= 2 + await refresh_helper.stop() + + asyncio.run(scenario()) + + +def test_set_input_mode_also_asserts_current_input_type(): + async def scenario(): + sdk = TapSDK.__new__(TapSDK) + sdk.client = MagicMock() + sdk.input_mode = InputModeController() + sdk.input_type = InputType.AUTO + sdk.input_mode_refresh = MagicMock() + sdk.input_mode_refresh.is_running = True + sdk._write_input_mode = AsyncMock() + + await TapSDK.set_input_mode(sdk, InputModeController()) + assert sdk._write_input_mode.await_args_list == [ + call(InputModeController().get_command()), + call(input_type_command(InputType.AUTO)), + ] + + sdk._write_input_mode.reset_mock() + await TapSDK.set_input_type(sdk, InputType.KEYBOARD) + sdk._write_input_mode.assert_awaited_once_with( + input_type_command(InputType.KEYBOARD) + ) + + sdk._write_input_mode.reset_mock() + await sdk._refresh_input_mode() + assert sdk._write_input_mode.await_args_list == [ + call(InputModeController().get_command()), + call(input_type_command(InputType.KEYBOARD)), + ] + + asyncio.run(scenario()) + + +def test_mode_writes_are_serialized_and_spaced(): + starts = [] + + async def scenario(): + sdk = TapSDK.__new__(TapSDK) + sdk.client = MagicMock() + sdk._mode_write_lock = asyncio.Lock() + + async def write_gatt_char(uuid, value, response=False): + assert response is True + starts.append(asyncio.get_running_loop().time()) + + sdk.client.write_gatt_char = AsyncMock(side_effect=write_gatt_char) + with patch("tapsdk.tap.MODE_COMMAND_SETTLE_SECONDS", 0.01): + await asyncio.gather( + sdk._write_input_mode(InputModeController().get_command()), + sdk._write_input_mode(input_type_command(InputType.KEYBOARD)), + ) + + asyncio.run(scenario()) + + assert len(starts) == 2 + assert starts[1] - starts[0] >= 0.01 diff --git a/tests/test_on_tapped.py b/tests/test_on_tapped.py new file mode 100644 index 0000000..585e4ed --- /dev/null +++ b/tests/test_on_tapped.py @@ -0,0 +1,35 @@ +from unittest.mock import MagicMock + +from tapsdk.enumerations import MouseModes +from tapsdk.tap import TapSDK + + +def _sdk(): + sdk = TapSDK.__new__(TapSDK) + sdk.mouse_mode = MouseModes.STDBY + sdk.tap_event_cb = MagicMock() + sdk.air_gesture_event_cb = MagicMock() + sdk.air_gesture_state_event_cb = None + return sdk + + +def test_standby_delivers_tap_events(): + sdk = _sdk() + sdk.on_tapped("id", bytearray([3, 0, 0, 0])) + sdk.tap_event_cb.assert_called_once_with("id", 3) + + +def test_air_mouse_maps_click_taps_to_gestures_only(): + sdk = _sdk() + sdk.mouse_mode = MouseModes.AIR_MOUSE + sdk.on_tapped("id", bytearray([2, 0, 0, 0])) + sdk.tap_event_cb.assert_not_called() + sdk.air_gesture_event_cb.assert_called_once_with("id", 12) + + +def test_air_mouse_still_delivers_non_click_taps(): + sdk = _sdk() + sdk.mouse_mode = MouseModes.AIR_MOUSE + sdk.on_tapped("id", bytearray([1, 0, 0, 0])) + sdk.tap_event_cb.assert_called_once_with("id", 1) + sdk.air_gesture_event_cb.assert_not_called() diff --git a/tests/test_parsers.py b/tests/test_parsers.py index 993d754..25973ee 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -6,6 +6,11 @@ def test_mouse_data_msg(): assert parsers.mouse_data_msg(data) == (1, 2, True) +def test_mouse_data_msg_with_euler_angles(): + data = bytearray([0, 1, 0, 2, 0, 0, 0, 0, 0, 1, 10, 0, 20, 0, 30, 0]) + assert parsers.mouse_data_msg(data, parse_euler_angles=True) == (1, 2, True, [10, 20, 30]) + + def test_tap_data_msg(): data = bytearray([5]) assert parsers.tap_data_msg(data) == [5] @@ -92,3 +97,84 @@ def test_raw_data_accl_msg_scaled(): 'ts': 456, 'payload': expected }] + + +def test_tap_inc_msg_imu_raw_scaled(): + g_scale = 8.75 + a_scale = 0.244 + ts = 50 + imu_bytes = ts.to_bytes(4, 'little', signed=False) + imu_samples = [10, 20, 30, 40, 50, 60] + payload = b'' + for v in imu_samples: + payload += v.to_bytes(2, 'little', signed=True) + data = bytearray([ + parsers.IncCommandType.IMU_DATA, + parsers.IncSubCommandType1.IMU_RAW_DATA, + 0, + 0, + ]) + imu_bytes + payload + result = parsers.tap_inc_msg(data, scale_factors=[0, g_scale, a_scale]) + expected = [imu_samples[i] * g_scale if i < 3 else imu_samples[i] * a_scale + for i in range(6)] + assert result == { + 'type': 'imu_raw', + 'data': [{'type': 'imu', 'ts': 50, 'payload': expected}], + } + + +def _config_state_packet(subcmd1, payload): + return bytearray([ + parsers.IncCommandType.CONFIG_STATE, + subcmd1, + 0, + 0, + ]) + bytearray(payload) + + +def test_config_state_feature(): + data = _config_state_packet(parsers.IncConfigStateSubCommandType1.FEATURE, [2, 1]) + assert parsers.tap_inc_msg(data) == { + 'type': 'config_feature', + 'data': {'feature_number': 2, 'feature_value': True}, + } + + +def test_config_state_vision_op_mode(): + data = _config_state_packet(parsers.IncConfigStateSubCommandType1.VISION_OP_MODE, [2]) + assert parsers.tap_inc_msg(data) == { + 'type': 'config_vision_op_mode', + 'data': 2, + } + + +def test_config_state_vision_model(): + data = _config_state_packet(parsers.IncConfigStateSubCommandType1.VISION_MODEL, [1]) + assert parsers.tap_inc_msg(data) == { + 'type': 'config_vision_model', + 'data': 1, + } + + +def test_config_state_imu_sensitivity(): + data = _config_state_packet(parsers.IncConfigStateSubCommandType1.IMU_SENSITIVITY, [3, 4]) + assert parsers.tap_inc_msg(data) == { + 'type': 'config_imu_sensitivity', + 'data': (3, 4), + } + + +def test_config_state_haptic_pattern(): + data = _config_state_packet( + parsers.IncConfigStateSubCommandType1.HAPTIC_PATTERN, + [50, 20, 50], + ) + assert parsers.tap_inc_msg(data) == { + 'type': 'config_haptic_pattern', + 'data': [50, 20, 50], + } + + +def test_config_state_feature_short_payload(): + data = _config_state_packet(parsers.IncConfigStateSubCommandType1.FEATURE, [2]) + assert parsers.tap_inc_msg(data) is None