diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 4d6da04600..31baac2486 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -19,6 +19,7 @@ jobs: python-version: - "3.12" - "3.13" + - "3.14" steps: - uses: actions/checkout@v6 diff --git a/README.md b/README.md index b947a6b56b..9f5604b7f8 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@ **Step Climber** and **Stair Climber** machines are **not supported** due to incomplete protocol information and low popularity. +This fork publishes production-ready fixes used by the forked Home Assistant FTMS +integration. The corresponding fork release for this branch is `v0.4.15+mw.5`. + ## Requirments 1. `bleak` @@ -19,6 +22,13 @@ pip install pyftms ``` +## Install the forked release + +```bash +pip install "pyftms @ git+https://github.com/michaelw/python-pyftms.git@v0.4.15+mw.5" +``` + ## Usage -Please read API [documentation](https://dudanov.github.io/python-pyftms/pyftms.html). +Please read the fork README and source on +[GitHub](https://github.com/michaelw/python-pyftms). diff --git a/pyproject.toml b/pyproject.toml index 081007bce6..aec5b2ba05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyftms" -version = "0.4.15" +version = "0.4.15+mw.5" description = "Bluetooth Fitness Machine Service async client library." authors = [ { name = "Sergey Dudanov", email = "sergey.dudanov@gmail.com" }, @@ -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,13 +34,14 @@ classifiers = [ "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] [project.urls] -"Documentation" = "https://github.com/dudanov/pyftms" -"Home Page" = "https://github.com/dudanov/pyftms" -"Issue Tracker" = "https://github.com/dudanov/pyftms/issues" -"Source Code" = "https://github.com/dudanov/pyftms.git" +"Documentation" = "https://github.com/michaelw/python-pyftms" +"Home Page" = "https://github.com/michaelw/python-pyftms" +"Issue Tracker" = "https://github.com/michaelw/python-pyftms/issues" +"Source Code" = "https://github.com/michaelw/python-pyftms.git" [project.optional-dependencies] docs = [ diff --git a/src/pyftms/__init__.py b/src/pyftms/__init__.py index 39f67ebcfb..60fa6ce863 100644 --- a/src/pyftms/__init__.py +++ b/src/pyftms/__init__.py @@ -24,6 +24,8 @@ discover_ftms_devices, get_client, get_client_from_address, + get_machine_type_from_advertisement, + get_machine_type_from_gatt, get_machine_type_from_service_data, ) from .client.backends import FtmsEvents @@ -41,6 +43,8 @@ "discover_ftms_devices", "get_client", "get_client_from_address", + "get_machine_type_from_advertisement", + "get_machine_type_from_gatt", "get_machine_type_from_service_data", "FitnessMachine", "CrossTrainer", diff --git a/src/pyftms/client/__init__.py b/src/pyftms/client/__init__.py index 77a937a375..fc65126e67 100644 --- a/src/pyftms/client/__init__.py +++ b/src/pyftms/client/__init__.py @@ -32,6 +32,8 @@ MachineType, MovementDirection, SettingRange, + get_machine_type_from_advertisement, + get_machine_type_from_gatt, get_machine_type_from_service_data, ) @@ -67,7 +69,7 @@ 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) + adv_or_type = get_machine_type_from_advertisement(adv_or_type) cls = get_machine(adv_or_type) @@ -109,7 +111,7 @@ async def discover_ftms_devices( continue try: - machine_type = get_machine_type_from_service_data(adv) + machine_type = get_machine_type_from_advertisement(adv) except NotFitnessMachineError: continue @@ -174,6 +176,9 @@ async def get_client_from_address( "discover_ftms_devices", "get_client", "get_client_from_address", + "get_machine_type_from_advertisement", + "get_machine_type_from_gatt", + "get_machine_type_from_service_data", "MachineType", "NotFitnessMachineError", "UpdateEvent", diff --git a/src/pyftms/client/backends/controller.py b/src/pyftms/client/backends/controller.py index 1d778f6d76..2451dc20a1 100644 --- a/src/pyftms/client/backends/controller.py +++ b/src/pyftms/client/backends/controller.py @@ -107,6 +107,8 @@ def __init__(self, callback: FtmsCallback) -> None: self._subscribed = False self._auth = False self._cb = callback + self._cli: BleakClient | None = None + self._training_status_refresh_task: asyncio.Task[None] | None = None self._write_lock = asyncio.Lock() async def subscribe(self, cli: BleakClient) -> None: @@ -114,8 +116,12 @@ async def subscribe(self, cli: BleakClient) -> None: if self._subscribed: return + self._cli = cli + if c := cli.services.get_characteristic(TRAINING_STATUS_UUID): - self._on_training_status(c, await cli.read_gatt_char(c)) + await self._read_and_emit_training_status( + cli, c, initial_data=await cli.read_gatt_char(c, use_cached=False) + ) await cli.start_notify(c, self._on_training_status) if c := cli.services.get_characteristic(STATUS_UUID): @@ -130,6 +136,10 @@ def reset(self): """Resetting state. Call while disconnection event.""" self._subscribed = False self._auth = False + self._cli = None + if self._training_status_refresh_task is not None: + self._training_status_refresh_task.cancel() + self._training_status_refresh_task = None def _on_indicate(self, c: BleakGATTCharacteristic, data: bytes) -> None: """Control indication callback.""" @@ -261,17 +271,109 @@ def _on_training_status( self, c: BleakGATTCharacteristic, data: bytearray ) -> None: """Training Status notification callback.""" + fallback_event, needs_extended_read = self._build_training_status_event( + data, include_inline_string=False + ) + + if needs_extended_read: + self._schedule_training_status_refresh(c, fallback_event) + return + + event, _ = self._build_training_status_event( + data, include_inline_string=True + ) + self._cb(event) + + def _build_training_status_event( + self, data: bytes | bytearray, *, include_inline_string: bool + ) -> tuple[UpdateEvent, bool]: bio = io.BytesIO(data) status = TrainingStatusModel._deserialize(bio) status_data = UpdateEventData(training_status=status.code) + needs_extended_read = ( + TrainingStatusFlags.EXTENDED_STRING in status.flags + ) - if TrainingStatusFlags.STRING_PRESENT in status.flags: - if b := bio.read(): - status_data["training_status_string"] = b.decode( - encoding="utf-8" - ) + if ( + include_inline_string + and TrainingStatusFlags.STRING_PRESENT in status.flags + and (b := bio.read()) + ): + status_data["training_status_string"] = b.decode( + encoding="utf-8" + ) - event = UpdateEvent(event_id="update", event_data=status_data) + return UpdateEvent("update", status_data), needs_extended_read + + async def _read_and_emit_training_status( + self, + cli: BleakClient, + c: BleakGATTCharacteristic, + initial_data: bytes | bytearray | None = None, + ) -> None: + # FTMS v1.0.1 section 4.10.1.2 requires the client to read the full + # characteristic value when the Extended String flag is set because the + # string may exceed the current MTU-sized value. The Wahoo KICKR CORE + # v2 also sets this flag. + data = ( + initial_data + if initial_data is not None + else await cli.read_gatt_char(c, use_cached=False) + ) + event, needs_extended_read = self._build_training_status_event( + data, include_inline_string=True + ) + + if needs_extended_read: + _LOGGER.debug( + "Training Status Extended String bit is set; using an " + "explicit characteristic read to retrieve the full value." + ) + if initial_data is not None: + data = await cli.read_gatt_char(c, use_cached=False) + event, _ = self._build_training_status_event( + data, include_inline_string=True + ) self._cb(event) + + def _schedule_training_status_refresh( + self, + c: BleakGATTCharacteristic, + fallback_event: UpdateEvent, + ) -> None: + if self._cli is None: + self._cb(fallback_event) + return + + if ( + self._training_status_refresh_task is not None + and not self._training_status_refresh_task.done() + ): + _LOGGER.debug( + "Training Status refresh already in flight; skipping " + "duplicate extended-string read." + ) + return + + async def _refresh() -> None: + try: + await self._read_and_emit_training_status(self._cli, c) + except Exception: + _LOGGER.warning( + "Failed to refresh Training Status with Extended String; " + "emitting status code without training_status_string.", + exc_info=True, + ) + self._cb(fallback_event) + + task = asyncio.create_task(_refresh()) + task.add_done_callback(self._clear_training_status_refresh_task) + self._training_status_refresh_task = task + + def _clear_training_status_refresh_task( + self, task: asyncio.Task[None] + ) -> None: + if self._training_status_refresh_task is task: + self._training_status_refresh_task = None diff --git a/src/pyftms/client/backends/updater.py b/src/pyftms/client/backends/updater.py index dc51867a09..deff08155d 100644 --- a/src/pyftms/client/backends/updater.py +++ b/src/pyftms/client/backends/updater.py @@ -24,11 +24,13 @@ def __init__( self._serializer = get_serializer(model) self._prev: dict[str, Any] = {} self._result: dict[str, Any] = {} + self._seen_nonzero = False def reset(self) -> None: """Resetting state. Call while disconnection event.""" self._prev.clear() self._result.clear() + self._seen_nonzero = False async def subscribe(self, cli: BleakClient, uuid: str) -> None: """Subscribe for notification.""" @@ -46,16 +48,31 @@ def _on_notify(self, c: BleakGATTCharacteristic, data: bytearray) -> None: _LOGGER.debug("'More Data' bit is set. Waiting for next data.") return - # My device sends a lot of null packets during wakeup and sleep mode. - # So I just filter null packets. - if any(self._result.values()): - update = self._result.items() ^ self._prev.items() - - if update := {k: self._result[k] for k, _ in update}: - _LOGGER.debug("Update data: %s", update) - update = cast(UpdateEventData, update) # unsafe casting - update = UpdateEvent(event_id="update", event_data=update) - self._cb(update) - self._prev = self._result.copy() + # Some devices send zero-only realtime packets during wakeup/sleep. + # Ignore those until we have seen a real nonzero packet, but still + # preserve valid nonzero-to-zero transitions after activity begins. + result = self._result.copy() + prev = self._prev.copy() + + if result and all(value == 0 for value in result.values()): + if not self._seen_nonzero: + self._result.clear() + return + else: + self._seen_nonzero = True + + missing = object() + update = { + key: value + for key, value in result.items() + if prev.get(key, missing) != value + } + + if update: + _LOGGER.debug("Update data: %s", update) + update = cast(UpdateEventData, update) # unsafe casting + update = UpdateEvent(event_id="update", event_data=update) + self._cb(update) + self._prev = result self._result.clear() diff --git a/src/pyftms/client/client.py b/src/pyftms/client/client.py index 8dca257fa1..3608d34a59 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 @@ -32,10 +36,12 @@ MachineSettings, MachineType, SettingRange, + get_machine_type_from_gatt, read_device_info, read_features, ) from .properties.device_info import DIS_UUID +from .errors import CharacteristicNotFound, NotFitnessMachineError _LOGGER = logging.getLogger(__name__) @@ -242,7 +248,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() @@ -274,15 +281,86 @@ 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: + try: + detected_type = get_machine_type_from_gatt(self._cli) + except NotFitnessMachineError: + detected_type = None + + mt_map = { + MachineType.INDOOR_BIKE: (c.INDOOR_BIKE_DATA_UUID, IndoorBikeData), + MachineType.TREADMILL: (c.TREADMILL_DATA_UUID, TreadmillData), + MachineType.CROSS_TRAINER: ( + c.CROSS_TRAINER_DATA_UUID, + CrossTrainerData, + ), + MachineType.ROWER: (c.ROWER_DATA_UUID, RowerData), + } + if detected_type in mt_map: + uuid, model = mt_map[detected_type] + ch = svc.get_characteristic(uuid) + if ch and "notify" in getattr(ch, "properties", []): + should_switch = ( + getattr(self, "_data_uuid", None) != uuid + or self._data_model is not model + ) + else: + should_switch = False + + if should_switch: + _LOGGER.debug( + "Detected data characteristic %s; switching machine type to %s", + uuid, + detected_type.name, + ) + self._machine_type = detected_type + 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 diff --git a/src/pyftms/client/errors.py b/src/pyftms/client/errors.py index e73a883196..2080d3e8ba 100644 --- a/src/pyftms/client/errors.py +++ b/src/pyftms/client/errors.py @@ -19,10 +19,12 @@ class NotFitnessMachineError(FtmsError): functions if advertisement data was passed as an argument. """ - def __init__(self, data: bytes | None = None) -> None: - if data is None: + def __init__( + self, data: bytes | None = None, reason: str | None = None + ) -> None: + if reason is None and data is None: reason = "No FTMS service data" - else: + elif reason is None: reason = f"Wrong FTMS service data: '{data.hex(" ").upper()}'" super().__init__(f"Device is not Fitness Machine. {reason}.") diff --git a/src/pyftms/client/manager.py b/src/pyftms/client/manager.py index a62dd8cc26..0fa664eac2 100644 --- a/src/pyftms/client/manager.py +++ b/src/pyftms/client/manager.py @@ -61,8 +61,8 @@ def get_setting(self, name: str) -> Any: @property def properties(self) -> UpdateEventData: - """Read-only updateable properties mapping.""" - return cast(UpdateEventData, MappingProxyType(self._properties)) + """Read-only snapshot of properties mapping.""" + return cast(UpdateEventData, MappingProxyType(self._properties.copy())) @property def live_properties(self) -> tuple[str, ...]: @@ -75,8 +75,8 @@ def live_properties(self) -> tuple[str, ...]: @property def settings(self) -> SetupEventData: - """Read-only updateable settings mapping.""" - return cast(SetupEventData, MappingProxyType(self._settings)) + """Read-only snapshot of settings mapping.""" + return cast(SetupEventData, MappingProxyType(self._settings.copy())) @property def training_status(self) -> TrainingStatusCode: diff --git a/src/pyftms/client/properties/__init__.py b/src/pyftms/client/properties/__init__.py index bd4a3d3b1f..87e0664414 100644 --- a/src/pyftms/client/properties/__init__.py +++ b/src/pyftms/client/properties/__init__.py @@ -9,10 +9,17 @@ SettingRange, read_features, ) -from .machine_type import MachineType, get_machine_type_from_service_data +from .machine_type import ( + MachineType, + get_machine_type_from_advertisement, + get_machine_type_from_gatt, + get_machine_type_from_service_data, +) __all__ = [ "DeviceInfo", + "get_machine_type_from_advertisement", + "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..591674bc5f 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 @@ -48,6 +55,14 @@ class MachineType(Flag): """Indoor Bike Machine.""" +GATT_DATA_UUID_TO_MACHINE_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), +) + + def get_machine_type_from_service_data( adv_data: AdvertisementData, ) -> MachineType: @@ -79,3 +94,35 @@ def get_machine_type_from_service_data( return mt raise NotFitnessMachineError(data) + + +def get_machine_type_from_advertisement( + adv_data: AdvertisementData, +) -> MachineType: + """Returns fitness machine type from Bluetooth advertisement data. + + Some FTMS devices advertise the FTMS service UUID but omit FTMS service + data. Treat those as indoor bikes until GATT probing can detect the real + machine type after connection. + """ + + try: + return get_machine_type_from_service_data(adv_data) + + except NotFitnessMachineError: + if normalize_uuid_str(FTMS_UUID) in (adv_data.service_uuids or []): + return MachineType.INDOOR_BIKE + + raise + + +def get_machine_type_from_gatt(cli: BleakClient) -> MachineType: + """Returns fitness machine type from GATT data characteristics.""" + + for uuid, machine_type in GATT_DATA_UUID_TO_MACHINE_TYPE: + if cli.services.get_characteristic(uuid) is not None: + return machine_type + + raise NotFitnessMachineError( + reason="No supported FTMS data characteristic found" + ) diff --git a/src/pyftms/models/training_status.py b/src/pyftms/models/training_status.py index dc334c18f2..38a1da8b73 100644 --- a/src/pyftms/models/training_status.py +++ b/src/pyftms/models/training_status.py @@ -2,10 +2,21 @@ # SPDX-License-Identifier: Apache-2.0 import dataclasses as dc +import logging from enum import STRICT, IntEnum, IntFlag, auto from .common import BaseModel, model_meta +_LOGGER = logging.getLogger(__name__) +_LOGGED_UNKNOWN_FLAG_VALUES: set[int] = set() +_LOGGED_UNKNOWN_CODE_VALUES: set[int] = set() +_DEFINED_TRAINING_STATUS_FLAGS_MASK = 0x03 +_MAX_DEFINED_TRAINING_STATUS_CODE = 0x0F + + +def _format_value(value: int) -> str: + return f"{value} (0x{value:02X})" + class TrainingStatusFlags(IntFlag, boundary=STRICT): """ @@ -22,6 +33,49 @@ class TrainingStatusFlags(IntFlag, boundary=STRICT): EXTENDED_STRING = auto() """Idle.""" + @classmethod + def _missing_(cls, value): + # FTMS v1.0.1 section 4.10.1.2 defines only bits 0 and 1 in the + # Training Status flags field. Bits 2-7 are RFU and should not break + # parsing when devices set them. + # + # Observed on a Wahoo KICKR CORE v2: + # - raw flags: 155 (0x9B) + # - raw code: 93 (0x5D) + # These values are outside the public FTMS-defined surface, so we + # preserve the defined subset and report the reserved portion. + raw_value = int(value) + masked = raw_value & _DEFINED_TRAINING_STATUS_FLAGS_MASK + reserved_bits = raw_value & ~_DEFINED_TRAINING_STATUS_FLAGS_MASK + + if reserved_bits and raw_value not in _LOGGED_UNKNOWN_FLAG_VALUES: + _LOGGED_UNKNOWN_FLAG_VALUES.add(raw_value) + _LOGGER.warning( + "Received TrainingStatusFlags value %s outside the FTMS-defined " + "flag bits; masked to %s with reserved/RFU bits %s", + _format_value(raw_value), + _format_value(masked), + _format_value(reserved_bits), + ) + + obj = int.__new__(cls, masked) + obj._value_ = masked + obj._raw_value_ = raw_value + obj._reserved_bits_ = reserved_bits + return obj + + @property + def raw_value(self) -> int: + return getattr(self, "_raw_value_", int(self)) + + @property + def reserved_bits(self) -> int: + return getattr(self, "_reserved_bits_", 0) + + @property + def has_reserved_bits(self) -> bool: + return bool(self.reserved_bits) + class TrainingStatusCode(IntEnum, boundary=STRICT): """ @@ -30,6 +84,9 @@ class TrainingStatusCode(IntEnum, boundary=STRICT): Represents the current training state while a user is exercising. Described in section **4.10.1.2: Training Status Field**. + + FTMS v1.0.1 defines values 0x00-0x0F in the Training Status field. + Values 0x10-0xFF are reserved. """ OTHER = 0 @@ -80,6 +137,42 @@ class TrainingStatusCode(IntEnum, boundary=STRICT): POST_WORKOUT = auto() """Post-Workout.""" + @classmethod + def _missing_(cls, value: int): + value = int(value) + + # Reserved values are cached so repeated notifications reuse the same + # pseudo-member instead of synthesizing a new enum instance each time. + pseudo = cls._value2member_map_.get(value) + if pseudo is not None: + return pseudo + + if value not in _LOGGED_UNKNOWN_CODE_VALUES: + _LOGGED_UNKNOWN_CODE_VALUES.add(value) + _LOGGER.warning( + "Received reserved TrainingStatusCode value %s outside the " + "FTMS-defined range 0x00-0x%02X", + _format_value(value), + _MAX_DEFINED_TRAINING_STATUS_CODE, + ) + + obj = int.__new__(cls, value) + obj._value_ = value + obj._name_ = f"RESERVED_{value}" + obj._raw_value_ = value + obj._is_reserved_ = True + + cls._value2member_map_[value] = obj + return obj + + @property + def raw_value(self) -> int: + return getattr(self, "_raw_value_", int(self)) + + @property + def is_reserved(self) -> bool: + return getattr(self, "_is_reserved_", False) + @dc.dataclass(frozen=True) class TrainingStatusModel(BaseModel): diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000000..b85f207cee --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,26 @@ +from pyftms.client.client import FitnessMachine + + +class ResetTracker: + def __init__(self): + self.calls = 0 + + def reset(self): + self.calls += 1 + + +def test_on_disconnect_can_be_called_multiple_times(): + disconnects = [] + machine = object.__new__(FitnessMachine) + machine._updater = ResetTracker() + machine._controller = ResetTracker() + machine._disconnect_cb = disconnects.append + machine._cli = object() + + machine._on_disconnect(None) + machine._on_disconnect(None) + + assert machine._updater.calls == 2 + assert machine._controller.calls == 2 + assert disconnects == [machine, machine] + assert not hasattr(machine, "_cli") diff --git a/tests/test_controller.py b/tests/test_controller.py new file mode 100644 index 0000000000..03bbfac7ae --- /dev/null +++ b/tests/test_controller.py @@ -0,0 +1,125 @@ +import asyncio +import logging + +from pyftms.client.backends.controller import MachineController +from pyftms.models import TrainingStatusCode + + +class FakeCharacteristic: + uuid = "2ad3" + + +class FakeServices: + def __init__(self, characteristic): + self._characteristic = characteristic + + def get_characteristic(self, uuid): + if uuid == self._characteristic.uuid: + return self._characteristic + return None + + +class FakeClient: + def __init__(self, characteristic, responses): + self.services = FakeServices(characteristic) + self._responses = list(responses) + self.read_calls = [] + self.notify_callbacks = {} + + async def read_gatt_char(self, characteristic, *, use_cached=False, **kwargs): + self.read_calls.append((characteristic, use_cached)) + response = self._responses.pop(0) + if isinstance(response, Exception): + raise response + return response + + async def start_notify(self, characteristic, callback): + self.notify_callbacks[characteristic.uuid] = callback + + +def test_subscribe_reads_full_training_status_when_extended_string_is_set(): + async def scenario(): + events = [] + characteristic = FakeCharacteristic() + client = FakeClient( + characteristic, + [ + b"\x03\x05Wo", + b"\x03\x05Workout", + ], + ) + controller = MachineController(events.append) + + await controller.subscribe(client) + + assert len(events) == 1 + assert events[0].event_data["training_status"] == TrainingStatusCode(5) + assert events[0].event_data["training_status_string"] == "Workout" + assert client.read_calls == [ + (characteristic, False), + (characteristic, False), + ] + assert characteristic.uuid in client.notify_callbacks + + asyncio.run(scenario()) + + +def test_notify_extended_string_refresh_deduplicates_reads(): + async def scenario(): + events = [] + characteristic = FakeCharacteristic() + client = FakeClient(characteristic, [b"\x03\x05Workout"]) + controller = MachineController(events.append) + controller._cli = client + + controller._on_training_status(characteristic, b"\x03\x05Wo") + controller._on_training_status(characteristic, b"\x03\x05Wo") + + assert controller._training_status_refresh_task is not None + await controller._training_status_refresh_task + await asyncio.sleep(0) + + assert len(events) == 1 + assert events[0].event_data["training_status"] == TrainingStatusCode(5) + assert events[0].event_data["training_status_string"] == "Workout" + assert client.read_calls == [(characteristic, False)] + assert controller._training_status_refresh_task is None + + asyncio.run(scenario()) + + +def test_notify_extended_string_refresh_failure_falls_back_to_status(caplog): + async def scenario(): + events = [] + characteristic = FakeCharacteristic() + client = FakeClient(characteristic, [RuntimeError("read failed")]) + controller = MachineController(events.append) + controller._cli = client + + with caplog.at_level( + logging.WARNING, logger="pyftms.client.backends.controller" + ): + controller._on_training_status(characteristic, b"\x03\x05Wo") + assert controller._training_status_refresh_task is not None + await controller._training_status_refresh_task + await asyncio.sleep(0) + + assert len(events) == 1 + assert events[0].event_data == { + "training_status": TrainingStatusCode(5) + } + assert "Failed to refresh Training Status with Extended String" in caplog.text + + asyncio.run(scenario()) + + +def test_training_status_without_extended_string_uses_inline_payload(): + events = [] + controller = MachineController(events.append) + characteristic = FakeCharacteristic() + + controller._on_training_status(characteristic, b"\x01\x05Workout") + + assert len(events) == 1 + assert events[0].event_data["training_status"] == TrainingStatusCode(5) + assert events[0].event_data["training_status_string"] == "Workout" diff --git a/tests/test_machine_type.py b/tests/test_machine_type.py new file mode 100644 index 0000000000..99b228116e --- /dev/null +++ b/tests/test_machine_type.py @@ -0,0 +1,98 @@ +import pytest +from bleak.backends.scanner import AdvertisementData +from bleak.uuids import normalize_uuid_str + +from pyftms import ( + MachineType, + NotFitnessMachineError, + get_machine_type_from_advertisement, + get_machine_type_from_gatt, + get_machine_type_from_service_data, +) +from pyftms.client.const import ( + CROSS_TRAINER_DATA_UUID, + FTMS_UUID, + INDOOR_BIKE_DATA_UUID, + ROWER_DATA_UUID, + TREADMILL_DATA_UUID, +) + + +FTMS_SERVICE_UUID = normalize_uuid_str(FTMS_UUID) + + +def _advertisement( + *, + service_data: dict[str, bytes] | None = None, + service_uuids: list[str] | None = None, +) -> AdvertisementData: + return AdvertisementData( + local_name=None, + manufacturer_data={}, + service_data=service_data or {}, + service_uuids=service_uuids or [], + tx_power=None, + rssi=-60, + platform_data=(), + ) + + +class _FakeServices: + def __init__(self, uuids): + self._uuids = set(uuids) + + def get_characteristic(self, uuid): + return object() if uuid in self._uuids else None + + +class _FakeClient: + def __init__(self, uuids): + self.services = _FakeServices(uuids) + + +def test_get_machine_type_from_advertisement_uses_service_data(): + advertisement = _advertisement( + service_data={FTMS_SERVICE_UUID: b"\x01\x20"}, + service_uuids=[FTMS_SERVICE_UUID], + ) + + assert get_machine_type_from_advertisement(advertisement) is MachineType.INDOOR_BIKE + assert get_machine_type_from_service_data(advertisement) is MachineType.INDOOR_BIKE + + +def test_get_machine_type_from_advertisement_accepts_uuid_only_devices(): + advertisement = _advertisement(service_uuids=[FTMS_SERVICE_UUID]) + + assert get_machine_type_from_advertisement(advertisement) is MachineType.INDOOR_BIKE + with pytest.raises(NotFitnessMachineError): + get_machine_type_from_service_data(advertisement) + + +def test_get_machine_type_from_advertisement_rejects_non_ftms_devices(): + advertisement = _advertisement() + + with pytest.raises(NotFitnessMachineError): + get_machine_type_from_advertisement(advertisement) + + +@pytest.mark.parametrize( + ("uuid", "machine_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), + ], +) +def test_get_machine_type_from_gatt_uses_data_characteristic( + uuid, machine_type +): + assert get_machine_type_from_gatt(_FakeClient([uuid])) is machine_type + + +def test_get_machine_type_from_gatt_rejects_missing_data_characteristic(): + with pytest.raises( + NotFitnessMachineError, + match="No supported FTMS data characteristic found", + ): + get_machine_type_from_gatt(_FakeClient([])) diff --git a/tests/test_manager.py b/tests/test_manager.py new file mode 100644 index 0000000000..769c51b8c2 --- /dev/null +++ b/tests/test_manager.py @@ -0,0 +1,44 @@ +from pyftms.client.backends import SetupEvent, UpdateEvent +from pyftms.client.manager import PropertiesManager + + +def test_properties_returns_stable_snapshot(): + manager = PropertiesManager() + manager._on_event(UpdateEvent("update", {"speed_instant": 3.0})) + + properties = manager.properties + iterator = iter(properties.items()) + assert next(iterator) == ("speed_instant", 3.0) + + manager._on_event(UpdateEvent("update", {"cadence_instant": 90.0})) + + assert list(iterator) == [] + assert dict(properties) == {"speed_instant": 3.0} + assert manager.properties["cadence_instant"] == 90.0 + + +def test_settings_returns_stable_snapshot(): + manager = PropertiesManager() + manager._on_event( + SetupEvent( + event_id="setup", + event_data={"target_resistance": 5.0}, + event_source="callback", + ) + ) + + settings = manager.settings + iterator = iter(settings.items()) + assert next(iterator) == ("target_resistance", 5.0) + + manager._on_event( + SetupEvent( + event_id="setup", + event_data={"target_power": 120}, + event_source="callback", + ) + ) + + assert list(iterator) == [] + assert dict(settings) == {"target_resistance": 5.0} + assert manager.settings["target_power"] == 120 diff --git a/tests/test_models.py b/tests/test_models.py index 9597075916..78c897116b 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,7 +1,17 @@ +import io +import logging + import pytest -from pyftms.models import MachineStatusModel, TreadmillData +from pyftms.models import ( + MachineStatusModel, + TrainingStatusCode, + TrainingStatusFlags, + TrainingStatusModel, + TreadmillData, +) from pyftms.serializer import BaseModel, ModelSerializer, get_serializer +from pyftms.models import training_status as training_status_module @pytest.mark.parametrize( @@ -43,3 +53,79 @@ def test_realtime_data(model: type[BaseModel], data: bytes, result: dict): assert isinstance(s, ModelSerializer) assert s.deserialize(data)._asdict() == result + + +@pytest.fixture(autouse=True) +def reset_training_status_unknown_state(): + training_status_module._LOGGED_UNKNOWN_FLAG_VALUES.clear() + training_status_module._LOGGED_UNKNOWN_CODE_VALUES.clear() + + +def test_training_status_flags_masks_unknown_bits_and_logs_once(caplog): + with caplog.at_level(logging.WARNING, logger="pyftms.models.training_status"): + flags = TrainingStatusFlags(155) + flags_again = TrainingStatusFlags(155) + + assert flags == ( + TrainingStatusFlags.STRING_PRESENT | TrainingStatusFlags.EXTENDED_STRING + ) + assert flags.raw_value == 155 + assert flags.reserved_bits == 152 + assert flags.has_reserved_bits is True + assert flags_again.raw_value == 155 + + messages = [ + record.getMessage() + for record in caplog.records + if record.name == "pyftms.models.training_status" + ] + assert len(messages) == 1 + assert "TrainingStatusFlags value 155 (0x9B)" in messages[0] + assert "outside the FTMS-defined flag bits" in messages[0] + assert "masked to 3 (0x03)" in messages[0] + assert "reserved/RFU bits 152 (0x98)" in messages[0] + + +def test_training_status_flags_known_value_has_no_reserved_bits(): + flags = TrainingStatusFlags.STRING_PRESENT + + assert flags.raw_value == 1 + assert flags.reserved_bits == 0 + assert flags.has_reserved_bits is False + + +def test_training_status_code_reserved_value_is_cached_and_logs_once(caplog): + with caplog.at_level(logging.WARNING, logger="pyftms.models.training_status"): + code = TrainingStatusCode(93) + code_again = TrainingStatusCode(93) + + assert code is code_again + assert code.name == "RESERVED_93" + assert code.raw_value == 93 + assert code.is_reserved is True + + messages = [ + record.getMessage() + for record in caplog.records + if record.name == "pyftms.models.training_status" + ] + assert len(messages) == 1 + assert "TrainingStatusCode value 93 (0x5D)" in messages[0] + assert "outside the FTMS-defined range 0x00-0x0F" in messages[0] + + +def test_training_status_code_known_value_is_not_reserved(): + assert TrainingStatusCode.IDLE.raw_value == 1 + assert TrainingStatusCode.IDLE.is_reserved is False + + +def test_training_status_model_deserializes_non_standard_values(): + model = TrainingStatusModel._deserialize(io.BytesIO(b"\x9B\x5D")) + + assert model.flags.raw_value == 155 + assert model.flags.reserved_bits == 152 + assert model.flags.has_reserved_bits is True + assert model.code == TrainingStatusCode(93) + assert model.code.name == "RESERVED_93" + assert model.code.raw_value == 93 + assert model.code.is_reserved is True diff --git a/tests/test_updater.py b/tests/test_updater.py new file mode 100644 index 0000000000..b922ba8d84 --- /dev/null +++ b/tests/test_updater.py @@ -0,0 +1,125 @@ +from pyftms.client.backends import DataUpdater +from pyftms.models import IndoorBikeData + + +class _FakeRealtimeModel: + def __init__(self, data): + self._data = data + + def _asdict(self): + return self._data + + +class _FakeSerializer: + def __init__(self, *payloads): + self._payloads = iter(payloads) + + def deserialize(self, _data): + return _FakeRealtimeModel(next(self._payloads)) + + +def test_updater_emits_zero_value_changes(): + events = [] + updater = DataUpdater(IndoorBikeData, events.append) + updater._serializer = _FakeSerializer( + {"speed_instant": 5.0, "cadence_instant": 50.0}, + {"speed_instant": 0.0, "cadence_instant": 0.0}, + ) + + updater._on_notify(None, bytearray(b"\x00")) + updater._on_notify(None, bytearray(b"\x00")) + + assert len(events) == 2 + assert events[0].event_id == "update" + assert events[0].event_data == { + "speed_instant": 5.0, + "cadence_instant": 50.0, + } + assert events[1].event_id == "update" + assert events[1].event_data == { + "speed_instant": 0.0, + "cadence_instant": 0.0, + } + + +def test_updater_uses_stable_snapshot_for_callback_event(): + events = [] + updater = DataUpdater(IndoorBikeData, events.append) + updater._serializer = _FakeSerializer( + {"speed_instant": 5.0}, + {"cadence_instant": 90.0}, + ) + + updater._on_notify(None, bytearray(b"\x00")) + first_event_data = events[0].event_data + + updater._on_notify(None, bytearray(b"\x00")) + + assert first_event_data == {"speed_instant": 5.0} + assert events[1].event_data == {"cadence_instant": 90.0} + + +def test_updater_suppresses_zero_only_packets_before_first_activity(): + events = [] + updater = DataUpdater(IndoorBikeData, events.append) + updater._serializer = _FakeSerializer( + {"speed_instant": 0.0}, + {"speed_instant": 0.0}, + ) + + updater._on_notify(None, bytearray(b"\x00")) + updater._on_notify(None, bytearray(b"\x00")) + + assert events == [] + + +def test_updater_deduplicates_repeated_zero_packets(): + events = [] + updater = DataUpdater(IndoorBikeData, events.append) + updater._serializer = _FakeSerializer( + {"speed_instant": 3.0}, + {"speed_instant": 0.0}, + {"speed_instant": 0.0}, + ) + + updater._on_notify(None, bytearray(b"\x00")) + updater._on_notify(None, bytearray(b"\x00")) + updater._on_notify(None, bytearray(b"\x00")) + + assert len(events) == 2 + assert events[0].event_data == {"speed_instant": 3.0} + assert events[1].event_data == {"speed_instant": 0.0} + + +def test_updater_emits_mixed_packets_before_seen_nonzero(): + events = [] + updater = DataUpdater(IndoorBikeData, events.append) + updater._serializer = _FakeSerializer( + {"speed_instant": 0.0, "cadence_instant": 50.0}, + ) + + updater._on_notify(None, bytearray(b"\x00")) + + assert len(events) == 1 + assert events[0].event_data == { + "speed_instant": 0.0, + "cadence_instant": 50.0, + } + + +def test_updater_reset_restores_startup_zero_suppression(): + events = [] + updater = DataUpdater(IndoorBikeData, events.append) + updater._serializer = _FakeSerializer( + {"speed_instant": 4.0}, + {"speed_instant": 0.0}, + {"speed_instant": 0.0}, + ) + + updater._on_notify(None, bytearray(b"\x00")) + updater.reset() + updater._on_notify(None, bytearray(b"\x00")) + updater._on_notify(None, bytearray(b"\x00")) + + assert len(events) == 1 + assert events[0].event_data == {"speed_instant": 4.0} diff --git a/uv.lock b/uv.lock index eaab8d1a65..ddf3c80691 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.12, <3.14" +requires-python = ">=3.12, <3.15" [[package]] name = "aiooui" @@ -241,7 +241,7 @@ wheels = [ [[package]] name = "pyftms" -version = "0.4.15" +version = "0.4.15+mw.5" source = { editable = "." } dependencies = [ { name = "bleak" },