From 88e94ce30cb23b56ecb05a1b3f59aa922fafe323 Mon Sep 17 00:00:00 2001 From: Michael Weber Date: Mon, 23 Mar 2026 19:29:20 -0500 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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")