citysports-efc-ble

Async Python library for treadmills that speak the EFC protocol over Bluetooth Low Energy, such as the CITYSPORTS WP9. Requires Python 3.14 or newer.

The library is unofficial and reverse-engineered. It is not endorsed by CITYSPORTS or EQiSports and can break when the firmware changes.

Install

uv add citysports-efc-ble

Usage

EfcClient takes a BleTransport. BleakTransport wraps a Bleak BLEDevice. Tests can pass a fake transport instead.

import asyncio

from bleak import BleakScanner

from citysports_efc_ble import BleakTransport, EfcClient


async def main() -> None:
    device = await BleakScanner.find_device_by_address("AA:BB:CC:DD:EE:FF")
    if device is None:
        raise SystemExit("Treadmill not found")
    async with EfcClient(BleakTransport(device)) as client:
        print(client.state)


asyncio.run(main())

connect() returns once a device info frame and a status frame arrived and the session is READY. A transport failure during connect() raises EfcConnectionError. Every valid frame updates client.state and is passed to the optional update_callback as an EfcUpdate.

The keyword-only options response_timeout_seconds, keepalive_seconds, write_spacing_seconds and ramp_interval_seconds tune the timing. The API reference lists their defaults. An invalid timing raises EfcValidationError.

Controls

Control methods raise EfcNotReadyError unless the client was built with allow_control=True and the session is READY. Without allow_control=True they raise EfcControlDisabledError, a subclass of EfcNotReadyError. After a failed session they raise EfcConnectionError until the next connect().

async with EfcClient(BleakTransport(device), allow_control=True) as client:
    await client.start()
    await asyncio.sleep(4)
    await client.set_speed(3.0)
    await client.stop()

set_speed ramps in 0.1 steps from the last reported speed while the belt runs, one step every ramp_interval_seconds seconds. A later set_speed, start, pause, resume, stop or disconnect() cancels a running ramp. pause, resume, set_incline and request_sport_record follow the published Trught notes and are untested on real hardware.

Connection loss

The client detects a dropped link through the disconnect callback of the transport and through a failed write. A keepalive sends the device info query every keepalive_seconds seconds, 30 by default. When a session fails:

  • pending and later control calls raise EfcConnectionError

  • client.status becomes DISCONNECTED

  • the optional connection_lost_callback receives the exception

Call connect() again to start a new session.