diff --git a/.cursor/rules/diataxis-docs.mdc b/.cursor/rules/diataxis-docs.mdc new file mode 100644 index 0000000..fa5bdd7 --- /dev/null +++ b/.cursor/rules/diataxis-docs.mdc @@ -0,0 +1,40 @@ +--- +description: Diátaxis documentation standards for the Tap Python SDK +globs: docs/**/*.md,Readme.md,mkdocs.yml +alwaysApply: false +--- + +# Diátaxis docs guidelines + +This repository's docs follow [Diátaxis](https://diataxis.fr/). Keep them consistent with that model. + +## Quadrants + +| Quadrant | Path | Purpose | +|----------|------|---------| +| Tutorial | `docs/tutorial/` | Learning-oriented; one successful first path | +| How-to | `docs/how-to/` | Problem-oriented recipes for a specific goal | +| Reference | `docs/reference/` | Information-oriented API / type / event facts | +| Explanation | `docs/explanation/` | Understanding-oriented background | + +`Readme.md` stays a short overview that links into `docs/` and the published MkDocs site. Do not re-inflate it into a full API manual. + +## When a code change requires doc updates + +- Public API, callbacks, command semantics, or example behavior → **Reference** (and How-to / Tutorial if the happy path changed) +- New supported task or platform install step → **How-to** +- Conceptual behavior (modes, connection, sensors) → **Explanation** +- First-run experience changes → **Tutorial** +- Docs-only PRs that already match the change → no further edits + +## Style + +- Prefer clear, unambiguous language; one job per page +- Do not invent features or UUIDs not present in the code +- Keep MkDocs links valid (`mkdocs build --strict` must pass) +- Example file links should point at GitHub `blob` URLs, not `../../examples/...` +- Axis diagrams live under `docs/assets/` + +## Published site + +Configured by `mkdocs.yml` (Material). GitHub Pages workflow: `.github/workflows/docs.yml`. diff --git a/.github/docs-verify-cli.json b/.github/docs-verify-cli.json new file mode 100644 index 0000000..976b64a --- /dev/null +++ b/.github/docs-verify-cli.json @@ -0,0 +1,26 @@ +{ + "permissions": { + "allow": [ + "Read(**/*)", + "Shell(grep)", + "Shell(find)", + "Shell(ls)", + "Shell(git diff)", + "Shell(git log)", + "Shell(git ls-files)", + "Shell(git show)", + "Shell(git status)", + "Shell(test)" + ], + "deny": [ + "Write(**/*)", + "Shell(git commit)", + "Shell(git push)", + "Shell(git checkout)", + "Shell(gh)", + "Shell(rm)", + "Shell(curl)", + "Shell(wget)" + ] + } +} diff --git a/.github/workflows/docs-verify.yml b/.github/workflows/docs-verify.yml new file mode 100644 index 0000000..4261507 --- /dev/null +++ b/.github/workflows/docs-verify.yml @@ -0,0 +1,183 @@ +name: Verify docs + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - "tapsdk/**" + - "examples/**" + - "docs/**" + - "Readme.md" + - "mkdocs.yml" + - "requirements-docs.txt" + - ".github/docs-verify-cli.json" + - ".github/workflows/docs-verify.yml" + workflow_dispatch: + inputs: + pr_number: + description: "PR number to verify (required for manual runs)" + required: true + type: string + +permissions: + contents: read + pull-requests: write + +concurrency: + group: docs-verify-${{ github.event.pull_request.number || inputs.pr_number || github.run_id }} + cancel-in-progress: true + +jobs: + verify-docs: + runs-on: ubuntu-latest + steps: + - name: Resolve PR number + id: pr + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" + echo "base_ref=${{ github.base_ref }}" >> "$GITHUB_OUTPUT" + echo "head_ref=${{ github.head_ref }}" >> "$GITHUB_OUTPUT" + echo "sha=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_OUTPUT" + else + echo "number=${{ inputs.pr_number }}" >> "$GITHUB_OUTPUT" + data=$(gh api "repos/${{ github.repository }}/pulls/${{ inputs.pr_number }}") + echo "base_ref=$(echo "$data" | jq -r .base.ref)" >> "$GITHUB_OUTPUT" + echo "head_ref=$(echo "$data" | jq -r .head.ref)" >> "$GITHUB_OUTPUT" + echo "sha=$(echo "$data" | jq -r .head.sha)" >> "$GITHUB_OUTPUT" + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ steps.pr.outputs.sha }} + + - name: Check CURSOR_API_KEY + id: cursor_key + env: + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + run: | + if [ -z "$CURSOR_API_KEY" ]; then + echo "present=false" >> "$GITHUB_OUTPUT" + echo "::warning::CURSOR_API_KEY is not set. Skipping agent verification. Add it under Settings → Secrets → Actions to enable this check." + else + echo "present=true" >> "$GITHUB_OUTPUT" + fi + + - name: Notice when verification is skipped + if: steps.cursor_key.outputs.present != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + MARKER="" + BODY="${MARKER}"$'\n'"## Docs check"$'\n'$'\n'"Verification skipped: repository secret \`CURSOR_API_KEY\` is not configured."$'\n'$'\n'"Add it from [Cursor API Keys](https://cursor.com/dashboard/api), then re-run **Verify docs**." + PR="${{ steps.pr.outputs.number }}" + existing=$(gh api "repos/${{ github.repository }}/issues/${PR}/comments" \ + --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" | head -n1) + if [ -n "${existing}" ]; then + gh api -X PATCH "repos/${{ github.repository }}/issues/comments/${existing}" -f body="$BODY" + else + gh pr comment "$PR" --body "$BODY" + fi + + - name: Install Cursor CLI + if: steps.cursor_key.outputs.present == 'true' + run: | + curl https://cursor.com/install -fsS | bash + echo "$HOME/.cursor/bin" >> "$GITHUB_PATH" + + - name: Apply verify-only CLI permissions + if: steps.cursor_key.outputs.present == 'true' + run: | + mkdir -p .cursor + cp .github/docs-verify-cli.json .cursor/cli.json + + - name: Verify docs + if: steps.cursor_key.outputs.present == 'true' + id: verify + env: + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + run: | + set -euo pipefail + agent -p --mode ask --trust --output-format text \ + "You are verifying documentation for TapWithUs/tap-python-sdk PR #${{ steps.pr.outputs.number }}. + + Context: + - Base branch: ${{ steps.pr.outputs.base_ref }} + - Head branch: ${{ steps.pr.outputs.head_ref }} + - Guidelines: read .cursor/rules/diataxis-docs.mdc and docs/index.md + - Site config: mkdocs.yml + + Steps: + 1. Inspect the PR diff (git diff origin/${{ steps.pr.outputs.base_ref }}...HEAD). + 2. Decide which Diátaxis quadrants (if any) are affected. + 3. Check that docs/, Readme.md, and mkdocs.yml still match the guidelines and the code change. + 4. Before claiming any file or asset is missing, verify in the checked-out tree: + - git ls-files -- (required for tracked files, including binaries under docs/assets/) + - test -f + Report a path as missing only when both checks show it is absent. + 5. Do not infer absence from the diff alone; binary assets may be added without appearing in text diffs. + 6. Do not invent features. If the PR is docs-only and consistent, status is OK. + + Output markdown ONLY (no preamble), using exactly this template: + + ## Docs check + - Status: OK + - Affected quadrants: none | tutorial | how-to | reference | explanation (comma-separated) + - Gaps: none + - Suggested edits: none + + If updates are required, use Status: NEEDS UPDATES and replace Gaps / Suggested edits + with concrete file paths and what to change. + + Do NOT modify files, commit, push, or call gh." \ + | tee /tmp/docs-check.md + + - name: Comment on PR + if: steps.cursor_key.outputs.present == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + MARKER="" + RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + PR="${{ steps.pr.outputs.number }}" + + if grep -qiE 'Status:[[:space:]]*NEEDS UPDATES' /tmp/docs-check.md || \ + ! grep -qiE 'Status:[[:space:]]*OK' /tmp/docs-check.md; then + { + printf '%s\n' "$MARKER" + printf 'Workflow run: %s\n\n' "$RUN_URL" + cat /tmp/docs-check.md + } > /tmp/docs-comment.md + gh pr comment "$PR" --body-file /tmp/docs-comment.md + else + { + printf '%s\n' "$MARKER" + cat /tmp/docs-check.md + } > /tmp/docs-comment.md + existing=$(gh api "repos/${{ github.repository }}/issues/${PR}/comments" \ + --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" | head -n1) + if [ -n "${existing}" ]; then + gh api -X PATCH "repos/${{ github.repository }}/issues/comments/${existing}" \ + -F "body=@/tmp/docs-comment.md" + else + gh pr comment "$PR" --body-file /tmp/docs-comment.md + fi + fi + + - name: Fail when docs need updates + if: steps.cursor_key.outputs.present == 'true' + run: | + if grep -qiE 'Status:[[:space:]]*NEEDS UPDATES' /tmp/docs-check.md; then + gaps=$(sed -n 's/^- Gaps:[[:space:]]*//p' /tmp/docs-check.md | head -n1) + echo "::error::Documentation updates required (see latest PR comment). ${gaps}" + exit 1 + fi + if ! grep -qiE 'Status:[[:space:]]*OK' /tmp/docs-check.md; then + echo "::error::Docs check output missing Status: OK or NEEDS UPDATES (see latest PR comment)." + exit 1 + fi diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..ff610fc --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,83 @@ +name: Deploy docs + +# Test on this PR: the build job runs automatically on pull_request. +# Test full Pages deploy before merge: Actions → Deploy docs → Run workflow +# (select this branch). Requires Pages source = GitHub Actions in repo settings. +on: + push: + branches: [master] + paths: + - "docs/**" + - "docs/assets/**" + - "mkdocs.yml" + - "requirements-docs.txt" + - ".github/workflows/docs.yml" + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - "docs/**" + - "docs/assets/**" + - "mkdocs.yml" + - "requirements-docs.txt" + - ".github/workflows/docs.yml" + workflow_dispatch: + inputs: + deploy: + description: "Upload artifact and deploy to GitHub Pages" + type: boolean + default: true + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Cache MkDocs + uses: actions/cache@v4 + with: + path: ~/.cache + key: mkdocs-material-${{ hashFiles('requirements-docs.txt') }} + restore-keys: | + mkdocs-material- + + - name: Install docs dependencies + run: pip install -r requirements-docs.txt + + - name: Build site + run: mkdocs build --strict --clean + + - name: Upload Pages artifact + if: > + github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && inputs.deploy) + uses: actions/upload-pages-artifact@v3 + with: + path: site + + deploy: + if: > + github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && inputs.deploy) + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/Readme.md b/Readme.md index b391a2c..8561afe 100644 --- a/Readme.md +++ b/Readme.md @@ -1,290 +1,70 @@ ## TapStrap Python SDK (beta) -### What Is This ? +[![PyPI version](https://img.shields.io/pypi/v/tap-python-sdk.svg)](https://pypi.org/project/tap-python-sdk/) -TAP python SDK allows you to build python app that can establish BLE connection with Tap Strap and TapXR, send commands and receive events and data - Thus allowing TAP to act as a controller for your app! -The library is developed with Python >= 3.9 and is **currently in beta**. +BLE SDK for building Python apps that connect to **Tap Strap** and **TapXR**, send commands, and receive tap, mouse, air-gesture, and raw sensor events. +**Python ≥ 3.9** · **macOS / Windows / Linux** · **currently in beta** -### Supported Platforms -This package supports the following platforms: -* MacOS (tested on 10.15.2) - using Apple's CoreBluetooth library. The library depends on PyObjC which Apple includes with their Python version on OSX. Note that if you're using a different Python, be sure to install PyObjC for that version of Python. -* Windows 10 - using [Bleak](https://github.com/hbldh/bleak) with WinRT for BLE connectivity (no external DLL required). -* Linux (tested on Ubuntu 18.04) - need to install libbluetooth-dev and bluez-tools - ``` - sudo apt-get install bluez-tools libbluetooth-dev - ``` - also the user needs to be in the bluetooth group: - ``` - sudo usermod -G bluetooth -a - #and can reload groups in this shell by running the following command or by logging out and back in: - su - $USER - ``` +### Documentation +Published docs (MkDocs Material): [https://tapwithus.github.io/tap-python-sdk/](https://tapwithus.github.io/tap-python-sdk/) -### Installation +Pick the path that matches your goal: -Install the package from PyPI: -```console -pip install tap-python-sdk -``` +| I want to… | Go to | +|------------|--------| +| Get a first working connection | [Tutorial: Getting started](docs/tutorial/getting-started.md) | +| Solve a specific task | [How-to guides](docs/how-to/index.md) | +| Look up APIs and types | [Reference](docs/reference/index.md) | +| Understand modes and sensors | [Explanation](docs/explanation/index.md) | + +Full index: [docs/index.md](docs/index.md). Local preview: `pip install -r requirements-docs.txt && mkdocs serve`. + +### Install -Or clone this repo and install the package locally: ```console -git clone https://github.com/TapWithUs/tap-python-sdk -cd tap-python-sdk -pip install . +pip install tap-python-sdk ``` +Platform notes (BlueZ on Linux, Bleak pins, pairing): [Install the SDK](docs/how-to/install.md). -The SDK is asyncio-based. Pair your Tap device with the OS first, then connect and register callbacks inside an async entry point: +### Quick example ```python import asyncio -from tapsdk import TapSDK +from tapsdk import TapSDK, InputModeController async def main(): - tap_device = TapSDK() - tap_device.register_tap_events(lambda identifier, tapcode: print(identifier, tapcode)) - await tap_device.run() # connects to a paired Tap, or scans until one is found + tap = TapSDK() + tap.register_tap_events(lambda identifier, tapcode: print(identifier, tapcode)) + await tap.run() + await tap.set_input_mode(InputModeController()) + await asyncio.Event().wait() asyncio.run(main()) ``` -If no Tap is already connected at the OS level, `run()` will scan and wait for a device. On Windows and MacOS it also polls for already-paired devices that reconnect without advertising. - -Also make sure that you have updated your Tap device to the latest version. - -### Features - -This SDK implements two basic interfaces with a Tap device. - -First is setting the operation mode: - -1. *Text mode* - the Tap device will operate normally, with no events being sent to the SDK -2. *Controller mode* - the Tap device will send events to the SDK -3. *Controller and Text mode* - the Tap device will operate normally, in parallel with sending events to the SDK -4. *Raw data mode* - the Tap device will stream raw sensors data to the SDK. - -Second, subscribing to the following events: - -1. *Tap event* - whenever a tap event has occurred -2. *Mouse event* - whenever a mouse movement has occurred -3. *AirGesture event* - whenever one of the gestures is detected -4. *Raw data* - whenever new raw data sample is being made. - -Additional to these functional events, there are also some state events, such as connection and disconnection of Tap devices to the SDK backend. - -#### Spatial Control - NEW -Authorized developers can gain access to the experimental Spatial Control features: -1. Extended AirGesture state - enabling aggregation for pinch, drag and swipe gestures. -2. Select input type - enabling the selection of input type to be activated - i.e. AirMouse/Tapping. - -These features are only available on TapXR and only for qualified developers. Request access [here](https://www.tapwithus.com/contact-us/) - - -### Migration from 0.6.x - -If you are upgrading from an earlier release, note these breaking changes: - -* `TapInputMode("controller")` → `InputModeController()` (and similarly for other modes). Import from `tapsdk`. -* `TapInputMode("raw", sensitivity=[2, 1, 4])` → `InputModeRaw(finger_accl_sens=..., imu_gyro_sens=..., imu_accl_sens=...)`. Use the typed enums in `tapsdk.enumerations`. -* `from tapsdk.models import AirGestures` → `from tapsdk import AirGestures` -* The `loop=` constructor argument has been removed. -* Event registration (`register_*`) is synchronous; call it before `await tap_device.run()`. -* Commands (`set_input_mode`, `set_input_type`, `send_vibration_sequence`) are async and must be awaited. -* OS-specific examples (`example_unix.py`, `example_win.py`) have been replaced by a single cross-platform `examples/basic.py`. - - -### High level API -The SDK uses callbacks to implement user functions on the various events. To register a callback, you just have to instance a TapSDK object and: - -```python -def on_tap_event(identifier, tapcode): - print(identifier + " tapped " + str(tapcode)) - -tap_device.register_tap_events(on_tap_event) -``` -#### Commands list -1. ```set_input_mode(self, input_mode:InputMode, identifier=None):``` -This function sends a mode selection command. It accepts an object of type ```InputMode``` such as ```InputModeText```, ```InputModeController```, ```InputModeControllerText```, or ```InputModeRaw```. -For example: - ```python - from tapsdk import InputModeController - await tap_device.set_input_mode(InputModeController()) - ``` - For raw sensors mode, you can specify sensitivity and scaling: - ```python - from tapsdk import InputModeRaw - from tapsdk.enumerations import FingerAcclSensitivity, ImuGyroSensitivity, ImuAcclSensitivity - await tap_device.set_input_mode(InputModeRaw( - scaled=True, - finger_accl_sens=FingerAcclSensitivity.G4, - imu_gyro_sens=ImuGyroSensitivity.DPS250, - imu_accl_sens=ImuAcclSensitivity.G4 - )) - ``` -2. ```set_input_type(self, input_type:InputType, identifier=None):``` - > **Only for TapXR and with Spatial Control experimental firmware** - - This function sends a command to force input type. It accepts an enum of type ```InputType``` initialized with any of the types ```InputType.MOUSE```, ```InputType.KEYBOARD```, or ```InputType.AUTO```. - For example: - ```python - from tapsdk import InputType - await tap_device.set_input_type(InputType.AUTO) - ``` - This will set the input to be automatically selected by the Tap device, based on hand posture. - -3. ```send_vibration_sequence(self, sequence:list, identifier=None):``` -This function sends a series of haptic activations. ```sequence``` is a list of integers indicating the activation and delay periods one after another. The periods are in millisecond units, in the range of [10,2550] and in resolution of 10ms. Each haptic command supports up to 18 period definitions (i.e. 9 haptics + delay pairs). -For example: - ```python - await tap_device.send_vibration_sequence(sequence=[1000,300,200]) - ``` - will trigger a 1s haptic, followed by 300ms delay, followed by 200ms haptic. - -4. ```get_device_info(self) -> DeviceInfo:``` -Reads public device information (bonded connection required). Returns a ```DeviceInfo``` with ```name```, ```fw_version```, ```fw_version2```, ```model_version``` (hex, e.g. ```0x2A```), ```hardware_revision```, ```serial_number```, ```manufacturer```, ```software_revision``` (bootloader on Tap), and ```battery_level``` (0–100). Missing fields are ```None``` (e.g. ```fw_version2``` / ```model_version``` on devices that do not expose them). - ```python - info = await tap_device.get_device_info() - print(info.name, info.fw_version, info.fw_version2, info.model_version, info.battery_level) - ``` - - -#### Events list -1. ```register_connection_events(self, listener:Callable):``` -Register callback to a Tap strap connection event. - ```python - def on_connect(tap_sdk_instance): - print("Connected to Tap device") +Pair the Tap with the OS first. Update firmware with Tap Manager. More complete flow: [`examples/basic.py`](examples/basic.py). - tap_device.register_connection_events(on_connect) - ``` +### Features (summary) -2. ```register_disconnection_events(self, listener:Callable):``` -Register callback to a Tap strap disconnection event. - ```python - def on_disconnect(client): - print("Tap device disconnected") +- **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 +- **Spatial Control** (authorized TapXR builds): see [Use Spatial Control](docs/how-to/use-spatial-control.md) - tap_device.register_disconnection_events(on_disconnect) - ``` +### Migrating from 0.6.x -3. ```register_tap_events(self, listener:Callable):``` -Register callback to a tap event. - ```python - def on_tap_event(identifier, tapcode): - print(identifier + " - tapped " + str(tapcode)) - - tap_device.register_tap_events(on_tap_event) - ``` - ```tapcode``` is an 8-bit unsigned number, between 1 and 31 which is formed by a binary representation of the fingers that are tapped. -The LSb is thumb finger, the MSb is the pinky finger. -For example: if combination equals 5 - its binary form is 10100 - means that the thumb and the middle fingers were tapped. - - -4. ```register_mouse_events(self, listener:Callable):``` -Register callback to a mouse or air mouse movement event. - ```python - def on_mouse_event(identifier, vx, vy, proximity): - print(identifier + " - moused: %d, %d" %(vx, vy)) - - tap_device.register_mouse_events(on_mouse_event) - ``` - ```vx``` and ```vy``` are the horizontal and vertical velocities of the mouse movement respectively. -```proximity``` is a boolean that indicates proximity with a surface. -5. ```register_raw_data_events(self, listener:Callable):``` -Register callback to raw sensors data packet received event. - ```python - def on_raw_sensor_data(identifier, packets): - for packet in packets: - print(identifier, packet["type"], packet["ts"], packet["payload"]) - - tap_device.register_raw_data_events(on_raw_sensor_data) - ``` - The callback receives a list of dicts, each with keys `type` (`"imu"` or `"accl"`), `ts` (millisecond timestamp), and `payload` (list of sample values). When `InputModeRaw(scaled=True)` is active, values are in mg and mdps. - You'll find more information on that mode in the dedicated section below or [here](https://tapwithus.atlassian.net/wiki/spaces/TD/pages/792002574/Tap+Strap+Raw+Sensors+Mode). - -6. ```register_air_gesture_events(self, listener:Callable):``` -Register callback to air gesture events. - ```python - from tapsdk import AirGestures - - def on_airgesture(identifier, gesture): - print(identifier + " - gesture: " + str(AirGestures(gesture))) - - tap_device.register_air_gesture_events(on_airgesture) - ``` - ```gesture``` is an integer code of the air gesture detected. The air gesture values are enumerated in the ```AirGestures``` class, including directional gestures (`UP_ONE_FINGER`, `PINCH`, etc.), thumb gestures (`THUMB_FINGER`, `THUMB_MIDDLE`), and spatial state gestures (`STATE_OPEN`, `STATE_FIST`, etc.). - -7. ```register_air_gesture_state_events(self, listener:Callable):``` -Register callback to mouse-mode state changes (e.g. air mouse, optical mouse). - ```python - from tapsdk.enumerations import MouseModes - - def on_mouse_mode_change(identifier, mouse_mode): - print(identifier + " - mode: " + str(mouse_mode)) - - tap_device.register_air_gesture_state_events(on_mouse_mode_change) - ``` - ```mouse_mode``` is a ```MouseModes``` enum value: `STDBY`, `AIR_MOUSE`, `OPTICAL1`, or `OPTICAL2`. - -### Raw sensors mode - -**Make sure that "Developer mode" is enabled on TapManager app for this mode to work properly** - -In raw sensors mode, the Tap device continuously sends raw data from the following sensors: -1. Five 3-axis accelerometers - one per each finger (**available only on TAP Strap and Tap Strap 2**). - * sampled at 200Hz - * allows dynamic range configuration (±2G, ±4G, ±8G, ±16G) -2. IMU (3-axis accelerometer + gyro) located on the thumb (**available on TAP Strap 2 and TapXR**). - * sampled at 208Hz. - * allows dynamic range configuration for the accelerometer (±2G, ±4G, ±8G, ±16G) and for the gyro (±125dps, ±250dps, ±500dps, ±1000dps, ±2000dps). - -The sensors measurements are given with respect to the reference system below. - -![alt text](TAP-axis-alpha.png "Tap Strap reference frame") -![alt text](TAPXR-axis.png "TapXR reference frame") - -Each sample (of accelerometer or imu) is preambled with a millisecond timestamp, referenced to an internal Tap clock. - - -The dynamic range of the sensors is determined with the ```set_input_mode``` method by passing an ```InputModeRaw``` instance with the desired sensitivity enums, and a boolean flag indicating if the data should be scaled to mg and mdps for the accelerometer and gyro respectively: -```python -from tapsdk import InputModeRaw -from tapsdk.enumerations import FingerAcclSensitivity, ImuGyroSensitivity, ImuAcclSensitivity - -await tap_device.set_input_mode(InputModeRaw( - scaled=True, - finger_accl_sens=FingerAcclSensitivity.G4, - imu_gyro_sens=ImuGyroSensitivity.DPS250, - imu_accl_sens=ImuAcclSensitivity.G4 -)) -``` -Refer to `FingerAcclSensitivity`, `ImuGyroSensitivity`, and `ImuAcclSensitivity` in [`tapsdk.enumerations`](tapsdk/enumerations.py) for the available sensitivity values. - -### Examples - -You can find some examples in the [examples folder](examples). +Breaking API changes are listed in [Migrate from 0.6](docs/how-to/migrate-from-0.6.md) and [History.md](History.md). ### Testing -To run the tests, first install the development dependencies: - ```bash pip install .[dev] -``` - -Then run the tests using pytest: - -```bash pytest ``` - -### Known Issues -See [History.md](History.md) for release notes. No known issues at 0.7.0. - ### Support -Please refer to the issues tab! :) +Use the [GitHub issues](https://github.com/TapWithUs/tap-python-sdk/issues) tab. diff --git a/docs/assets/TAP-axis-alpha.png b/docs/assets/TAP-axis-alpha.png new file mode 100644 index 0000000..948f797 Binary files /dev/null and b/docs/assets/TAP-axis-alpha.png differ diff --git a/docs/assets/TAPXR-axis.png b/docs/assets/TAPXR-axis.png new file mode 100644 index 0000000..f413d0d Binary files /dev/null and b/docs/assets/TAPXR-axis.png differ diff --git a/docs/explanation/connection-model.md b/docs/explanation/connection-model.md new file mode 100644 index 0000000..15d8e6f --- /dev/null +++ b/docs/explanation/connection-model.md @@ -0,0 +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. + +## 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. + +Platform differences matter: + +- **macOS** retrieves already-connected peripherals that expose the Tap service. +- **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`. + +## 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. + +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. diff --git a/docs/explanation/index.md b/docs/explanation/index.md new file mode 100644 index 0000000..dca09fe --- /dev/null +++ b/docs/explanation/index.md @@ -0,0 +1,9 @@ +# Explanation + +Understanding-oriented background. For steps, use the [tutorial](../tutorial/getting-started.md) or [how-to guides](../how-to/index.md). + +| Topic | Page | +|-------|------| +| How the SDK talks to Tap over BLE | [Connection model](connection-model.md) | +| Why input modes exist | [Input modes](input-modes.md) | +| Raw sensors, frames, and scaling | [Raw sensors](raw-sensors.md) | diff --git a/docs/explanation/input-modes.md b/docs/explanation/input-modes.md new file mode 100644 index 0000000..12c25f8 --- /dev/null +++ b/docs/explanation/input-modes.md @@ -0,0 +1,14 @@ +# Input modes + +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. + +The SDK models that personality as **input modes**: + +- **Text** — OS-facing HID behavior; your Python callbacks stay quiet for taps. +- **Controller** — events are for your app; classic typing to the OS is not the goal. +- **Controller + Text** — parallel paths when users still need to type. +- **Raw** — bypass gesture interpretation and ship sensor samples. + +Modes are orthogonal to **Spatial Control input type** (mouse vs keyboard vs auto) on TapXR. Mode answers “who receives data and in what form?”; input type answers “which XR input modality is forced?” when you have experimental firmware access. + +Switching modes is a small binary command on the NUS RX characteristic. The SDK owns the byte layout so applications work with typed classes instead of magic numbers. diff --git a/docs/explanation/raw-sensors.md b/docs/explanation/raw-sensors.md new file mode 100644 index 0000000..8a3b859 --- /dev/null +++ b/docs/explanation/raw-sensors.md @@ -0,0 +1,30 @@ +# Raw sensors + +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 + +1. **Finger accelerometers** (Tap Strap / Tap Strap 2): one 3-axis sensor per finger, ~200 Hz, configurable ±2/4/8/16 g. +2. **Thumb IMU** (Tap Strap 2 / TapXR): accelerometer + gyro, ~208 Hz, configurable accelerometer and gyro ranges. + +Samples are timestamped in milliseconds on an internal device clock. Timestamps are not synchronized to wall time by the SDK. + +## Scaling + +Firmware sends integer LSB counts. `InputModeRaw(scaled=True)` multiplies by the scale factors that belong to the selected sensitivity enums, yielding **mg** (accelerometer) and **mdps** (gyro). If you scale yourself, keep `scaled=False` and use `RawSensorsSensitivity.get_scale_factors()`. + +You cannot change sensitivity while remaining in raw mode with a different command; leave raw mode, then re-enter with new enums. + +## Reference frames + +Axes are defined relative to the hardware: + +![Tap Strap reference frame](../assets/TAP-axis-alpha.png) + +![TapXR reference frame](../assets/TAPXR-axis.png) + +Additional protocol notes: [Tap Strap Raw Sensors Mode](https://tapwithus.atlassian.net/wiki/spaces/TD/pages/792002574/Tap+Strap+Raw+Sensors+Mode) (internal Confluence). + +## Developer mode + +Raw streaming expects Developer mode enabled in Tap Manager. Without it, characteristics may be present but the stream may not behave as documented. diff --git a/docs/how-to/connect-and-listen.md b/docs/how-to/connect-and-listen.md new file mode 100644 index 0000000..9289094 --- /dev/null +++ b/docs/how-to/connect-and-listen.md @@ -0,0 +1,66 @@ +# Connect and listen for events + +## Connect + +Pair the Tap with the OS first. Then: + +```python +import asyncio +from tapsdk import TapSDK + +async def main(): + tap = TapSDK() + await tap.run() + +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). + +## Connection and disconnection callbacks + +Register callbacks before `await run()`: + +```python +def on_connect(sdk): + print("connected", sdk) + +def on_disconnect(client): + print("disconnected", client) + +tap = TapSDK() +tap.register_connection_events(on_connect) +tap.register_disconnection_events(on_disconnect) +``` + +`on_connect` receives the `TapSDK` instance. `on_disconnect` receives the underlying Bleak client (platform-dependent). + +## Subscribe to input events + +```python +from tapsdk import AirGestures +from tapsdk.enumerations import MouseModes + +tap.register_tap_events(lambda id, tapcode: print("tap", id, tapcode)) +tap.register_mouse_events(lambda id, vx, vy, prox: print("mouse", vx, vy, prox)) +tap.register_air_gesture_events( + lambda id, gesture: print("gesture", AirGestures(gesture)) +) +tap.register_air_gesture_state_events( + lambda id, mode: print("mouse mode", MouseModes(mode)) +) +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). + +## Keep the process alive + +`run()` returns after notifications are set up. Keep the event loop running, for example: + +```python +await tap.run() +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. diff --git a/docs/how-to/index.md b/docs/how-to/index.md new file mode 100644 index 0000000..99b3778 --- /dev/null +++ b/docs/how-to/index.md @@ -0,0 +1,13 @@ +# How-to guides + +Problem-oriented recipes for common Tap Python SDK tasks. + +| Task | Guide | +|------|-------| +| 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) | +| Play a haptic pattern | [Send haptics](send-haptics.md) | +| Force mouse or keyboard on TapXR | [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/install.md b/docs/how-to/install.md new file mode 100644 index 0000000..f8ee78e --- /dev/null +++ b/docs/how-to/install.md @@ -0,0 +1,49 @@ +# Install the SDK + +## From PyPI + +```bash +pip install tap-python-sdk +``` + +## From source + +```bash +git clone https://github.com/TapWithUs/tap-python-sdk +cd tap-python-sdk +pip install . +``` + +For development (tests and flake8): + +```bash +pip install .[dev] +``` + +## Platform prerequisites + +### macOS + +Uses Apple CoreBluetooth via Bleak. If you use a non-system Python, install PyObjC for that interpreter. The SDK pins `bleak==0.12.1` on Darwin. + +### Windows 10+ + +Uses Bleak with WinRT (`bleak==0.22.3` and `bleak-winrt==1.2.0`). No external DLL is required. + +### Linux + +Install BlueZ tools and grant Bluetooth access: + +```bash +sudo apt-get install bluez-tools libbluetooth-dev +sudo usermod -G bluetooth -a +su - $USER +``` + +The device’s Bluetooth name must start with `Tap` for the Linux discovery path. + +## Verify the import + +```bash +python -c "from tapsdk import TapSDK; print('ok')" +``` diff --git a/docs/how-to/migrate-from-0.6.md b/docs/how-to/migrate-from-0.6.md new file mode 100644 index 0000000..5cb6765 --- /dev/null +++ b/docs/how-to/migrate-from-0.6.md @@ -0,0 +1,42 @@ +# Migrate from 0.6.x to 0.7.x + +## Input modes + +```python +# 0.6 +TapInputMode("controller") +TapInputMode("raw", sensitivity=[2, 1, 4]) + +# 0.7 +from tapsdk import InputModeController, InputModeRaw +from tapsdk.enumerations import FingerAcclSensitivity, ImuGyroSensitivity, ImuAcclSensitivity + +InputModeController() +InputModeRaw( + finger_accl_sens=FingerAcclSensitivity.G4, + imu_gyro_sens=ImuGyroSensitivity.DPS125, + imu_accl_sens=ImuAcclSensitivity.G8, +) +``` + +## Imports + +```python +# 0.6 +from tapsdk.models import AirGestures + +# 0.7 +from tapsdk import AirGestures +``` + +## Async API + +- Removed: `loop=` constructor argument. +- `register_*` remains synchronous — call before `await run()`. +- `set_input_mode`, `set_input_type`, and `send_vibration_sequence` are **async** and must be awaited. + +## Examples and backends + +- Use [`examples/basic.py`](https://github.com/TapWithUs/tap-python-sdk/blob/master/examples/basic.py) instead of `example_unix.py` / `example_win.py`. +- Windows no longer uses `TAPWin.dll`; Bleak/WinRT is required. +- Python 3.9+ is required. diff --git a/docs/how-to/send-haptics.md b/docs/how-to/send-haptics.md new file mode 100644 index 0000000..e816e4d --- /dev/null +++ b/docs/how-to/send-haptics.md @@ -0,0 +1,21 @@ +# Send haptic (vibration) sequences + +```python +await tap.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. + +The list alternates **on** and **off** durations. The example above vibrates for 1 s, pauses 300 ms, then vibrates 200 ms. + +## 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). + +## Example pattern + +```python +# short buzz, pause, short buzz, pause, long buzz +await tap.send_vibration_sequence([100, 200, 100, 200, 500]) +``` diff --git a/docs/how-to/stream-raw-sensors.md b/docs/how-to/stream-raw-sensors.md new file mode 100644 index 0000000..b4cbf87 --- /dev/null +++ b/docs/how-to/stream-raw-sensors.md @@ -0,0 +1,45 @@ +# Stream raw sensor data + +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 + +```python +from tapsdk import InputModeRaw +from tapsdk.enumerations import ( + FingerAcclSensitivity, + ImuGyroSensitivity, + ImuAcclSensitivity, +) + +tap.register_raw_data_events(on_raw) + +await tap.set_input_mode(InputModeRaw( + scaled=True, + finger_accl_sens=FingerAcclSensitivity.G4, + imu_gyro_sens=ImuGyroSensitivity.DPS250, + imu_accl_sens=ImuAcclSensitivity.G4, +)) +``` + +With `scaled=True`, accelerometer values are in mg and gyro values in mdps. With `scaled=False`, payloads are raw LSB counts. + +## Handle packets + +```python +def on_raw(identifier, packets): + for packet in packets: + # packet["type"]: "imu" or "accl" + # packet["ts"]: ms timestamp from the device clock + # packet["payload"]: list of samples + print(identifier, packet["type"], packet["ts"], packet["payload"]) +``` + +- **`imu`:** 6 values — gyro x/y/z then accelerometer x/y/z on the thumb. +- **`accl`:** 15 values — 3-axis accelerometer for thumb, index, middle, ring, pinky (Tap Strap family). + +## Sensitivity options + +See [`FingerAcclSensitivity`](../reference/enumerations.md), [`ImuGyroSensitivity`](../reference/enumerations.md), and [`ImuAcclSensitivity`](../reference/enumerations.md). + +For coordinate frames and sampling rates, see [Raw sensors explained](../explanation/raw-sensors.md). diff --git a/docs/how-to/switch-input-modes.md b/docs/how-to/switch-input-modes.md new file mode 100644 index 0000000..720570c --- /dev/null +++ b/docs/how-to/switch-input-modes.md @@ -0,0 +1,36 @@ +# Switch input modes + +Input mode controls whether the Tap talks to the OS as a keyboard/mouse, streams events to your app, or both. + +## Choose a mode + +```python +from tapsdk import ( + InputModeText, + InputModeController, + InputModeControllerText, + InputModeRaw, +) + +await tap.set_input_mode(InputModeText()) # OS typing only; no SDK tap events +await tap.set_input_mode(InputModeController()) # SDK events only +await tap.set_input_mode(InputModeControllerText()) # both +await tap.set_input_mode(InputModeRaw(...)) # raw sensor stream +``` + +## When to use each + +| 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 + Text | Users still type while your app also listens | +| Raw | You need accelerometer / IMU samples | + +## Refresh behavior + +After the first `set_input_mode` / `set_input_type`, the SDK periodically rewrites the mode so the device stays in sync if the link hiccups. You do not need to call refresh yourself. + +## Changing raw sensitivities + +You cannot change raw sensitivity enums while already in raw mode with a different command payload. Leave raw mode first (for example switch to Controller), then enter raw again with the new settings. See [Stream raw sensors](stream-raw-sensors.md). diff --git a/docs/how-to/use-spatial-control.md b/docs/how-to/use-spatial-control.md new file mode 100644 index 0000000..cdf3001 --- /dev/null +++ b/docs/how-to/use-spatial-control.md @@ -0,0 +1,33 @@ +# Use Spatial Control (TapXR) + +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 + +```python +from tapsdk import InputType + +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 +``` + +Combine with a controller-capable [input mode](switch-input-modes.md) so your app receives events. + +## Extended air gestures + +Register both gesture and state callbacks: + +```python +from tapsdk import AirGestures +from tapsdk.enumerations import MouseModes + +tap.register_air_gesture_events( + lambda id, g: print(AirGestures(g)) +) +tap.register_air_gesture_state_events( + lambda id, mode: print(MouseModes(mode)) +) +``` + +State events report mouse-mode changes (`STDBY`, `AIR_MOUSE`, `OPTICAL1`, `OPTICAL2`). Gesture values include directional swipes, pinches, thumb contacts, and fist/open states — see [AirGestures](../reference/enumerations.md). diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..9dc8248 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,22 @@ +# Tap Python SDK documentation + +Pick the section that matches what you need: + +| Goal | Section | +|------|---------| +| Learn by doing — first working connection | [Tutorial](tutorial/getting-started.md) | +| Solve a specific task | [How-to guides](how-to/index.md) | +| Look up an API, type, or event | [Reference](reference/index.md) | +| Understand how modes and sensors work | [Explanation](explanation/index.md) | + +## Package + +- **PyPI:** [`tap-python-sdk`](https://pypi.org/project/tap-python-sdk/) +- **Import name:** `tapsdk` +- **Python:** 3.9+ +- **Status:** beta +- **Source:** [TapWithUs/tap-python-sdk](https://github.com/TapWithUs/tap-python-sdk) + +## Platforms + +macOS (CoreBluetooth), Windows 10+ (Bleak/WinRT), and Linux (BlueZ). Pair the Tap with the OS before running your app for the most reliable connection path. diff --git a/docs/reference/enumerations.md b/docs/reference/enumerations.md new file mode 100644 index 0000000..097ebde --- /dev/null +++ b/docs/reference/enumerations.md @@ -0,0 +1,76 @@ +# Enumerations + +All live in `tapsdk.enumerations`. `InputType` and `AirGestures` are also re-exported from `tapsdk`. + +## `InputType` + +Spatial Control input selection. + +| Member | Value | +|--------|-------| +| `MOUSE` | 1 | +| `KEYBOARD` | 2 | +| `AUTO` | 3 | + +## `MouseModes` + +Reported by air-gesture state events (`0x14` notifications). + +| Member | Value | +|--------|-------| +| `STDBY` | 0 | +| `AIR_MOUSE` | 1 | +| `OPTICAL1` | 2 | +| `OPTICAL2` | 3 | + +## `AirGestures` + +| Member | Value | +|--------|-------| +| `NONE` | 0 | +| `GENERAL` | 1 | +| `UP_ONE_FINGER` | 2 | +| `UP_TWO_FINGERS` | 3 | +| `DOWN_ONE_FINGER` | 4 | +| `DOWN_TWO_FINGERS` | 5 | +| `LEFT_ONE_FINGER` | 6 | +| `LEFT_TWO_FINGERS` | 7 | +| `RIGHT_ONE_FINGER` | 8 | +| `RIGHT_TWO_FINGERS` | 9 | +| `PINCH` | 10 | +| `THUMB_FINGER` | 12 | +| `THUMB_MIDDLE` | 14 | +| `STATE_OPEN` | 100 | +| `STATE_THUMB_FINGER` | 101 | +| `STATE_THUMB_MIDDLE` | 102 | +| `STATE_THUMB_RING` | 103 | +| `STATE_THUMB_PINKY` | 104 | +| `STATE_FIST` | 105 | + +## `FingerAcclSensitivity` + +| Member | Approx. range | +|--------|----------------| +| `G2` | ±2 g | +| `G4` | ±4 g | +| `G8` | ±8 g | +| `G16` | ±16 g | + +## `ImuGyroSensitivity` + +| Member | Approx. range | +|--------|----------------| +| `DPS125` | ±125 °/s | +| `DPS250` | ±250 °/s | +| `DPS500` | ±500 °/s | +| `DPS1000` | ±1000 °/s | +| `DPS2000` | ±2000 °/s | + +## `ImuAcclSensitivity` + +| Member | Approx. range | +|--------|----------------| +| `G2` | ±2 g | +| `G4` | ±4 g | +| `G8` | ±8 g | +| `G16` | ±16 g | diff --git a/docs/reference/events.md b/docs/reference/events.md new file mode 100644 index 0000000..8159254 --- /dev/null +++ b/docs/reference/events.md @@ -0,0 +1,70 @@ +# 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. + +## Connection + +```text +register_connection_events(cb) +cb(tap_sdk: TapSDK) -> None +``` + +Called after GATT notifications are started successfully. + +```text +register_disconnection_events(cb) +cb(client) -> None +``` + +Passed through to Bleak’s disconnected callback. + +## Tap + +```text +register_tap_events(cb) +cb(identifier, tapcode: int) -> 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. + +While air-mouse mode is active, tapcodes `2` and `4` are remapped into air-gesture handling instead of the tap callback. + +## Mouse + +```text +register_mouse_events(cb) +cb(identifier, vx: int, vy: int, proximity: bool) -> None +``` + +`vx` / `vy` are signed velocities. `proximity` is `True` when a surface is detected. + +## Air gesture + +```text +register_air_gesture_events(cb) +cb(identifier, gesture: int) -> None +``` + +`gesture` matches [`AirGestures`](enumerations.md). + +```text +register_air_gesture_state_events(cb) +cb(identifier, mouse_mode: MouseModes) -> None +``` + +Fired when the device reports mouse-mode changes (`0x14` payload). + +## Raw sensors + +```text +register_raw_data_events(cb) +cb(identifier, packets: list[dict]) -> None +``` + +Each dict: + +| Key | Type | Description | +|-----|------|-------------| +| `type` | `str` | `"imu"` or `"accl"` | +| `ts` | `int` | Device timestamp (ms) | +| `payload` | `list` | Sample values (scaled or raw LSB) | diff --git a/docs/reference/index.md b/docs/reference/index.md new file mode 100644 index 0000000..c1cd0bc --- /dev/null +++ b/docs/reference/index.md @@ -0,0 +1,11 @@ +# Reference + +Information-oriented descriptions of the public API. For recipes, see [How-to guides](../how-to/index.md). For concepts, see [Explanation](../explanation/index.md). + +| Topic | Page | +|-------|------| +| `TapSDK` class | [TapSDK](tapsdk.md) | +| Input mode classes | [Input modes](input-modes.md) | +| Enums | [Enumerations](enumerations.md) | +| Event callbacks | [Events](events.md) | +| Package exports | [Package](package.md) | diff --git a/docs/reference/input-modes.md b/docs/reference/input-modes.md new file mode 100644 index 0000000..f73530f --- /dev/null +++ b/docs/reference/input-modes.md @@ -0,0 +1,56 @@ +# Input modes + +Defined in `tapsdk.inputmodes`. Prefer importing the concrete classes from `tapsdk`. + +## Base: `InputMode` + +| Attribute / method | Description | +|--------------------|-------------| +| `COMMAND_PREFIX` | `bytearray([0x3, 0xc, 0x0])` | +| `get_command()` | Full GATT write payload: prefix + mode `code` | +| `name` | Human-readable label | + +## `InputModeText` + +Normal Tap operation for the OS. Mode code `0x00`. SDK tap events are not produced. + +## `InputModeController` + +Controller-only. Mode code `0x01`. Device sends tap / mouse / gesture events to the SDK. + +## `InputModeControllerText` + +Combined Text + Controller. Mode code `0x05`. + +## `InputModeRaw` + +```python +InputModeRaw( + scaled=False, + finger_accl_sens=None, + imu_gyro_sens=None, + imu_accl_sens=None, +) +``` + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `scaled` | `False` | If `True`, raw payloads are multiplied by sensitivity scale factors (mg / mdps) | +| `finger_accl_sens` | `FingerAcclSensitivity.G2` | Finger accelerometer range | +| `imu_gyro_sens` | `ImuGyroSensitivity.DPS125` | IMU gyro range | +| `imu_accl_sens` | `ImuAcclSensitivity.G2` | IMU accelerometer range | + +Mode code: `0x0a` followed by the three sensitivity enum values. + +### `RawSensorsSensitivity` + +Internal helper used by `InputModeRaw`. + +| Method | Returns | +|--------|---------| +| `tolist()` | `[finger, gyro, imu_accl]` integer values for the command | +| `get_scale_factors()` | `[finger_mg_per_lsb, gyro_mdps_per_lsb, imu_mg_per_lsb]` | + +## `input_type_command(input_type)` + +Builds the Spatial Control write: `bytearray([0x3, 0xd, 0x0, input_type.value])`. Used by `TapSDK.set_input_type`. diff --git a/docs/reference/package.md b/docs/reference/package.md new file mode 100644 index 0000000..5bbb784 --- /dev/null +++ b/docs/reference/package.md @@ -0,0 +1,39 @@ +# Package layout + +## Public imports (`tapsdk`) + +| Name | Kind | +|------|------| +| `TapSDK` | Class (lazy import from `tapsdk.tap`) | +| `InputModeText` | Class | +| `InputModeController` | Class | +| `InputModeControllerText` | Class | +| `InputModeRaw` | Class | +| `InputType` | Enum | +| `AirGestures` | Enum | +| `DeviceInfo` | Dataclass (lazy import from `tapsdk.tap`) | + +Version string: `tapsdk.__version__`. + +## Modules + +| Module | Role | +|--------|------| +| `tapsdk.tap` | BLE client, `TapSDK`, GATT UUIDs | +| `tapsdk.inputmodes` | Mode command builders | +| `tapsdk.enumerations` | Public enums | +| `tapsdk.parsers` | Notification payload parsers | + +## 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_mode_characteristic` | `6e400002-…` | NUS RX — mode / input-type commands (write) | +| `raw_sensors_characteristic` | `6e400003-…` | NUS TX — raw stream (notify) | + +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 new file mode 100644 index 0000000..d1ae3ac --- /dev/null +++ b/docs/reference/tapsdk.md @@ -0,0 +1,107 @@ +# TapSDK + +Primary entry point. Import with `from tapsdk import TapSDK`. + +Construction imports a platform BLE backend (macOS, Windows, or Linux). Creating `TapSDK` on an unsupported platform, or with the wrong Bleak pin, raises `ImportError`. + +## Constructor + +```python +TapSDK(address=None) +``` + +| Parameter | Description | +|-----------|-------------| +| `address` | Optional BLE address / platform device id. On Linux, if omitted, the SDK picks a connected device whose name starts with `Tap`. | + +## Connection + +### `async run()` + +Connect to a Tap and start GATT notifications for tap, mouse, air-gesture, and raw characteristics. + +- 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. + +## Commands + +### `async set_input_mode(input_mode, identifier=None)` + +Write an [input mode](input-modes.md) command to the device. + +| Parameter | Description | +|-----------|-------------| +| `input_mode` | Instance of `InputModeText`, `InputModeController`, `InputModeControllerText`, or `InputModeRaw` | +| `identifier` | Reserved for multi-device use; currently unused | + +Starts periodic mode refresh on first call. Changing raw sensitivities while already in a different raw configuration is rejected with a warning. + +### `async set_input_type(input_type, identifier=None)` + +TapXR Spatial Control only. Force mouse, keyboard, or automatic input selection. + +| Parameter | Description | +|-----------|-------------| +| `input_type` | `InputType.MOUSE`, `InputType.KEYBOARD`, or `InputType.AUTO` | +| `identifier` | Reserved; currently unused | + +### `async send_vibration_sequence(sequence, identifier=None)` + +Send haptic on/off periods. + +| Parameter | Description | +|-----------|-------------| +| `sequence` | List of integers (ms). Each value is clamped to 0–2550 and stored as `value // 10`. Max length 18. | +| `identifier` | Reserved; currently unused | + +### `async get_device_info() -> DeviceInfo` + +Read public device information. Requires a bonded connection (DIS/BAS characteristics are encrypted on Tap firmware). Missing characteristics yield `None` for that field. + +Returns a `DeviceInfo` dataclass: + +| Field | Description | +|-------|-------------| +| `name` | Device name | +| `fw_version` | Firmware revision | +| `fw_version2` | Secondary firmware version (`None` if absent) | +| `model_version` | Model version as hex (e.g. `0x2A`; `None` if absent) | +| `hardware_revision` | Hardware revision | +| `serial_number` | Serial number | +| `manufacturer` | Manufacturer name | +| `software_revision` | Bootloader revision on Tap | +| `battery_level` | 0–100 | + +```python +from tapsdk import DeviceInfo + +info = await tap_device.get_device_info() +print(info.name, info.fw_version, info.model_version, info.battery_level) +``` + +## Event registration + +All `register_*` methods are synchronous. Pass a callable; pass `None` is not required to clear (re-assign by registering again). See [Events](events.md). + +| Method | Callback signature | +|--------|-------------------| +| `register_connection_events` | `(tap_sdk)` | +| `register_disconnection_events` | `(client)` — Bleak disconnected callback | +| `register_tap_events` | `(identifier, tapcode)` | +| `register_mouse_events` | `(identifier, vx, vy, proximity)` | +| `register_air_gesture_events` | `(identifier, gesture)` | +| `register_air_gesture_state_events` | `(identifier, mouse_mode)` | +| `register_raw_data_events` | `(identifier, packets)` | + +## Attributes (runtime) + +| Attribute | Meaning | +|-----------|---------| +| `client` | Underlying `TapClient` / `BleakClient` | +| `input_mode` | Last requested `InputMode` instance | +| `input_type` | Last requested `InputType` | +| `mouse_mode` | Current `MouseModes` from air-gesture state notifications | diff --git a/docs/tutorial/getting-started.md b/docs/tutorial/getting-started.md new file mode 100644 index 0000000..8d99f1e --- /dev/null +++ b/docs/tutorial/getting-started.md @@ -0,0 +1,88 @@ +# Getting started + +This tutorial walks you through installing the SDK, connecting to a Tap, and printing tap events. By the end you will have a small asyncio program that talks to a real device. + +## What you need + +- Python 3.9 or newer +- A Tap Strap or TapXR, updated with Tap Manager +- The Tap already paired with your computer over Bluetooth + +## 1. Install the SDK + +```bash +pip install tap-python-sdk +``` + +On Linux, also install BlueZ tooling and add your user to the `bluetooth` group: + +```bash +sudo apt-get install bluez-tools libbluetooth-dev +sudo usermod -G bluetooth -a "$USER" +su - "$USER" +``` + +## 2. Create a project file + +Create `hello_tap.py`: + +```python +import asyncio +from tapsdk import TapSDK, InputModeController + + +def on_tap(identifier, tapcode): + print(f"{identifier} tapped {tapcode}") + + +def on_connect(sdk): + print("Connected to Tap") + + +async def main(): + tap = TapSDK() + tap.register_connection_events(on_connect) + tap.register_tap_events(on_tap) + + await tap.run() + await tap.set_input_mode(InputModeController()) + + # Keep receiving events + await asyncio.Event().wait() + + +asyncio.run(main()) +``` + +## 3. Run it + +1. Turn the Tap on and confirm it is connected in the OS Bluetooth settings. +2. Run: + +```bash +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: + +```text +XX:XX:XX:XX:XX:XX tapped 5 +``` + +`tapcode` is a bitmask of fingers (bit 0 = thumb … bit 4 = pinky). `5` means thumb + middle. + +## 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. + +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. + +## 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) diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..4f758ff --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,91 @@ +site_name: Tap Python SDK +site_description: BLE SDK documentation for Tap Strap and TapXR +site_url: https://tapwithus.github.io/tap-python-sdk/ +site_author: Tap Systems Inc. + +repo_name: TapWithUs/tap-python-sdk +repo_url: https://github.com/TapWithUs/tap-python-sdk +edit_uri: edit/master/docs/ + +docs_dir: docs +site_dir: site + +theme: + name: material + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + - navigation.sections + - navigation.expand + - navigation.top + - navigation.footer + - search.suggest + - search.highlight + - content.code.copy + - content.action.edit + - toc.follow + +plugins: + - search + +markdown_extensions: + - admonition + - attr_list + - def_list + - tables + - toc: + permalink: true + - pymdownx.details + - pymdownx.superfences + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.tabbed: + alternate_style: true + +nav: + - Home: index.md + - Tutorial: + - Getting started: tutorial/getting-started.md + - How-to guides: + - Overview: how-to/index.md + - Install the SDK: how-to/install.md + - 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 + - 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 + - Input modes: reference/input-modes.md + - Enumerations: reference/enumerations.md + - Events: reference/events.md + - Package: reference/package.md + - Explanation: + - Overview: explanation/index.md + - Connection model: explanation/connection-model.md + - Input modes: explanation/input-modes.md + - Raw sensors: explanation/raw-sensors.md + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/TapWithUs/tap-python-sdk + - icon: fontawesome/brands/python + link: https://pypi.org/project/tap-python-sdk/ diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 0000000..d752c76 --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1,2 @@ +mkdocs>=1.6,<2 +mkdocs-material>=9.5,<10 diff --git a/tapsdk/__init__.py b/tapsdk/__init__.py index b0c2227..a1af53d 100644 --- a/tapsdk/__init__.py +++ b/tapsdk/__init__.py @@ -1,3 +1,9 @@ +"""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. +""" + from tapsdk.enumerations import InputType, AirGestures # noqa: F401 from tapsdk.inputmodes import InputModeRaw, InputModeController, InputModeText, InputModeControllerText # noqa: F401 diff --git a/tapsdk/enumerations.py b/tapsdk/enumerations.py index 9ce68f9..9848363 100644 --- a/tapsdk/enumerations.py +++ b/tapsdk/enumerations.py @@ -2,6 +2,8 @@ class MouseModes(Enum): + """Mouse / air-mouse state reported by air-gesture state events.""" + STDBY = 0 AIR_MOUSE = 1 OPTICAL1 = 2 @@ -9,12 +11,16 @@ class MouseModes(Enum): class InputType(Enum): + """Spatial Control input modality (TapXR experimental firmware).""" + MOUSE = 1 KEYBOARD = 2 AUTO = 3 class AirGestures(Enum): + """Air-gesture and spatial-state codes from gesture notifications.""" + NONE = 0 GENERAL = 1 UP_ONE_FINGER = 2 @@ -37,6 +43,8 @@ class AirGestures(Enum): class FingerAcclSensitivity(Enum): + """Dynamic range for per-finger accelerometers in raw mode.""" + G2 = 1 G4 = 2 G8 = 3 @@ -44,6 +52,8 @@ class FingerAcclSensitivity(Enum): class ImuGyroSensitivity(Enum): + """Dynamic range for the thumb IMU gyroscope in raw mode.""" + DPS125 = 1 DPS250 = 2 DPS500 = 3 @@ -52,6 +62,8 @@ class ImuGyroSensitivity(Enum): class ImuAcclSensitivity(Enum): + """Dynamic range for the thumb IMU accelerometer in raw mode.""" + G2 = 1 G4 = 2 G8 = 3 diff --git a/tapsdk/inputmodes.py b/tapsdk/inputmodes.py index 02947e1..8a67af9 100644 --- a/tapsdk/inputmodes.py +++ b/tapsdk/inputmodes.py @@ -6,6 +6,8 @@ class RawSensorsSensitivity(): + """Maps sensitivity enums to command bytes and physical scale factors.""" + finger_acc_scales = [None, 3.91, 7.81, 15.62, 31.25] # mg/lsb imu_gyro_scales = [None, 4.375, 8.75, 17.5, 35, 70] # mdps/lsb imu_acc_scales = [None, 0.061, 0.122, 0.244, 0.488] # mg/lsb @@ -21,16 +23,21 @@ def __init__(self, finger_accl_sens, imu_gyro_sens, imu_accl_sens): ] def tolist(self): + """Return ``[finger, gyro, imu_accl]`` enum values for the mode command.""" return self.sens_values def get_scale_factors(self): + """Return ``[finger_mg/lsb, gyro_mdps/lsb, imu_mg/lsb]``.""" return self.scale_factors class InputMode: + """Base class for Tap input-mode GATT commands.""" + COMMAND_PREFIX = bytearray([0x3, 0xc, 0x0]) def get_command(self): + """Return the full write payload for this mode.""" return self.COMMAND_PREFIX + self.code def __repr__(self): @@ -38,26 +45,41 @@ def __repr__(self): class InputModeController(InputMode): + """Controller-only mode: SDK receives tap/mouse/gesture events.""" + def __init__(self): self.name = "Controller Mode" self.code = bytearray([0x1]) class InputModeText(InputMode): + """Text mode: normal OS keyboard/mouse behavior; no SDK tap events.""" + def __init__(self): self.name = "Text Mode" self.code = bytearray([0x0]) class InputModeControllerText(InputMode): + """Combined Text and Controller mode.""" + def __init__(self): self.name = "Controller and Text Mode" self.code = bytearray([0x5]) class InputModeRaw(InputMode): + """Raw sensor streaming mode with optional physical-unit scaling.""" + def __init__(self, scaled=False, finger_accl_sens=None, imu_gyro_sens=None, imu_accl_sens=None): + """ + Args: + scaled: If True, parse payloads into mg / mdps using scale factors. + finger_accl_sens: ``FingerAcclSensitivity`` (default ``G2``). + imu_gyro_sens: ``ImuGyroSensitivity`` (default ``DPS125``). + imu_accl_sens: ``ImuAcclSensitivity`` (default ``G2``). + """ self.name = "Raw sensors Mode" self.scaled = scaled self.sensitivity = RawSensorsSensitivity(finger_accl_sens or FingerAcclSensitivity.G2, @@ -67,5 +89,6 @@ def __init__(self, scaled=False, finger_accl_sens=None, def input_type_command(input_type): + """Build the Spatial Control input-type write payload.""" assert isinstance(input_type, InputType), "input_type must be of type InputType" return bytearray([0x3, 0xd, 0x0, input_type.value]) diff --git a/tapsdk/parsers.py b/tapsdk/parsers.py index e883e5a..6345f7b 100644 --- a/tapsdk/parsers.py +++ b/tapsdk/parsers.py @@ -3,6 +3,7 @@ def tapcode_to_fingers(tapcode: int): def mouse_data_msg(data: bytearray): + """Parse a mouse notification into ``(vx, vy, proximity)``.""" vx = int.from_bytes(data[1:3], "little", signed=True) vy = int.from_bytes(data[3:5], "little", signed=True) prox = data[9] == 1 @@ -10,31 +11,40 @@ def mouse_data_msg(data: bytearray): def air_gesture_data_msg(data: bytearray): + """Parse an air-gesture notification into ``[gesture_code]``.""" return [data[0]] def tap_data_msg(data: bytearray): + """Parse a tap notification into ``[tapcode]``.""" return [data[0]] def raw_data_msg(data: bytearray, scale_factors=None): - ''' - Parses raw data messages into structured data with optional scaling. - Raw data is packed into messages with the following structure: + """Parse raw sensor notifications into structured packets. + + Raw data is packed into messages with the following structure:: + [msg_type (1 bit)][timestamp (31 bit)][payload (12 - 30 bytes)] * msg type - '0' for imu message - - '1' for accelerometers message - * timestamp - unsigned int, given in milliseconds - * payload - for imu message is 12 bytes - composed by a series of 6 uint16 numbers - representing [g_x, g_y, g_z, xl_x, xl_y, xl_z] - - for accelerometers message is 30 bytes - composed by a series of 15 uint16 numbers - representing [xl_x_thumb , xl_y_thumb, xl_z_thumb, - xl_x_finger, xl_y_finger, xl_z_finger, - ...] - - ''' + - '1' for accelerometers message + * timestamp - unsigned int, given in milliseconds + * payload - for imu message is 12 bytes + composed by a series of 6 uint16 numbers + representing [g_x, g_y, g_z, xl_x, xl_y, xl_z] + - for accelerometers message is 30 bytes + composed by a series of 15 uint16 numbers + representing [xl_x_thumb , xl_y_thumb, xl_z_thumb, + xl_x_finger, xl_y_finger, xl_z_finger, + ...] + + Args: + data: GATT notification payload. + scale_factors: Optional ``[finger_mg, gyro_mdps, imu_mg]`` multipliers. + + Returns: + List of dicts with keys ``type``, ``ts``, and ``payload``. + """ L = len(data) ptr = 0 messages = [] diff --git a/tapsdk/tap.py b/tapsdk/tap.py index 407188f..c1d03ae 100644 --- a/tapsdk/tap.py +++ b/tapsdk/tap.py @@ -276,7 +276,21 @@ def _format_model_version_hex(value: Optional[str]) -> Optional[str]: class TapSDK(): + """High-level async API for one Tap Strap / TapXR over BLE. + + 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``. + """ + def __init__(self, **kwargs): + """Create an SDK instance. + + Args: + 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")) self.mouse_event_cb = None self.tap_event_cb = None @@ -295,24 +309,31 @@ def _client_connected(client) -> bool: return is_connected() if callable(is_connected) else is_connected def register_tap_events(self, cb: Callable): + """Register ``cb(identifier, tapcode)`` for tap events.""" self.tap_event_cb = cb def register_mouse_events(self, cb: Callable): + """Register ``cb(identifier, vx, vy, proximity)`` for mouse motion.""" self.mouse_event_cb = cb def register_air_gesture_events(self, cb: Callable): + """Register ``cb(identifier, gesture)`` for air-gesture codes.""" self.air_gesture_event_cb = cb def register_air_gesture_state_events(self, cb: Callable): + """Register ``cb(identifier, mouse_mode)`` for mouse-mode changes.""" self.air_gesture_state_event_cb = cb def register_raw_data_events(self, cb: Callable): + """Register ``cb(identifier, packets)`` for raw sensor batches.""" self.raw_data_event_cb = cb def register_connection_events(self, cb: Callable): + """Register ``cb(tap_sdk)`` called after notifications are started.""" self.connection_cb = cb def register_disconnection_events(self, cb: Callable): + """Register Bleak's disconnected callback ``cb(client)``.""" self.client.set_disconnected_callback(cb) def on_moused(self, identifier, data): @@ -398,6 +419,13 @@ async def get_device_info(self) -> DeviceInfo: ) async def send_vibration_sequence(self, sequence, identifier=None): + """Send a haptic on/off sequence. + + Args: + sequence: Periods in milliseconds (10–2550, 10 ms steps). Alternating + on/off durations. At most 18 values; longer lists are truncated. + identifier: Reserved for multi-device use; currently unused. + """ if len(sequence) > 18: sequence = sequence[:18] for i, d in enumerate(sequence): @@ -407,6 +435,12 @@ async def send_vibration_sequence(self, sequence, identifier=None): await self.client.write_gatt_char(ui_cmd_characteristic, write_value) async def set_input_mode(self, input_mode: InputMode, identifier=None): + """Set Text, Controller, Controller+Text, or Raw input mode. + + Args: + input_mode: An ``InputMode`` instance from ``tapsdk``. + identifier: Reserved for multi-device use; currently unused. + """ if (isinstance(input_mode, InputModeRaw) and isinstance(self.input_mode, InputModeRaw) and self.input_mode.get_command() != input_mode.get_command()): logger.warning("Can't change \"raw\" sensitivities while in \"raw\"") @@ -421,6 +455,12 @@ async def set_input_mode(self, input_mode: InputMode, identifier=None): 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). + + Args: + input_type: ``InputType.MOUSE``, ``KEYBOARD``, or ``AUTO``. + identifier: Reserved for multi-device use; currently unused. + """ 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) @@ -440,6 +480,13 @@ async def _write_input_mode(self, value): await self.client.write_gatt_char(tap_mode_characteristic, value) async def run(self): + """Connect to a Tap and start GATT notifications. + + Attaches to an already-connected device when possible; otherwise scans + (and on Windows polls for paired reconnects). Invokes the connection + 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