From 47db30f669b595d3d62aa35cccc6825f67ec3c75 Mon Sep 17 00:00:00 2001 From: netsoft-ruidias Date: Mon, 18 May 2026 18:19:53 +0100 Subject: [PATCH 1/5] Add Bodytone DU30-B1D8 Fitness Bike --- src/pyftms/client/__init__.py | 27 ++++++++++++-- src/pyftms/client/properties/__init__.py | 7 +++- src/pyftms/client/properties/machine_type.py | 37 +++++++++++++++++++- 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/pyftms/client/__init__.py b/src/pyftms/client/__init__.py index 77a937a375..cba82a08f9 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, ) @@ -112,7 +113,27 @@ async def discover_ftms_devices( 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. + if normalize_uuid_str(FTMS_UUID) not in adv.service_uuids: + continue + + 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() From 021491ede67bb5dc83a5a69797f5bde573bc450d Mon Sep 17 00:00:00 2001 From: netsoft-ruidias Date: Mon, 18 May 2026 18:58:37 +0100 Subject: [PATCH 2/5] Add fallback --- src/pyftms/client/__init__.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/pyftms/client/__init__.py b/src/pyftms/client/__init__.py index cba82a08f9..5f8588f601 100644 --- a/src/pyftms/client/__init__.py +++ b/src/pyftms/client/__init__.py @@ -98,17 +98,28 @@ 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) @@ -117,9 +128,6 @@ async def discover_ftms_devices( # not include machine type in its service data (e.g. # Bodytone DU30). Fall back to GATT characteristic # inspection by briefly connecting to the device. - if normalize_uuid_str(FTMS_UUID) not in adv.service_uuids: - continue - try: async with BleakClient( dev, services=[FTMS_UUID] From 0376d9fd690118a94fff070ed6ad3c008b54298a Mon Sep 17 00:00:00 2001 From: netsoft-ruidias Date: Mon, 18 May 2026 20:00:43 +0100 Subject: [PATCH 3/5] bump python dependency --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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] From bbeae6cd00ab0479dc956fb864f4fb185623484e Mon Sep 17 00:00:00 2001 From: netsoft-ruidias Date: Sat, 6 Jun 2026 11:20:16 +0100 Subject: [PATCH 4/5] update --- diagnose_bt.py | 70 +++++++++++++++++++++++++++++++++++++++++++++++++ instructions.md | 4 +++ 2 files changed, 74 insertions(+) create mode 100644 diagnose_bt.py create mode 100644 instructions.md diff --git a/diagnose_bt.py b/diagnose_bt.py new file mode 100644 index 0000000000..2ecb4d9e44 --- /dev/null +++ b/diagnose_bt.py @@ -0,0 +1,70 @@ +""" +Bluetooth diagnostic script. +Scans ALL nearby BLE devices (no UUID filter) and shows advertisement details. +Run with: python diagnose_bt.py +""" + +import asyncio +from bleak import BleakScanner +from bleak.uuids import normalize_uuid_str + +FTMS_UUID = normalize_uuid_str("1826") +SCAN_TIME = 10 # seconds + + +async def main(): + print(f"Scanning ALL BLE devices for {SCAN_TIME}s (no filter)...\n") + + found = {} + + def callback(device, adv): + if device.address not in found: + found[device.address] = (device, adv) + + async with BleakScanner(detection_callback=callback): + await asyncio.sleep(SCAN_TIME) + + if not found: + print("No BLE devices found at all.") + print("Possible causes:") + print(" - Bluetooth adapter not available") + print(" - BlueZ not running (check: sudo systemctl status bluetooth)") + print(" - Home Assistant holding exclusive access to the adapter") + return + + print(f"Found {len(found)} BLE device(s):\n") + + ftms_devices = [] + + for addr, (dev, adv) in sorted(found.items()): + has_ftms = FTMS_UUID in (adv.service_uuids or []) + marker = " <-- FTMS (fitness machine!)" if has_ftms else "" + print(f" [{addr}] {dev.name or '(no name)'}{marker}") + print(f" RSSI: {adv.rssi} dBm") + if adv.service_uuids: + print(f" Service UUIDs: {list(adv.service_uuids)}") + if adv.service_data: + print(f" Service Data: {adv.service_data}") + if adv.manufacturer_data: + mfr = {k: v.hex() for k, v in adv.manufacturer_data.items()} + print(f" Manufacturer: {mfr}") + print() + + if has_ftms: + ftms_devices.append((dev, adv)) + + print("-" * 60) + if ftms_devices: + print(f"FTMS devices found: {len(ftms_devices)}") + for dev, adv in ftms_devices: + has_service_data = FTMS_UUID in (adv.service_data or {}) + print(f" {dev.address} ({dev.name})") + print(f" Has FTMS service data: {has_service_data}") + if not has_service_data: + print(" NOTE: No FTMS service data -> requires GATT fallback") + else: + print("No FTMS devices found in the raw scan.") + print("Check that the bike is powered on and in Bluetooth range.") + + +asyncio.run(main()) diff --git a/instructions.md b/instructions.md new file mode 100644 index 0000000000..da03b35862 --- /dev/null +++ b/instructions.md @@ -0,0 +1,4 @@ +# PowerShell + +usbipd list +usbipd attach --wsl --busid 2-5 From ce1670d05358b62a7777434d7e608f6cf139e70c Mon Sep 17 00:00:00 2001 From: netsoft-ruidias Date: Sat, 6 Jun 2026 16:51:29 +0100 Subject: [PATCH 5/5] fix: guard against missing _cli in _on_disconnect A D-Bus disconnect event can fire before self._cli is assigned during connection setup (bleak_retry_connector registers the disconnect callback before establish_connection returns). This causes an AttributeError when _on_disconnect calls del self._cli on a fresh IndoorBike instance. Fix: check with hasattr before deleting _cli. --- src/pyftms/client/client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pyftms/client/client.py b/src/pyftms/client/client.py index 8dca257fa1..beda8db620 100644 --- a/src/pyftms/client/client.py +++ b/src/pyftms/client/client.py @@ -242,7 +242,8 @@ def supported_ranges(self) -> MappingProxyType[str, SettingRange]: def _on_disconnect(self, cli: BleakClient) -> None: _LOGGER.debug("Client disconnected. Reset updaters states.") - del self._cli + if hasattr(self, "_cli"): + del self._cli self._updater.reset() self._controller.reset()