diff --git a/src/pyftms/__init__.py b/src/pyftms/__init__.py index 39f67ebcfb..a9db440464 100644 --- a/src/pyftms/__init__.py +++ b/src/pyftms/__init__.py @@ -27,7 +27,7 @@ get_machine_type_from_service_data, ) from .client.backends import FtmsEvents -from .client.machines import CrossTrainer, IndoorBike, Rower, Treadmill +from .client.machines import CrossTrainer, IndoorBike, Rower, Treadmill, Unknown from .models import ( IndoorBikeSimulationParameters, ResultCode, @@ -47,6 +47,7 @@ "IndoorBike", "Treadmill", "Rower", + "Unknown", "FtmsCallback", "FtmsEvents", "MachineType", diff --git a/src/pyftms/client/backends/updater.py b/src/pyftms/client/backends/updater.py index dc51867a09..20461d8738 100644 --- a/src/pyftms/client/backends/updater.py +++ b/src/pyftms/client/backends/updater.py @@ -48,7 +48,7 @@ def _on_notify(self, c: BleakGATTCharacteristic, data: bytearray) -> None: # My device sends a lot of null packets during wakeup and sleep mode. # So I just filter null packets. - if any(self._result.values()): + if any(v is not None for v in self._result.values()): update = self._result.items() ^ self._prev.items() if update := {k: self._result[k] for k, _ in update}: diff --git a/src/pyftms/client/client.py b/src/pyftms/client/client.py index 8dca257fa1..fc7260f5c8 100644 --- a/src/pyftms/client/client.py +++ b/src/pyftms/client/client.py @@ -271,15 +271,23 @@ async def _connect(self) -> None: # Reading necessary static fitness machine information - if not self._device_info: + if "_device_info" not in self.__dict__: self._device_info = await read_device_info(self._cli) - if not self._m_features: - ( - self._m_features, - self._m_settings, - self._settings_ranges, - ) = await read_features(self._cli, self._machine_type) + if "_m_features" not in self.__dict__: + try: + ( + self._m_features, + self._m_settings, + self._settings_ranges, + ) = await read_features(self._cli, self._machine_type) + except Exception as e: + _LOGGER.debug( + "Feature characteristic not found or failed to read; proceeding in data-only mode. Error: %s", e + ) + 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) diff --git a/src/pyftms/client/machines/__init__.py b/src/pyftms/client/machines/__init__.py index daaa8c50b5..308c2f6fca 100644 --- a/src/pyftms/client/machines/__init__.py +++ b/src/pyftms/client/machines/__init__.py @@ -7,6 +7,7 @@ from .indoor_bike import IndoorBike from .rower import Rower from .treadmill import Treadmill +from .unknown import Unknown def get_machine(mt: MachineType) -> type[FitnessMachine]: @@ -14,6 +15,9 @@ def get_machine(mt: MachineType) -> type[FitnessMachine]: assert len(mt) == 1 match mt: + case MachineType.UNKNOWN: + return Unknown + case MachineType.TREADMILL: return Treadmill @@ -34,5 +38,6 @@ def get_machine(mt: MachineType) -> type[FitnessMachine]: "IndoorBike", "Rower", "Treadmill", + "Unknown", "get_machine", ] diff --git a/src/pyftms/client/machines/unknown.py b/src/pyftms/client/machines/unknown.py new file mode 100644 index 0000000000..0f46198ebe --- /dev/null +++ b/src/pyftms/client/machines/unknown.py @@ -0,0 +1,277 @@ +# Copyright 2025, Christian Kündig +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from bleak import BleakClient +from bleak.backends.characteristic import BleakGATTCharacteristic +from bleak.backends.device import BLEDevice +from bleak.backends.scanner import AdvertisementData +from bleak_retry_connector import close_stale_connections, establish_connection + +from .. import const as c +from ..backends import FtmsCallback +from ..client import DisconnectCallback, FitnessMachine +from ..properties import MachineType +from ..properties.device_info import DIS_UUID + +_LOGGER = logging.getLogger(__name__) + +# Mapping of data UUID to machine type +_UUID_TO_MACHINE_TYPE: dict[str, MachineType] = { + c.TREADMILL_DATA_UUID: MachineType.TREADMILL, + c.CROSS_TRAINER_DATA_UUID: MachineType.CROSS_TRAINER, + c.ROWER_DATA_UUID: MachineType.ROWER, + c.INDOOR_BIKE_DATA_UUID: MachineType.INDOOR_BIKE, +} + +# All data UUIDs to subscribe to for type detection +_ALL_DATA_UUIDS = tuple(_UUID_TO_MACHINE_TYPE.keys()) + + +class Unknown: + """ + Unknown Machine Type - Wrapper/Proxy Pattern. + + Used for devices that advertise FTMS but don't include proper service_data + to determine the machine type. This class: + + 1. Connects and subscribes to all possible data UUIDs + 2. Detects the actual type from which UUID sends data first + 3. Creates and wraps the actual client (Treadmill, CrossTrainer, etc.) + 4. Proxies all attribute access to the wrapped client + + After detection, this instance behaves exactly like the detected client type. + The caller doesn't need to swap objects - just keep using this instance. + + **Important**: Store `detected_machine_type` in config, not UNKNOWN. + """ + + def __init__( + self, + ble_device: BLEDevice, + adv_data: AdvertisementData | None = None, + *, + timeout: float = 2.0, + on_ftms_event: FtmsCallback | None = None, + on_disconnect: DisconnectCallback | None = None, + detection_timeout: float = 10.0, + **kwargs: Any, + ) -> None: + self._device = ble_device + self._adv_data = adv_data + self._timeout = timeout + self._on_ftms_event = on_ftms_event + self._on_disconnect = on_disconnect + self._detection_timeout = detection_timeout + self._kwargs = kwargs + + self._detected_type: MachineType | None = None + self._detection_event = asyncio.Event() + self._wrapped_client: FitnessMachine | None = None + self._cli: BleakClient | None = None + + def __getattr__(self, name: str) -> Any: + """Proxy attribute access to the wrapped client after detection.""" + # Avoid infinite recursion for our own attributes + if name.startswith("_"): + raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") + + _LOGGER.debug( + "Unknown.__getattr__(%s): wrapped_client=%s", + name, + type(self._wrapped_client).__name__ if self._wrapped_client else None, + ) + + if self._wrapped_client is not None: + return getattr(self._wrapped_client, name) + + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'. " + "Type detection not complete - call connect() and wait_for_detection() first." + ) + + @property + def machine_type(self) -> MachineType: + """Machine type - returns detected type if available, otherwise UNKNOWN.""" + if self._wrapped_client is not None: + return self._wrapped_client.machine_type + return MachineType.UNKNOWN + + @property + def is_connected(self) -> bool: + """Current connection status.""" + if self._wrapped_client is not None: + return self._wrapped_client.is_connected + return self._cli is not None and self._cli.is_connected + + @property + def name(self) -> str: + """Device name or BLE address.""" + return self._device.name or self._device.address + + @property + def address(self) -> str: + """Bluetooth address.""" + return self._device.address + + async def wait_for_detection(self, timeout: float | None = None) -> MachineType: + """ + Wait for the machine type to be detected. + + Args: + timeout: Maximum time to wait. Uses detection_timeout from __init__ if None. + + Returns: + The detected MachineType. + + Raises: + asyncio.TimeoutError: If detection times out. + """ + if self._detected_type is not None: + return self._detected_type + + await asyncio.wait_for( + self._detection_event.wait(), + timeout=timeout or self._detection_timeout, + ) + + if self._detected_type is None: + raise ValueError("Detection completed but no type detected") + + return self._detected_type + + def _handle_disconnect(self, cli: BleakClient) -> None: + """Handle disconnection during detection phase.""" + _LOGGER.debug("Unknown: Disconnected during detection.") + self._cli = None + + def _on_data_notify(self, uuid: str): + """Create a notification handler for a specific UUID.""" + + def handler(char: BleakGATTCharacteristic, data: bytearray) -> None: + machine_type = _UUID_TO_MACHINE_TYPE.get(uuid) + if not machine_type: + return + + if self._detected_type is not None: + # Already detected - check if this is a different type + if machine_type != self._detected_type: + _LOGGER.error( + "Unknown: Device is sending data for MULTIPLE machine types! " + "Already detected as %s, but also received data for %s (UUID %s). " + "This device may be misconfigured or have firmware issues. " + "Data: %s", + self._detected_type.name, + machine_type.name, + uuid, + data.hex(" ").upper(), + ) + return + + self._detected_type = machine_type + self._detection_event.set() + _LOGGER.info( + "Unknown: Detected machine type %s from UUID %s", + machine_type.name, + uuid, + ) + + return handler + + async def connect(self) -> None: + """ + Connect, detect machine type, and initialize the wrapped client. + + After this completes, the Unknown instance proxies to the real client. + """ + if self._wrapped_client is not None: + # Already detected and wrapped - just reconnect wrapped client + await self._wrapped_client.connect() + return + + # Phase 1: Connect for type detection + await close_stale_connections(self._device) + + _LOGGER.debug("Unknown: Connecting for type detection.") + + self._cli = await establish_connection( + client_class=BleakClient, + device=self._device, + name=self.name, + disconnected_callback=self._handle_disconnect, + services=[c.FTMS_UUID, DIS_UUID], + ) + + _LOGGER.debug("Unknown: Subscribing to all data UUIDs for detection.") + + # Subscribe to all data UUIDs + for uuid in _ALL_DATA_UUIDS: + try: + char = self._cli.services.get_characteristic(uuid) + if char: + await self._cli.start_notify(uuid, self._on_data_notify(uuid)) + _LOGGER.debug("Unknown: Subscribed to UUID %s", uuid) + except Exception as e: + _LOGGER.debug("Unknown: Failed to subscribe to UUID %s: %s", uuid, e) + + # Wait for type detection + try: + detected = await self.wait_for_detection() + except asyncio.TimeoutError: + _LOGGER.warning("Unknown: Type detection timed out") + if self._cli and self._cli.is_connected: + await self._cli.disconnect() + self._cli = None + raise + + _LOGGER.info("Unknown: Detection complete, creating %s client", detected.name) + + # Disconnect detection client + if self._cli and self._cli.is_connected: + await self._cli.disconnect() + self._cli = None + + # Phase 2: Create and connect the real client + from . import get_machine + + cls = get_machine(detected) + self._wrapped_client = cls( + self._device, + self._adv_data, + timeout=self._timeout, + on_ftms_event=self._on_ftms_event, + on_disconnect=self._on_disconnect, + ) + _LOGGER.debug( + "Unknown: Created wrapped client %s, connecting...", + type(self._wrapped_client).__name__, + ) + + await self._wrapped_client.connect() + _LOGGER.debug( + "Unknown: Wrapped client connected. machine_type=%s, live_properties=%s", + self._wrapped_client.machine_type, + self._wrapped_client.live_properties, + ) + + async def disconnect(self) -> None: + """Disconnect from the device.""" + if self._wrapped_client is not None: + await self._wrapped_client.disconnect() + elif self._cli is not None and self._cli.is_connected: + await self._cli.disconnect() + self._cli = None + + def set_ble_device_and_advertisement_data( + self, ble_device: BLEDevice, adv_data: AdvertisementData | None + ) -> None: + """Update BLE device and advertisement data.""" + self._device = ble_device + self._adv_data = adv_data + if self._wrapped_client is not None: + self._wrapped_client.set_ble_device_and_advertisement_data(ble_device, adv_data) diff --git a/src/pyftms/client/manager.py b/src/pyftms/client/manager.py index a62dd8cc26..a9a98e078c 100644 --- a/src/pyftms/client/manager.py +++ b/src/pyftms/client/manager.py @@ -1,9 +1,12 @@ # Copyright 2024, Sergey Dudanov # SPDX-License-Identifier: Apache-2.0 +import logging from types import MappingProxyType from typing import Any, cast +_LOGGER = logging.getLogger(__name__) + from ..models import IndoorBikeSimulationParameters, TrainingStatusCode from . import const as c from .backends import FtmsCallback, FtmsEvents, SetupEventData, UpdateEventData @@ -44,7 +47,7 @@ def _on_event(self, e: FtmsEvents) -> None: if e.event_id == "update": self._properties |= e.event_data self._live_properties.update( - k for k, v in e.event_data.items() if v + k for k, v in e.event_data.items() if v is not None ) elif e.event_id == "setup": self._settings |= e.event_data @@ -69,8 +72,13 @@ def live_properties(self) -> tuple[str, ...]: """ Living properties. - Properties that had a value other than zero at least once. + Properties that had a non-None value at least once. """ + _LOGGER.debug( + "PropertiesManager.live_properties: class=%s, _live_properties=%s", + type(self).__name__, + list(self._live_properties) if hasattr(self, "_live_properties") else "NOT SET", + ) return tuple(self._live_properties) @property diff --git a/src/pyftms/client/properties/device_info.py b/src/pyftms/client/properties/device_info.py index 0f09b28b08..a049f51bd4 100644 --- a/src/pyftms/client/properties/device_info.py +++ b/src/pyftms/client/properties/device_info.py @@ -15,6 +15,7 @@ "serial_number": "2a25", "sw_version": "2a28", "hw_version": "2a27", + "fw_version": "2a26", } _LOGGER = logging.getLogger(__name__) @@ -33,6 +34,8 @@ class DeviceInfo(TypedDict, total=False): """Software Version""" hw_version: str """Hardware Version""" + fw_version: str + """Firmware Version""" async def read_device_info(cli: BleakClient) -> DeviceInfo: @@ -48,6 +51,13 @@ async def read_device_info(cli: BleakClient) -> DeviceInfo: data = await cli.read_gatt_char(c) result[k] = data.decode() + # Use fw_version as sw_version fallback if sw_version is missing/placeholder + sw = result.get("sw_version", "").strip() + fw = result.get("fw_version", "").strip() + is_placeholder = lambda v: not v or v.strip("0") == "" + if is_placeholder(sw) and not is_placeholder(fw): + result["sw_version"] = fw + _LOGGER.debug("Device Info: %s", result) return result diff --git a/src/pyftms/client/properties/machine_type.py b/src/pyftms/client/properties/machine_type.py index bbb2eb20bd..402be4894f 100644 --- a/src/pyftms/client/properties/machine_type.py +++ b/src/pyftms/client/properties/machine_type.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import functools +import logging import operator from enum import Flag, auto @@ -11,6 +12,8 @@ from ..const import FTMS_UUID from ..errors import NotFitnessMachineError +_LOGGER = logging.getLogger(__name__) + class MachineFlags(Flag): """ @@ -46,6 +49,9 @@ class MachineType(Flag): """Rower Machine.""" INDOOR_BIKE = auto() """Indoor Bike Machine.""" + UNKNOWN = auto() + """Unknown Machine Type. Used during discovery when device type cannot be determined + from advertisement data. The actual type is detected by subscribing to all data UUIDs.""" def get_machine_type_from_service_data( @@ -63,6 +69,18 @@ def get_machine_type_from_service_data( data = adv_data.service_data.get(normalize_uuid_str(FTMS_UUID)) if data is None or not (2 <= len(data) <= 3): + # Check if device advertises FTMS UUID but lacks proper service_data + has_ftms_uuid = any( + normalize_uuid_str(uuid) == normalize_uuid_str(FTMS_UUID) + for uuid in adv_data.service_uuids + ) + if has_ftms_uuid: + _LOGGER.info( + "Device %r advertises FTMS but lacks service_data. " + "Using UNKNOWN type - will detect from data UUIDs on connect.", + adv_data.local_name, + ) + return MachineType.UNKNOWN raise NotFitnessMachineError(data) # Reading mandatory `Flags` and `Machine Type`. diff --git a/src/pyftms/models/realtime_data/common.py b/src/pyftms/models/realtime_data/common.py index 7230292078..7f70fcc7e8 100644 --- a/src/pyftms/models/realtime_data/common.py +++ b/src/pyftms/models/realtime_data/common.py @@ -3,10 +3,13 @@ import dataclasses as dc import io +import logging from typing import Any, cast, override from ...serializer import BaseModel, get_serializer, model_meta +_LOGGER = logging.getLogger(__name__) + @dc.dataclass(frozen=True) class RealtimeData(BaseModel): @@ -21,14 +24,20 @@ def _deserialize_asdict(cls, src: io.IOBase) -> dict[str, Any]: for field, serializer in cls._iter_fields_serializers(): if mask & 1: - kwargs[field.name] = serializer.deserialize(src) + try: + kwargs[field.name] = serializer.deserialize(src) + except EOFError: + # Data was truncated - more data may arrive in next packet + break mask >>= 1 if not mask: break - assert not src.read() + remaining = src.read() + if remaining: + _LOGGER.debug("Extra bytes in data stream ignored: %s", remaining.hex()) return kwargs diff --git a/src/pyftms/models/realtime_data/cross_trainer.py b/src/pyftms/models/realtime_data/cross_trainer.py index c21d8185a4..88c3989344 100644 --- a/src/pyftms/models/realtime_data/cross_trainer.py +++ b/src/pyftms/models/realtime_data/cross_trainer.py @@ -164,7 +164,10 @@ class CrossTrainerData(RealtimeSpeedData): ) """Remaining Time""" - movement_direction: MovementDirection = dc.field(init=False) + movement_direction: MovementDirection = dc.field( + init=False, + metadata=model_meta(), + ) """Movement Direction""" def __post_init__(self, mask: int):