Skip to content

Commit e5bba07

Browse files
committed
Add Unknown device support for non-compliant FTMS devices
1 parent 73c2df7 commit e5bba07

File tree

6 files changed

+328
-10
lines changed

6 files changed

+328
-10
lines changed

src/pyftms/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
get_machine_type_from_service_data,
2828
)
2929
from .client.backends import FtmsEvents
30-
from .client.machines import CrossTrainer, IndoorBike, Rower, Treadmill
30+
from .client.machines import CrossTrainer, IndoorBike, Rower, Treadmill, Unknown
3131
from .models import (
3232
IndoorBikeSimulationParameters,
3333
ResultCode,
@@ -47,6 +47,7 @@
4747
"IndoorBike",
4848
"Treadmill",
4949
"Rower",
50+
"Unknown",
5051
"FtmsCallback",
5152
"FtmsEvents",
5253
"MachineType",

src/pyftms/client/client.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -271,15 +271,23 @@ async def _connect(self) -> None:
271271

272272
# Reading necessary static fitness machine information
273273

274-
if not self._device_info:
274+
if "_device_info" not in self.__dict__:
275275
self._device_info = await read_device_info(self._cli)
276276

277-
if not self._m_features:
278-
(
279-
self._m_features,
280-
self._m_settings,
281-
self._settings_ranges,
282-
) = await read_features(self._cli, self._machine_type)
277+
if "_m_features" not in self.__dict__:
278+
try:
279+
(
280+
self._m_features,
281+
self._m_settings,
282+
self._settings_ranges,
283+
) = await read_features(self._cli, self._machine_type)
284+
except Exception as e:
285+
_LOGGER.debug(
286+
"Feature characteristic not found or failed to read; proceeding in data-only mode. Error: %s", e
287+
)
288+
self._m_features = MachineFeatures(0)
289+
self._m_settings = MachineSettings(0)
290+
self._settings_ranges = MappingProxyType({})
283291

284292
await self._controller.subscribe(self._cli)
285293
await self._updater.subscribe(self._cli, self._data_uuid)

src/pyftms/client/machines/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,17 @@
77
from .indoor_bike import IndoorBike
88
from .rower import Rower
99
from .treadmill import Treadmill
10+
from .unknown import Unknown
1011

1112

1213
def get_machine(mt: MachineType) -> type[FitnessMachine]:
1314
"""Returns Fitness Machine by type."""
1415
assert len(mt) == 1
1516

1617
match mt:
18+
case MachineType.UNKNOWN:
19+
return Unknown
20+
1721
case MachineType.TREADMILL:
1822
return Treadmill
1923

@@ -34,5 +38,6 @@ def get_machine(mt: MachineType) -> type[FitnessMachine]:
3438
"IndoorBike",
3539
"Rower",
3640
"Treadmill",
41+
"Unknown",
3742
"get_machine",
3843
]
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
# Copyright 2025, Christian Kündig
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from __future__ import annotations
5+
6+
import asyncio
7+
import logging
8+
from typing import Any
9+
10+
from bleak import BleakClient
11+
from bleak.backends.characteristic import BleakGATTCharacteristic
12+
from bleak.backends.device import BLEDevice
13+
from bleak.backends.scanner import AdvertisementData
14+
from bleak_retry_connector import close_stale_connections, establish_connection
15+
16+
from .. import const as c
17+
from ..backends import FtmsCallback
18+
from ..client import DisconnectCallback, FitnessMachine
19+
from ..properties import MachineType
20+
from ..properties.device_info import DIS_UUID
21+
22+
_LOGGER = logging.getLogger(__name__)
23+
24+
# Mapping of data UUID to machine type
25+
_UUID_TO_MACHINE_TYPE: dict[str, MachineType] = {
26+
c.TREADMILL_DATA_UUID: MachineType.TREADMILL,
27+
c.CROSS_TRAINER_DATA_UUID: MachineType.CROSS_TRAINER,
28+
c.ROWER_DATA_UUID: MachineType.ROWER,
29+
c.INDOOR_BIKE_DATA_UUID: MachineType.INDOOR_BIKE,
30+
}
31+
32+
# All data UUIDs to subscribe to for type detection
33+
_ALL_DATA_UUIDS = tuple(_UUID_TO_MACHINE_TYPE.keys())
34+
35+
36+
class Unknown:
37+
"""
38+
Unknown Machine Type - Wrapper/Proxy Pattern.
39+
40+
Used for devices that advertise FTMS but don't include proper service_data
41+
to determine the machine type. This class:
42+
43+
1. Connects and subscribes to all possible data UUIDs
44+
2. Detects the actual type from which UUID sends data first
45+
3. Creates and wraps the actual client (Treadmill, CrossTrainer, etc.)
46+
4. Proxies all attribute access to the wrapped client
47+
48+
After detection, this instance behaves exactly like the detected client type.
49+
The caller doesn't need to swap objects - just keep using this instance.
50+
51+
**Important**: Store `detected_machine_type` in config, not UNKNOWN.
52+
"""
53+
54+
def __init__(
55+
self,
56+
ble_device: BLEDevice,
57+
adv_data: AdvertisementData | None = None,
58+
*,
59+
timeout: float = 2.0,
60+
on_ftms_event: FtmsCallback | None = None,
61+
on_disconnect: DisconnectCallback | None = None,
62+
detection_timeout: float = 10.0,
63+
**kwargs: Any,
64+
) -> None:
65+
self._device = ble_device
66+
self._adv_data = adv_data
67+
self._timeout = timeout
68+
self._on_ftms_event = on_ftms_event
69+
self._on_disconnect = on_disconnect
70+
self._detection_timeout = detection_timeout
71+
self._kwargs = kwargs
72+
73+
self._detected_type: MachineType | None = None
74+
self._detection_event = asyncio.Event()
75+
self._wrapped_client: FitnessMachine | None = None
76+
self._cli: BleakClient | None = None
77+
78+
def __getattr__(self, name: str) -> Any:
79+
"""Proxy attribute access to the wrapped client after detection."""
80+
# Avoid infinite recursion for our own attributes
81+
if name.startswith("_"):
82+
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
83+
84+
_LOGGER.debug(
85+
"Unknown.__getattr__(%s): wrapped_client=%s",
86+
name,
87+
type(self._wrapped_client).__name__ if self._wrapped_client else None,
88+
)
89+
90+
if self._wrapped_client is not None:
91+
return getattr(self._wrapped_client, name)
92+
93+
raise AttributeError(
94+
f"'{type(self).__name__}' object has no attribute '{name}'. "
95+
"Type detection not complete - call connect() and wait_for_detection() first."
96+
)
97+
98+
@property
99+
def machine_type(self) -> MachineType:
100+
"""Machine type - returns detected type if available, otherwise UNKNOWN."""
101+
if self._wrapped_client is not None:
102+
return self._wrapped_client.machine_type
103+
return MachineType.UNKNOWN
104+
105+
@property
106+
def is_connected(self) -> bool:
107+
"""Current connection status."""
108+
if self._wrapped_client is not None:
109+
return self._wrapped_client.is_connected
110+
return self._cli is not None and self._cli.is_connected
111+
112+
@property
113+
def name(self) -> str:
114+
"""Device name or BLE address."""
115+
return self._device.name or self._device.address
116+
117+
@property
118+
def address(self) -> str:
119+
"""Bluetooth address."""
120+
return self._device.address
121+
122+
async def wait_for_detection(self, timeout: float | None = None) -> MachineType:
123+
"""
124+
Wait for the machine type to be detected.
125+
126+
Args:
127+
timeout: Maximum time to wait. Uses detection_timeout from __init__ if None.
128+
129+
Returns:
130+
The detected MachineType.
131+
132+
Raises:
133+
asyncio.TimeoutError: If detection times out.
134+
"""
135+
if self._detected_type is not None:
136+
return self._detected_type
137+
138+
await asyncio.wait_for(
139+
self._detection_event.wait(),
140+
timeout=timeout or self._detection_timeout,
141+
)
142+
143+
if self._detected_type is None:
144+
raise ValueError("Detection completed but no type detected")
145+
146+
return self._detected_type
147+
148+
def _handle_disconnect(self, cli: BleakClient) -> None:
149+
"""Handle disconnection during detection phase."""
150+
_LOGGER.debug("Unknown: Disconnected during detection.")
151+
self._cli = None
152+
153+
def _on_data_notify(self, uuid: str):
154+
"""Create a notification handler for a specific UUID."""
155+
156+
def handler(char: BleakGATTCharacteristic, data: bytearray) -> None:
157+
machine_type = _UUID_TO_MACHINE_TYPE.get(uuid)
158+
if not machine_type:
159+
return
160+
161+
if self._detected_type is not None:
162+
# Already detected - check if this is a different type
163+
if machine_type != self._detected_type:
164+
_LOGGER.error(
165+
"Unknown: Device is sending data for MULTIPLE machine types! "
166+
"Already detected as %s, but also received data for %s (UUID %s). "
167+
"This device may be misconfigured or have firmware issues. "
168+
"Data: %s",
169+
self._detected_type.name,
170+
machine_type.name,
171+
uuid,
172+
data.hex(" ").upper(),
173+
)
174+
return
175+
176+
self._detected_type = machine_type
177+
self._detection_event.set()
178+
_LOGGER.info(
179+
"Unknown: Detected machine type %s from UUID %s",
180+
machine_type.name,
181+
uuid,
182+
)
183+
184+
return handler
185+
186+
async def connect(self) -> None:
187+
"""
188+
Connect, detect machine type, and initialize the wrapped client.
189+
190+
After this completes, the Unknown instance proxies to the real client.
191+
"""
192+
if self._wrapped_client is not None:
193+
# Already detected and wrapped - just reconnect wrapped client
194+
await self._wrapped_client.connect()
195+
return
196+
197+
# Phase 1: Connect for type detection
198+
await close_stale_connections(self._device)
199+
200+
_LOGGER.debug("Unknown: Connecting for type detection.")
201+
202+
self._cli = await establish_connection(
203+
client_class=BleakClient,
204+
device=self._device,
205+
name=self.name,
206+
disconnected_callback=self._handle_disconnect,
207+
services=[c.FTMS_UUID, DIS_UUID],
208+
)
209+
210+
_LOGGER.debug("Unknown: Subscribing to all data UUIDs for detection.")
211+
212+
# Subscribe to all data UUIDs
213+
for uuid in _ALL_DATA_UUIDS:
214+
try:
215+
char = self._cli.services.get_characteristic(uuid)
216+
if char:
217+
await self._cli.start_notify(uuid, self._on_data_notify(uuid))
218+
_LOGGER.debug("Unknown: Subscribed to UUID %s", uuid)
219+
except Exception as e:
220+
_LOGGER.debug("Unknown: Failed to subscribe to UUID %s: %s", uuid, e)
221+
222+
# Wait for type detection
223+
try:
224+
detected = await self.wait_for_detection()
225+
except asyncio.TimeoutError:
226+
_LOGGER.warning("Unknown: Type detection timed out")
227+
if self._cli and self._cli.is_connected:
228+
await self._cli.disconnect()
229+
self._cli = None
230+
raise
231+
232+
_LOGGER.info("Unknown: Detection complete, creating %s client", detected.name)
233+
234+
# Disconnect detection client
235+
if self._cli and self._cli.is_connected:
236+
await self._cli.disconnect()
237+
self._cli = None
238+
239+
# Phase 2: Create and connect the real client
240+
from . import get_machine
241+
242+
cls = get_machine(detected)
243+
self._wrapped_client = cls(
244+
self._device,
245+
self._adv_data,
246+
timeout=self._timeout,
247+
on_ftms_event=self._on_ftms_event,
248+
on_disconnect=self._on_disconnect,
249+
)
250+
_LOGGER.debug(
251+
"Unknown: Created wrapped client %s, connecting...",
252+
type(self._wrapped_client).__name__,
253+
)
254+
255+
await self._wrapped_client.connect()
256+
_LOGGER.debug(
257+
"Unknown: Wrapped client connected. machine_type=%s, live_properties=%s",
258+
self._wrapped_client.machine_type,
259+
self._wrapped_client.live_properties,
260+
)
261+
262+
async def disconnect(self) -> None:
263+
"""Disconnect from the device."""
264+
if self._wrapped_client is not None:
265+
await self._wrapped_client.disconnect()
266+
elif self._cli is not None and self._cli.is_connected:
267+
await self._cli.disconnect()
268+
self._cli = None
269+
270+
def set_ble_device_and_advertisement_data(
271+
self, ble_device: BLEDevice, adv_data: AdvertisementData | None
272+
) -> None:
273+
"""Update BLE device and advertisement data."""
274+
self._device = ble_device
275+
self._adv_data = adv_data
276+
if self._wrapped_client is not None:
277+
self._wrapped_client.set_ble_device_and_advertisement_data(ble_device, adv_data)

