From 96f1ac0497ad537f4862231844f7c9223e326fe6 Mon Sep 17 00:00:00 2001 From: "M. Hamzah Khan" Date: Sat, 30 Aug 2025 15:25:26 +0100 Subject: [PATCH] feat: Support UUID-only FTMS devices with data-only fallback and post-connect type detection --- src/pyftms/client/__init__.py | 16 ++++++- src/pyftms/client/client.py | 79 ++++++++++++++++++++++++++++++++--- 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/src/pyftms/client/__init__.py b/src/pyftms/client/__init__.py index 77a937a375..2797f3b067 100644 --- a/src/pyftms/client/__init__.py +++ b/src/pyftms/client/__init__.py @@ -67,7 +67,21 @@ def get_client( if isinstance(adv_or_type, AdvertisementData): adv_data = adv_or_type - adv_or_type = get_machine_type_from_service_data(adv_or_type) + try: + adv_or_type = get_machine_type_from_service_data(adv_or_type) + except NotFitnessMachineError: + # Fallback: some devices advertise FTMS UUID but omit FTMS service data. + # If FTMS UUID is present, instantiate a placeholder client so we can + # connect and detect the real machine type from GATT characteristics. + # Note: post-connect code will probe notifiable data characteristics + # (2ACD/2ACE/2AD1/2AD2) and switch the type accordingly. + if normalize_uuid_str(FTMS_UUID) in (adv_data.service_uuids or []): + _LOGGER.debug( + "FTMS UUID present but no FTMS service data; proceeding with placeholder client. Actual type will be detected after connect." + ) + adv_or_type = MachineType.INDOOR_BIKE + else: + raise cls = get_machine(adv_or_type) diff --git a/src/pyftms/client/client.py b/src/pyftms/client/client.py index 8dca257fa1..0df0f8539d 100644 --- a/src/pyftms/client/client.py +++ b/src/pyftms/client/client.py @@ -18,10 +18,14 @@ ControlCode, ControlModel, IndoorBikeSimulationParameters, + CrossTrainerData, + IndoorBikeData, RealtimeData, + RowerData, ResultCode, SpinDownControlCode, StopPauseCode, + TreadmillData, ) from . import const as c from .backends import DataUpdater, FtmsCallback, MachineController, UpdateEvent @@ -36,6 +40,7 @@ read_features, ) from .properties.device_info import DIS_UUID +from .errors import CharacteristicNotFound _LOGGER = logging.getLogger(__name__) @@ -274,15 +279,77 @@ async def _connect(self) -> None: if not self._device_info: self._device_info = await read_device_info(self._cli) + # Post-connect machine type/data characteristic probe for UUID-only fallback + try: + svc = self._cli.services.get_service(c.FTMS_UUID) + except Exception: + svc = None + + if svc is not None: + # Determine which real-time data characteristic is present and notifiable + mt_map = [ + (c.INDOOR_BIKE_DATA_UUID, MachineType.INDOOR_BIKE, IndoorBikeData), + (c.TREADMILL_DATA_UUID, MachineType.TREADMILL, TreadmillData), + (c.CROSS_TRAINER_DATA_UUID, MachineType.CROSS_TRAINER, CrossTrainerData), + (c.ROWER_DATA_UUID, MachineType.ROWER, RowerData), + ] + selected = None + for uuid, mt, model in mt_map: + ch = svc.get_characteristic(uuid) + if ch and "notify" in getattr(ch, "properties", []): + selected = (uuid, mt, model) + break + + if selected: + uuid, mt, model = selected + if getattr(self, "_data_uuid", None) != uuid or self._data_model is not model: + _LOGGER.debug( + "Detected data characteristic %s; switching machine type to %s", + uuid, + mt.name, + ) + self._machine_type = mt + self._data_uuid = uuid + self._updater = DataUpdater(model, self._on_event) + if not self._m_features: - ( - self._m_features, - self._m_settings, - self._settings_ranges, - ) = await read_features(self._cli, self._machine_type) + try: + ( + self._m_features, + self._m_settings, + self._settings_ranges, + ) = await read_features(self._cli, self._machine_type) + except CharacteristicNotFound: + # Data-only fallback: proceed without features/settings when + # devices expose real-time data but omit FTMS Feature characteristic. + _LOGGER.debug( + "Feature characteristic not found; proceeding in data-only mode." + ) + self._m_features = MachineFeatures(0) + self._m_settings = MachineSettings(0) + self._settings_ranges = MappingProxyType({}) await self._controller.subscribe(self._cli) - await self._updater.subscribe(self._cli, self._data_uuid) + try: + await self._updater.subscribe(self._cli, self._data_uuid) + except Exception as exc: + # Some stacks report characteristics that are not actually notifiable. + # Try Indoor Bike Data as a common fallback if available. + if self._data_uuid != c.INDOOR_BIKE_DATA_UUID and ( + self._cli.services.get_characteristic(c.INDOOR_BIKE_DATA_UUID) + ): + _LOGGER.debug( + "Subscribe failed on %s (%s). Falling back to %s.", + self._data_uuid, + exc, + c.INDOOR_BIKE_DATA_UUID, + ) + self._machine_type = MachineType.INDOOR_BIKE + self._data_uuid = c.INDOOR_BIKE_DATA_UUID + self._updater = DataUpdater(IndoorBikeData, self._on_event) + await self._updater.subscribe(self._cli, self._data_uuid) + else: + raise # COMMANDS