ESP32-H2 firmware bridging a CitySports CS-WP9 treadmill from BLE to Zigbee.
  • C 70.1%
  • Python 16.2%
  • Just 12.9%
  • CMake 0.8%
Find a file
dikkadev 5dad9c4d64 fix(main): clear pending speed when treadmill returns to idle
Ensure that a pending start speed is discarded when the user stops the treadmill (e.g. via handlebar during countdown) so it does not unexpectedly start running later when the state transitions again, aligning behavior with user intent and safety expectations.
2026-03-22 01:39:01 +01:00
.dev feat(treadmill): add esp32-h2 zigbee-ble bridge for citysports cs-wp9 2026-03-21 23:55:48 +01:00
.pyre feat(main): add zigbee config endpoint for persistent start speed 2026-03-22 01:29:52 +01:00
firmware fix(main): clear pending speed when treadmill returns to idle 2026-03-22 01:39:01 +01:00
ha_quirks feat(main): add zigbee config endpoint for persistent start speed 2026-03-22 01:29:52 +01:00
protocol_poc chore(protocol_poc): move treadmill protocol POC into dedicated subdirectory 2026-03-21 23:21:11 +01:00
.gitignore feat(firmware): make treadmill speed limits and zigbee mapping configurable 2026-03-22 01:03:47 +01:00
Justfile feat(firmware): make treadmill speed limits and zigbee mapping configurable 2026-03-22 01:03:47 +01:00
README.md feat(treadmill): add esp32-h2 zigbee-ble bridge for citysports cs-wp9 2026-03-21 23:55:48 +01:00

CitySports CS-WP9 Treadmill — Zigbee + BLE Controller

ESP32-H2 (NanoH2) that lives inside the treadmill, connects to its BLE control interface, and exposes it to Home Assistant as a Zigbee device.

How It Works

The treadmill uses a proprietary BLE protocol (not FTMS). The NanoH2 acts as a BLE GATT client that connects to the treadmill's CITYSPORTS-Linker BLE identity and translates commands to/from Zigbee HA clusters.

Home Assistant (ZHA)
    ↕ Zigbee
NanoH2 (this firmware)
    ↕ BLE GATT
Treadmill MCU

The NanoH2 is powered by the treadmill (mains power), so it boots when the treadmill is plugged in and waits a few seconds before attempting BLE connection.

v1 Scope

  • On/Off cluster → start / stop
  • Analog Output → target speed (1.0 12.0 km/h)
  • Analog Value → state code (1=starting, 2=running, 5=stopping, 6=idle)
  • BLE MAC configurable via Zigbee (Basic.location_description)
  • Auto-reconnect with exponential backoff

Not in v1 (needs more reverse engineering):

  • Actual belt speed (byte 4 always reads 1.0 — needs captures at higher speeds)
  • Telemetry (time, distance, calories, steps — type-0x02 notifications not decoded)
  • Pause/resume (may be just stop + start, needs testing)

Project Structure

.
├── README.md
├── Justfile                        # Build / flash / monitor recipes
├── firmware/
│   ├── CMakeLists.txt
│   ├── sdkconfig.defaults
│   ├── partitions.csv
│   └── main/
│       ├── CMakeLists.txt
│       ├── idf_component.yml       # esp-zigbee-lib ~1.6.0
│       └── main.c                  # All firmware code
├── ha_quirks/
│   └── citysports.py               # ZHA quirk for friendly HA entities
├── protocol_poc/                   # Protocol docs + Python/ESPHome PoCs
│   ├── CS-WP9_BLE_PROTOCOL.md     # Full protocol reference
│   ├── README.md
│   ├── citysports_control.py       # Python CLI controller (bleak)
│   └── esphome_treadmill.yaml      # ESPHome alternative (separate ESP32)
└── .dev/
    └── zigbee_and_ble_reference.md  # NimBLE + coexistence research notes

Setup

Prerequisites

  • ESP-IDF v5.3.3 (just setup-idf if not installed)
  • M5Stack NanoH2 (or any ESP32-H2 board)

Build & Flash

just set-target      # once, after first clone
just build           # compile
just flash           # build + flash via Windows COM port
just monitor         # serial output
just run             # flash + monitor

Update the COM port in the Justfile (port := "COM9").

Configure BLE MAC

After the device joins your Zigbee network, set the treadmill's BLE MAC address via the ZHA quirk button (or directly write to Basic cluster's location_description attribute):

70:19:88:XX:XX:XX

Find your treadmill's MAC with nRF Connect or similar BLE scanner — look for CITYSPORTS-Linker.

The MAC is saved to NVS and persists across reboots.

Home Assistant

Install the ZHA quirk (ha_quirks/citysports.py) to get friendly entities:

  • Switch: Treadmill On/Off
  • Number: Treadmill Speed (slider, 1.012.0 km/h, 0.1 step)
  • Sensor: Treadmill State (1/2/5/6)
  • Button: Set BLE MAC (one-time config)

Protocol Reference

See protocol_poc/CS-WP9_BLE_PROTOCOL.md for the full reverse-engineered protocol.

BLE + Zigbee Coexistence

The ESP32-H2 has a single 2.4 GHz radio shared between BLE and 802.15.4 (Zigbee) via time-division multiplexing. Key design decisions:

  • No BLE scanning — connects directly by MAC address (BLE scanning + Zigbee = unstable per Espressif coexistence docs)
  • BLE Connected + Zigbee ED = stable — the supported operating mode
  • Connection params (50100 ms interval) balance BLE responsiveness with Zigbee airtime
  • CONFIG_ESP_COEX_SW_COEXIST_ENABLE=y enables automatic coexistence