src/pyftms/client/properties/machine_type.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# SPDX-License-Identifier: Apache-2.0
33

44
import functools
5+
import logging
56
import operator
67
from enum import Flag, auto
78

@@ -11,6 +12,8 @@
1112
from ..const import FTMS_UUID
1213
from ..errors import NotFitnessMachineError
1314

15+
_LOGGER = logging.getLogger(__name__)
16+
1417

1518
class MachineFlags(Flag):
1619
"""
@@ -46,6 +49,9 @@ class MachineType(Flag):
4649
"""Rower Machine."""
4750
INDOOR_BIKE = auto()
4851
"""Indoor Bike Machine."""
52+
UNKNOWN = auto()
53+
"""Unknown Machine Type. Used during discovery when device type cannot be determined
54+
from advertisement data. The actual type is detected by subscribing to all data UUIDs."""
4955

5056

5157
def get_machine_type_from_service_data(
@@ -63,6 +69,18 @@ def get_machine_type_from_service_data(
6369
data = adv_data.service_data.get(normalize_uuid_str(FTMS_UUID))
6470

6571
if data is None or not (2 <= len(data) <= 3):
72+
# Check if device advertises FTMS UUID but lacks proper service_data
73+
has_ftms_uuid = any(
74+
normalize_uuid_str(uuid) == normalize_uuid_str(FTMS_UUID)
75+
for uuid in adv_data.service_uuids
76+
)
77+
if has_ftms_uuid:
78+
_LOGGER.info(
79+
"Device %r advertises FTMS but lacks service_data. "
80+
"Using UNKNOWN type - will detect from data UUIDs on connect.",
81+
adv_data.local_name,
82+
)
83+
return MachineType.UNKNOWN
6684
raise NotFitnessMachineError(data)
6785

6886
# Reading mandatory `Flags` and `Machine Type`.

0 commit comments

Comments
 (0)