From 88e94ce30cb23b56ecb05a1b3f59aa922fafe323 Mon Sep 17 00:00:00 2001 From: Michael Weber Date: Mon, 23 Mar 2026 19:29:20 -0500 Subject: [PATCH 01/15] Update Python version requirements to support 3.14 --- .github/workflows/pytest.yml | 1 + pyproject.toml | 3 ++- uv.lock | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) 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/pyproject.toml b/pyproject.toml index 081007bce6..f6f1ab5dd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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,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] diff --git a/uv.lock b/uv.lock index eaab8d1a65..97f5b6546c 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" From bf047f5b7c7354f785ae60761a49b69d7f29212f Mon Sep 17 00:00:00 2001 From: Michael Weber Date: Tue, 24 Mar 2026 04:30:38 +0000 Subject: [PATCH 02/15] Implement TrainingStatusFlags and TrainingStatusCode handling for unknown and reserved values with logging --- src/pyftms/models/training_status.py | 93 ++++++++++++++++++++++++++++ tests/test_models.py | 88 +++++++++++++++++++++++++- 2 files changed, 180 insertions(+), 1 deletion(-) 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_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 From d7243c5c7c6fadc326d509ac114f60eb56e90616 Mon Sep 17 00:00:00 2001 From: Michael Weber Date: Tue, 24 Mar 2026 11:16:15 -0500 Subject: [PATCH 03/15] Handle repeated disconnect callbacks without _cli errors --- src/pyftms/client/client.py | 3 ++- tests/test_client.py | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 tests/test_client.py diff --git a/src/pyftms/client/client.py b/src/pyftms/client/client.py index 8dca257fa1..beda8db620 100644 --- a/src/pyftms/client/client.py +++ b/src/pyftms/client/client.py @@ -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() 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") From 77c875b6b9c973e1892857d2114ca3cda4e04efd Mon Sep 17 00:00:00 2001 From: Michael Weber Date: Tue, 24 Mar 2026 09:26:31 -0500 Subject: [PATCH 04/15] Handle extended training status strings --- src/pyftms/client/backends/controller.py | 116 +++++++++++++++++++-- tests/test_controller.py | 125 +++++++++++++++++++++++ 2 files changed, 234 insertions(+), 7 deletions(-) create mode 100644 tests/test_controller.py 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/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" From 514eae67213d41b924f6898bda6068aa9d466ce2 Mon Sep 17 00:00:00 2001 From: Michael Weber Date: Tue, 24 Mar 2026 17:28:23 -0500 Subject: [PATCH 05/15] Prepare fork release v0.4.15+mw.1 Bump the package version for the production fork release and point project metadata at the fork. This release will be consumed by the forked Home Assistant FTMS integration via a tagged git dependency reference. --- README.md | 12 +++++++++++- pyproject.toml | 10 +++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b947a6b56b..14c0787bb1 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.1`. + ## 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.1" +``` + ## 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 f6f1ab5dd2..53cf0b9bd9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyftms" -version = "0.4.15" +version = "0.4.15+mw.1" description = "Bluetooth Fitness Machine Service async client library." authors = [ { name = "Sergey Dudanov", email = "sergey.dudanov@gmail.com" }, @@ -38,10 +38,10 @@ classifiers = [ ] [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 = [ From 06270d31115c3a345aba456e1f4698946eadae52 Mon Sep 17 00:00:00 2001 From: Michael Weber Date: Tue, 24 Mar 2026 22:25:38 -0500 Subject: [PATCH 06/15] Preserve real nonzero-to-zero realtime updates Zero-only realtime packets were previously treated as null packets. That dropped legitimate nonzero-to-zero transitions, which could leave Home Assistant sensors stuck on the last nonzero value.\n\nKeep suppressing likely bogus startup zero-only packets until the first nonzero realtime packet is seen, while still emitting real nonzero-to-zero transitions and deduplicating repeated zero packets. --- src/pyftms/client/backends/updater.py | 31 +++++--- tests/test_updater.py | 108 ++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 11 deletions(-) create mode 100644 tests/test_updater.py diff --git a/src/pyftms/client/backends/updater.py b/src/pyftms/client/backends/updater.py index dc51867a09..a37fd5a0db 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,23 @@ 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. + if self._result and all(value == 0 for value in self._result.values()): + if not self._seen_nonzero: + self._result.clear() + return + else: + self._seen_nonzero = True + + 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() self._result.clear() diff --git a/tests/test_updater.py b/tests/test_updater.py new file mode 100644 index 0000000000..828ffe98fe --- /dev/null +++ b/tests/test_updater.py @@ -0,0 +1,108 @@ +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_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} From 10231e2f44a916da57b178b674a3d67eb36bc6f1 Mon Sep 17 00:00:00 2001 From: Michael Weber Date: Tue, 24 Mar 2026 21:49:33 -0500 Subject: [PATCH 07/15] Prepare fork release v0.4.15+mw.2 --- README.md | 4 ++-- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 14c0787bb1..d11cee3f18 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ **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.1`. +integration. The corresponding fork release for this branch is `v0.4.15+mw.2`. ## Requirments @@ -25,7 +25,7 @@ pip install pyftms ## Install the forked release ```bash -pip install "pyftms @ git+https://github.com/michaelw/python-pyftms.git@v0.4.15+mw.1" +pip install "pyftms @ git+https://github.com/michaelw/python-pyftms.git@v0.4.15+mw.2" ``` ## Usage diff --git a/pyproject.toml b/pyproject.toml index 53cf0b9bd9..9acc0fedaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyftms" -version = "0.4.15+mw.1" +version = "0.4.15+mw.2" description = "Bluetooth Fitness Machine Service async client library." authors = [ { name = "Sergey Dudanov", email = "sergey.dudanov@gmail.com" }, From 9c4189e1ad06de8341992aa30167a5135a66e70a Mon Sep 17 00:00:00 2001 From: "M. Hamzah Khan" Date: Sat, 30 Aug 2025 15:25:26 +0100 Subject: [PATCH 08/15] 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 beda8db620..09e64a0c39 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__) @@ -275,15 +280,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 From d6639118479c314d88594b34b94506983150602f Mon Sep 17 00:00:00 2001 From: Michael Weber Date: Mon, 15 Jun 2026 10:38:17 -0500 Subject: [PATCH 09/15] Prepare fork release v0.4.15+mw.3 --- README.md | 4 ++-- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d11cee3f18..8a05b02ed5 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ **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.2`. +integration. The corresponding fork release for this branch is `v0.4.15+mw.3`. ## Requirments @@ -25,7 +25,7 @@ pip install pyftms ## Install the forked release ```bash -pip install "pyftms @ git+https://github.com/michaelw/python-pyftms.git@v0.4.15+mw.2" +pip install "pyftms @ git+https://github.com/michaelw/python-pyftms.git@v0.4.15+mw.3" ``` ## Usage diff --git a/pyproject.toml b/pyproject.toml index 9acc0fedaf..1e62b84f6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyftms" -version = "0.4.15+mw.2" +version = "0.4.15+mw.3" description = "Bluetooth Fitness Machine Service async client library." authors = [ { name = "Sergey Dudanov", email = "sergey.dudanov@gmail.com" }, From e91e8d3c5e0ee3d2f16f1f630804795d11d8764a Mon Sep 17 00:00:00 2001 From: "M. Hamzah Khan" Date: Sat, 30 Aug 2025 15:25:26 +0100 Subject: [PATCH 10/15] 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 From c9de1ee480d91211129e06d5dc9d1d9085a714d5 Mon Sep 17 00:00:00 2001 From: Michael Weber Date: Tue, 16 Jun 2026 17:52:44 -0500 Subject: [PATCH 11/15] Expose FTMS advertisement machine type detection --- src/pyftms/__init__.py | 2 + src/pyftms/client/__init__.py | 20 ++----- src/pyftms/client/properties/__init__.py | 7 ++- src/pyftms/client/properties/machine_type.py | 20 +++++++ tests/test_machine_type.py | 55 ++++++++++++++++++++ 5 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 tests/test_machine_type.py diff --git a/src/pyftms/__init__.py b/src/pyftms/__init__.py index 39f67ebcfb..d88970ed8c 100644 --- a/src/pyftms/__init__.py +++ b/src/pyftms/__init__.py @@ -24,6 +24,7 @@ discover_ftms_devices, get_client, get_client_from_address, + get_machine_type_from_advertisement, get_machine_type_from_service_data, ) from .client.backends import FtmsEvents @@ -41,6 +42,7 @@ "discover_ftms_devices", "get_client", "get_client_from_address", + "get_machine_type_from_advertisement", "get_machine_type_from_service_data", "FitnessMachine", "CrossTrainer", diff --git a/src/pyftms/client/__init__.py b/src/pyftms/client/__init__.py index 2797f3b067..0aadb5ef86 100644 --- a/src/pyftms/client/__init__.py +++ b/src/pyftms/client/__init__.py @@ -32,6 +32,7 @@ MachineType, MovementDirection, SettingRange, + get_machine_type_from_advertisement, get_machine_type_from_service_data, ) @@ -67,21 +68,7 @@ def get_client( if isinstance(adv_or_type, AdvertisementData): adv_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 + adv_or_type = get_machine_type_from_advertisement(adv_or_type) cls = get_machine(adv_or_type) @@ -123,7 +110,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 @@ -188,6 +175,7 @@ async def get_client_from_address( "discover_ftms_devices", "get_client", "get_client_from_address", + "get_machine_type_from_advertisement", "MachineType", "NotFitnessMachineError", "UpdateEvent", diff --git a/src/pyftms/client/properties/__init__.py b/src/pyftms/client/properties/__init__.py index bd4a3d3b1f..09c6f43f53 100644 --- a/src/pyftms/client/properties/__init__.py +++ b/src/pyftms/client/properties/__init__.py @@ -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_advertisement, + get_machine_type_from_service_data, +) __all__ = [ "DeviceInfo", + "get_machine_type_from_advertisement", "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..8e7dfb96e7 100644 --- a/src/pyftms/client/properties/machine_type.py +++ b/src/pyftms/client/properties/machine_type.py @@ -79,3 +79,23 @@ 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 diff --git a/tests/test_machine_type.py b/tests/test_machine_type.py new file mode 100644 index 0000000000..e6e88e3c2c --- /dev/null +++ b/tests/test_machine_type.py @@ -0,0 +1,55 @@ +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_service_data, +) +from pyftms.client.const import FTMS_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=(), + ) + + +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) From 4240857f19367fb10eb49727b79ccb6729461fa3 Mon Sep 17 00:00:00 2001 From: Michael Weber Date: Tue, 16 Jun 2026 17:54:49 -0500 Subject: [PATCH 12/15] Prepare fork release v0.4.15+mw.4 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1e62b84f6d..b3624d4e09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyftms" -version = "0.4.15+mw.3" +version = "0.4.15+mw.4" description = "Bluetooth Fitness Machine Service async client library." authors = [ { name = "Sergey Dudanov", email = "sergey.dudanov@gmail.com" }, diff --git a/uv.lock b/uv.lock index 97f5b6546c..edf43becae 100644 --- a/uv.lock +++ b/uv.lock @@ -241,7 +241,7 @@ wheels = [ [[package]] name = "pyftms" -version = "0.4.15" +version = "0.4.15+mw.4" source = { editable = "." } dependencies = [ { name = "bleak" }, From 16cd41dbb6b2823a9458564e9614dec96a54d00f Mon Sep 17 00:00:00 2001 From: Michael Weber Date: Thu, 18 Jun 2026 19:44:13 -0500 Subject: [PATCH 13/15] Snapshot FTMS callback state before diffing --- src/pyftms/client/backends/updater.py | 16 +++++++--- src/pyftms/client/manager.py | 8 ++--- tests/test_manager.py | 44 +++++++++++++++++++++++++++ tests/test_updater.py | 17 +++++++++++ 4 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 tests/test_manager.py diff --git a/src/pyftms/client/backends/updater.py b/src/pyftms/client/backends/updater.py index a37fd5a0db..deff08155d 100644 --- a/src/pyftms/client/backends/updater.py +++ b/src/pyftms/client/backends/updater.py @@ -51,20 +51,28 @@ def _on_notify(self, c: BleakGATTCharacteristic, data: bytearray) -> None: # 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. - if self._result and all(value == 0 for value in self._result.values()): + 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 - update = self._result.items() ^ self._prev.items() + missing = object() + update = { + key: value + for key, value in result.items() + if prev.get(key, missing) != value + } - if update := {k: self._result[k] for k, _ in update}: + 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 = self._result.copy() + self._prev = result self._result.clear() 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/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_updater.py b/tests/test_updater.py index 828ffe98fe..b922ba8d84 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -42,6 +42,23 @@ def test_updater_emits_zero_value_changes(): } +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) From 54e1b88cfb07c0cefbe2a5e23eae5b7b426da450 Mon Sep 17 00:00:00 2001 From: Michael Weber Date: Thu, 18 Jun 2026 22:10:32 -0500 Subject: [PATCH 14/15] Prepare fork release v0.4.15+mw.5 --- README.md | 4 ++-- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8a05b02ed5..9f5604b7f8 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ **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.3`. +integration. The corresponding fork release for this branch is `v0.4.15+mw.5`. ## Requirments @@ -25,7 +25,7 @@ pip install pyftms ## Install the forked release ```bash -pip install "pyftms @ git+https://github.com/michaelw/python-pyftms.git@v0.4.15+mw.3" +pip install "pyftms @ git+https://github.com/michaelw/python-pyftms.git@v0.4.15+mw.5" ``` ## Usage diff --git a/pyproject.toml b/pyproject.toml index b3624d4e09..aec5b2ba05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyftms" -version = "0.4.15+mw.4" +version = "0.4.15+mw.5" description = "Bluetooth Fitness Machine Service async client library." authors = [ { name = "Sergey Dudanov", email = "sergey.dudanov@gmail.com" }, diff --git a/uv.lock b/uv.lock index edf43becae..ddf3c80691 100644 --- a/uv.lock +++ b/uv.lock @@ -241,7 +241,7 @@ wheels = [ [[package]] name = "pyftms" -version = "0.4.15+mw.4" +version = "0.4.15+mw.5" source = { editable = "." } dependencies = [ { name = "bleak" }, From b30cd4b0da6b2e708a16b42744e66d3a2fe76a6c Mon Sep 17 00:00:00 2001 From: Michael Weber Date: Fri, 19 Jun 2026 12:56:01 -0500 Subject: [PATCH 15/15] Port Bodytone GATT machine type detection --- src/pyftms/__init__.py | 2 + src/pyftms/client/__init__.py | 3 ++ src/pyftms/client/client.py | 44 +++++++++++-------- src/pyftms/client/errors.py | 8 ++-- src/pyftms/client/properties/__init__.py | 2 + src/pyftms/client/properties/machine_type.py | 29 ++++++++++++- tests/test_machine_type.py | 45 +++++++++++++++++++- 7 files changed, 111 insertions(+), 22 deletions(-) diff --git a/src/pyftms/__init__.py b/src/pyftms/__init__.py index d88970ed8c..60fa6ce863 100644 --- a/src/pyftms/__init__.py +++ b/src/pyftms/__init__.py @@ -25,6 +25,7 @@ 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 @@ -43,6 +44,7 @@ "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 0aadb5ef86..fc65126e67 100644 --- a/src/pyftms/client/__init__.py +++ b/src/pyftms/client/__init__.py @@ -33,6 +33,7 @@ MovementDirection, SettingRange, get_machine_type_from_advertisement, + get_machine_type_from_gatt, get_machine_type_from_service_data, ) @@ -176,6 +177,8 @@ async def get_client_from_address( "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/client.py b/src/pyftms/client/client.py index 09e64a0c39..3608d34a59 100644 --- a/src/pyftms/client/client.py +++ b/src/pyftms/client/client.py @@ -36,11 +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 +from .errors import CharacteristicNotFound, NotFitnessMachineError _LOGGER = logging.getLogger(__name__) @@ -287,29 +288,38 @@ async def _connect(self) -> None: 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: + 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", []): - selected = (uuid, mt, model) - break + should_switch = ( + getattr(self, "_data_uuid", None) != uuid + or self._data_model is not model + ) + else: + should_switch = False - if selected: - uuid, mt, model = selected - if getattr(self, "_data_uuid", None) != uuid or self._data_model is not model: + if should_switch: _LOGGER.debug( "Detected data characteristic %s; switching machine type to %s", uuid, - mt.name, + detected_type.name, ) - self._machine_type = mt + self._machine_type = detected_type self._data_uuid = uuid self._updater = DataUpdater(model, self._on_event) 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/properties/__init__.py b/src/pyftms/client/properties/__init__.py index 09c6f43f53..87e0664414 100644 --- a/src/pyftms/client/properties/__init__.py +++ b/src/pyftms/client/properties/__init__.py @@ -12,12 +12,14 @@ 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 8e7dfb96e7..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: @@ -99,3 +114,15 @@ def get_machine_type_from_advertisement( 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/tests/test_machine_type.py b/tests/test_machine_type.py index e6e88e3c2c..99b228116e 100644 --- a/tests/test_machine_type.py +++ b/tests/test_machine_type.py @@ -6,9 +6,16 @@ MachineType, NotFitnessMachineError, get_machine_type_from_advertisement, + get_machine_type_from_gatt, get_machine_type_from_service_data, ) -from pyftms.client.const import FTMS_UUID +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) @@ -30,6 +37,19 @@ def _advertisement( ) +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"}, @@ -53,3 +73,26 @@ def test_get_machine_type_from_advertisement_rejects_non_ftms_devices(): 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([]))