Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,13 @@ For example:
```
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):```
Expand Down
14 changes: 14 additions & 0 deletions examples/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@ async def run():
await client.run()
logger.info("Connected: %s", client.client.is_connected)

info = await client.get_device_info()
logger.info(
"Device info: name=%s fw=%s fw2=%s model=%s hw=%s serial=%s mfg=%s bl=%s batt=%s",
info.name,
info.fw_version,
info.fw_version2,
info.model_version,
info.hardware_revision,
info.serial_number,
info.manufacturer,
info.software_revision,
info.battery_level,
)

logger.info("Set Controller Mode for 5 seconds")
await client.set_input_mode(im.InputModeController())
await asyncio.sleep(5)
Expand Down
4 changes: 4 additions & 0 deletions tapsdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,8 @@ def __getattr__(name):
from tapsdk.tap import TapSDK

return TapSDK
if name == "DeviceInfo":
from tapsdk.tap import DeviceInfo

return DeviceInfo
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
91 changes: 90 additions & 1 deletion tapsdk/tap.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import asyncio
import logging
import platform
from typing import Callable
from dataclasses import dataclass
from typing import Callable, Optional

from bleak import BleakClient, BleakScanner

Expand All @@ -17,9 +18,38 @@
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:
Expand Down Expand Up @@ -236,6 +266,15 @@ def get_mac_addr() -> str:
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


class TapSDK():
def __init__(self, **kwargs):
self.client = TapClient(address=kwargs.get("address"))
Expand Down Expand Up @@ -308,6 +347,56 @@ 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),
)

async def send_vibration_sequence(self, sequence, identifier=None):
if len(sequence) > 18:
sequence = sequence[:18]
Expand Down
96 changes: 96 additions & 0 deletions tests/test_device_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import asyncio
from unittest.mock import AsyncMock, MagicMock

from tapsdk.tap import DeviceInfo, TapSDK, _format_model_version_hex
from tapsdk.tap import (
battery_level_characteristic,
device_name_characteristic,
firmware_revision_characteristic,
fw_version2_characteristic,
gap_device_name_characteristic,
hardware_revision_characteristic,
manufacturer_name_characteristic,
model_version_characteristic,
serial_number_characteristic,
software_revision_characteristic,
)


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


def test_get_device_info_reads_dis_and_bas():
values = {
device_name_characteristic: b"Tap_XR42",
firmware_revision_characteristic: b"3.5.24",
fw_version2_characteristic: b"1.5.24",
model_version_characteristic: b"42",
hardware_revision_characteristic: b"4.4",
serial_number_characteristic: b"ABCDEF0123456789",
manufacturer_name_characteristic: b"TAP Systems",
software_revision_characteristic: b"012",
battery_level_characteristic: bytes([87]),
}

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())

assert info == DeviceInfo(
name="Tap_XR42",
fw_version="3.5.24",
fw_version2="1.5.24",
model_version="0x2A",
hardware_revision="4.4",
serial_number="ABCDEF0123456789",
manufacturer="TAP Systems",
software_revision="012",
battery_level=87,
)


def test_get_device_info_prefers_client_name_and_tolerates_missing_chars():
async def read_gatt_char(uuid):
if uuid == firmware_revision_characteristic:
return b"1.2.3"
raise Exception("missing")

sdk = TapSDK.__new__(TapSDK)
sdk.client = MagicMock()
sdk.client.name = "Tap_FromScan"
sdk.client.read_gatt_char = AsyncMock(side_effect=read_gatt_char)

info = asyncio.run(sdk.get_device_info())

assert info.name == "Tap_FromScan"
assert info.fw_version == "1.2.3"
assert info.fw_version2 is None
assert info.model_version is None
assert info.battery_level is None
assert info.serial_number is None


def test_resolve_device_name_falls_back_to_tap_characteristic():
async def read_gatt_char(uuid):
if uuid == device_name_characteristic:
return b"Tap_FromGatt"
if uuid == gap_device_name_characteristic:
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)

name = asyncio.run(sdk._resolve_device_name())
assert name == "Tap_FromGatt"
Loading