Skip to content
Open
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
70 changes: 70 additions & 0 deletions diagnose_bt.py
Original file line number Diff line number Diff line change
@@ -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())
4 changes: 4 additions & 0 deletions instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# PowerShell

usbipd list
usbipd attach --wsl --busid 2-5
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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]
Expand Down
45 changes: 37 additions & 8 deletions src/pyftms/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -32,6 +32,7 @@
MachineType,
MovementDirection,
SettingRange,
get_machine_type_from_gatt,
get_machine_type_from_service_data,
)

Expand Down Expand Up @@ -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:
Comment on lines 126 to +131
async with BleakClient(
dev, services=[FTMS_UUID]
) as cli:
Comment on lines +131 to +134
machine_type = await get_machine_type_from_gatt(
cli
)
except (BleakError, NotFitnessMachineError, OSError):
Comment on lines +132 to +138
_LOGGER.debug(
"Could not determine machine type for '%s' "
"via GATT fallback.",
dev.address,
)
continue
Comment on lines +138 to +144

devices.add(dev.address)

Expand Down
3 changes: 2 additions & 1 deletion src/pyftms/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
7 changes: 6 additions & 1 deletion src/pyftms/client/properties/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +13 to +15
Comment on lines +13 to +15
)

__all__ = [
"DeviceInfo",
"get_machine_type_from_gatt",
"get_machine_type_from_service_data",
"MachineFeatures",
"MachineSettings",
Expand Down
37 changes: 36 additions & 1 deletion src/pyftms/client/properties/machine_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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()