Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ jobs:
python-version:
- "3.12"
- "3.13"
- "3.14"

steps:
- uses: actions/checkout@v6
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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]
Expand Down
3 changes: 2 additions & 1 deletion src/pyftms/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
93 changes: 93 additions & 0 deletions src/pyftms/models/training_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand All @@ -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):
"""
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down
26 changes: 26 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
@@ -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")
88 changes: 87 additions & 1 deletion tests/test_models.py
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.