Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
88e94ce
Update Python version requirements to support 3.14
michaelw Mar 24, 2026
bf047f5
Implement TrainingStatusFlags and TrainingStatusCode handling for unk…
michaelw Mar 24, 2026
d7243c5
Handle repeated disconnect callbacks without _cli errors
michaelw Mar 24, 2026
77c875b
Handle extended training status strings
michaelw Mar 24, 2026
f3fde41
Merge branch 'mw/kickr-core-v2' into mw/main
michaelw Mar 24, 2026
82054a9
Merge branch 'mw/extended-status-strings' into mw/main
michaelw Mar 24, 2026
514eae6
Prepare fork release v0.4.15+mw.1
michaelw Mar 24, 2026
06270d3
Preserve real nonzero-to-zero realtime updates
michaelw Mar 25, 2026
116e8fc
Merge branch 'mw/zero-value-realtime-updates' into mw/main
michaelw Mar 25, 2026
10231e2
Prepare fork release v0.4.15+mw.2
michaelw Mar 25, 2026
9c4189e
feat: Support UUID-only FTMS devices with data-only fallback and post…
mhamzahkhan Aug 30, 2025
d663911
Prepare fork release v0.4.15+mw.3
michaelw Jun 15, 2026
e91e8d3
feat: Support UUID-only FTMS devices with data-only fallback and post…
mhamzahkhan Aug 30, 2025
c9de1ee
Expose FTMS advertisement machine type detection
michaelw Jun 16, 2026
dd75047
Merge branch 'mw/uuid-only-ftms-fallback' into mw/main
michaelw Jun 16, 2026
4240857
Prepare fork release v0.4.15+mw.4
michaelw Jun 16, 2026
16cd41d
Snapshot FTMS callback state before diffing
Jun 19, 2026
9120d14
Merge branch 'mw/pyftms-concurrency-snapshots' into mw/main
Jun 19, 2026
54e1b88
Prepare fork release v0.4.15+mw.5
Jun 19, 2026
b30cd4b
Port Bodytone GATT machine type detection
Jun 19, 2026
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
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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).
13 changes: 7 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" },
Expand All @@ -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,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 = [
Expand Down
4 changes: 4 additions & 0 deletions src/pyftms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down
9 changes: 7 additions & 2 deletions src/pyftms/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
MachineType,
MovementDirection,
SettingRange,
get_machine_type_from_advertisement,
get_machine_type_from_gatt,
get_machine_type_from_service_data,
)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
116 changes: 109 additions & 7 deletions src/pyftms/client/backends/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,21 @@ 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:
"""Subscribe for available notifications."""
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):
Expand All @@ -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."""
Expand Down Expand Up @@ -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
39 changes: 28 additions & 11 deletions src/pyftms/client/backends/updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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()
Loading