From 97ea7298e32d0ea7c3c507e19d8b6e6daccca116 Mon Sep 17 00:00:00 2001 From: Liron Date: Wed, 22 Jul 2026 10:57:46 +0300 Subject: [PATCH] Add get_device_info() API for Tap device metadata (#41) Read name, FW versions, model version (hex), hardware/serial, manufacturer, bootloader, and battery from standard BLE DIS/BAS and Tap service characteristics. Resolve device name from c3ff0003 when Bleak has no cached name. Document API, extend basic example, and add unit tests. Co-authored-by: Cursor --- Readme.md | 7 +++ examples/basic.py | 14 ++++++ tapsdk/__init__.py | 4 ++ tapsdk/tap.py | 91 ++++++++++++++++++++++++++++++++++++- tests/test_device_info.py | 96 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 tests/test_device_info.py diff --git a/Readme.md b/Readme.md index 7000386..b391a2c 100644 --- a/Readme.md +++ b/Readme.md @@ -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):``` diff --git a/examples/basic.py b/examples/basic.py index b32dc09..6ac23d8 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -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) diff --git a/tapsdk/__init__.py b/tapsdk/__init__.py index 41603a0..b0c2227 100644 --- a/tapsdk/__init__.py +++ b/tapsdk/__init__.py @@ -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}") diff --git a/tapsdk/tap.py b/tapsdk/tap.py index 255d6c6..407188f 100644 --- a/tapsdk/tap.py +++ b/tapsdk/tap.py @@ -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 @@ -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: @@ -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")) @@ -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] diff --git a/tests/test_device_info.py b/tests/test_device_info.py new file mode 100644 index 0000000..276305a --- /dev/null +++ b/tests/test_device_info.py @@ -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"