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
EfcConnectionErrorclient.statusbecomesDISCONNECTEDthe optional
connection_lost_callbackreceives the exception
Call connect() again to start a new session.