diff --git a/pyproject.toml b/pyproject.toml index 081007bce6..f6f1ab5dd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ maintainers = [ ] license = "Apache-2.0" readme = "README.md" -requires-python = ">=3.12,<3.14" +requires-python = ">=3.12,<3.15" dependencies = [ "bleak >= 0.21", "bleak-retry-connector >= 3.5", @@ -34,6 +34,7 @@ classifiers = [ "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] [project.urls] diff --git a/src/pyftms/client/__init__.py b/src/pyftms/client/__init__.py index 77a937a375..5f8588f601 100644 --- a/src/pyftms/client/__init__.py +++ b/src/pyftms/client/__init__.py @@ -6,10 +6,10 @@ from collections.abc import AsyncIterator from typing import Any -from bleak import BleakScanner +from bleak import BleakClient, BleakScanner from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData -from bleak.exc import BleakDeviceNotFoundError +from bleak.exc import BleakError, BleakDeviceNotFoundError from bleak.uuids import normalize_uuid_str from .backends import ( @@ -32,6 +32,7 @@ MachineType, MovementDirection, SettingRange, + get_machine_type_from_gatt, get_machine_type_from_service_data, ) @@ -97,22 +98,50 @@ async def discover_ftms_devices( """ devices: set[str] = set() - - async with BleakScanner( - service_uuids=[normalize_uuid_str(FTMS_UUID)], - kwargs=kwargs, - ) as scanner: + ftms_uuid = normalize_uuid_str(FTMS_UUID) + + # Scan without a service_uuids filter: on Linux/BlueZ the hardware-level + # UUID filter only matches devices that include FTMS data in the service + # data field of their advertisement. Some devices (e.g. Bodytone DU30) + # advertise the FTMS UUID in service_uuids but provide no service data, so + # they would be silently dropped. We therefore scan for all BLE devices + # and filter manually. + async with BleakScanner(**kwargs) as scanner: try: async with asyncio.timeout(discover_time): async for dev, adv in scanner.advertisement_data(): if dev.address in devices: continue + # Quick pre-filter: skip devices that have no FTMS UUID + # in either service_data or service_uuids. + if ftms_uuid not in adv.service_data and ftms_uuid not in ( + adv.service_uuids or () + ): + continue + try: machine_type = get_machine_type_from_service_data(adv) except NotFitnessMachineError: - continue + # The device advertises the FTMS service UUID but does + # not include machine type in its service data (e.g. + # Bodytone DU30). Fall back to GATT characteristic + # inspection by briefly connecting to the device. + try: + async with BleakClient( + dev, services=[FTMS_UUID] + ) as cli: + machine_type = await get_machine_type_from_gatt( + cli + ) + except (BleakError, NotFitnessMachineError, OSError): + _LOGGER.debug( + "Could not determine machine type for '%s' " + "via GATT fallback.", + dev.address, + ) + continue devices.add(dev.address) diff --git a/src/pyftms/client/properties/__init__.py b/src/pyftms/client/properties/__init__.py index bd4a3d3b1f..ba0f189516 100644 --- a/src/pyftms/client/properties/__init__.py +++ b/src/pyftms/client/properties/__init__.py @@ -9,10 +9,15 @@ SettingRange, read_features, ) -from .machine_type import MachineType, get_machine_type_from_service_data +from .machine_type import ( + MachineType, + get_machine_type_from_gatt, + get_machine_type_from_service_data +) __all__ = [ "DeviceInfo", + "get_machine_type_from_gatt", "get_machine_type_from_service_data", "MachineFeatures", "MachineSettings", diff --git a/src/pyftms/client/properties/machine_type.py b/src/pyftms/client/properties/machine_type.py index bbb2eb20bd..7a3d55527e 100644 --- a/src/pyftms/client/properties/machine_type.py +++ b/src/pyftms/client/properties/machine_type.py @@ -5,10 +5,17 @@ import operator from enum import Flag, auto +from bleak import BleakClient from bleak.backends.scanner import AdvertisementData from bleak.uuids import normalize_uuid_str -from ..const import FTMS_UUID +from ..const import ( + CROSS_TRAINER_DATA_UUID, + FTMS_UUID, + INDOOR_BIKE_DATA_UUID, + ROWER_DATA_UUID, + TREADMILL_DATA_UUID, +) from ..errors import NotFitnessMachineError @@ -79,3 +86,31 @@ def get_machine_type_from_service_data( return mt raise NotFitnessMachineError(data) + + +async def get_machine_type_from_gatt(cli: BleakClient) -> MachineType: + """Determines fitness machine type from connected GATT characteristics. + + Used as a fallback when the device advertises the FTMS service UUID but + does not include machine type information in its advertisement service data + (e.g. Bodytone DU30). + + Parameters: + cli: Connected `BleakClient` instance with FTMS service discovered. + + Returns: + Fitness machine type. + """ + + _UUID_TO_TYPE = ( + (TREADMILL_DATA_UUID, MachineType.TREADMILL), + (CROSS_TRAINER_DATA_UUID, MachineType.CROSS_TRAINER), + (ROWER_DATA_UUID, MachineType.ROWER), + (INDOOR_BIKE_DATA_UUID, MachineType.INDOOR_BIKE), + ) + + for uuid, machine_type in _UUID_TO_TYPE: + if cli.services.get_characteristic(uuid) is not None: + return machine_type + + raise NotFitnessMachineError()