Standalone ESP32 firmware that reads live telemetry from a Tenways CGO600 e-bike over Bluetooth Low Energy and forwards it to Home Assistant via MQTT. Includes a built-in, multilingual web dashboard (installable as a PWA) for live monitoring, light control, and a local ride history β no computer needed after initial setup.
"TENWAYS" and "CGO600" are trademarks of TENWAYS. This is an unofficial community project, not affiliated with or endorsed by TENWAYS β use at your own risk.
I own a Tenways CGO600 and run a smart home built around Home Assistant. I like to integrate as many devices as possible into Home Assistant, including devices that do not expose a ready-made integration.
For this project I reverse engineered the bike's Bluetooth protocol and built ESP32 firmware that acts as a small BLE-to-MQTT gateway. In my setup the ESP32 is mounted in the shed where the bike is parked. When the bike connects on departure or arrival, the gateway reads the latest data and publishes it to Home Assistant via MQTT.
| Dashboard | Rides | Configuration |
|---|---|---|
![]() |
![]() |
![]() |
| Sensor | Unit | MQTT topic |
|---|---|---|
| Battery | % | cgo600/sensor/battery/state |
| Speed | km/h | cgo600/sensor/speed/state |
| Max speed | km/h | cgo600/sensor/max_speed/state |
| Average speed | km/h | cgo600/sensor/avg_speed/state |
| Trip distance | km | cgo600/sensor/trip_distance/state |
| Trip duration | s | cgo600/sensor/trip_duration/state |
| Remaining range | km | cgo600/sensor/remaining_range/state |
| Total range | km | cgo600/sensor/range/state |
| Serial number | β | cgo600/sensor/serial_number/state |
| Last connected | ISO 8601 UTC | cgo600/sensor/last_connected/state |
| Light | on/off | cgo600/light/state |
The light can also be controlled from Home Assistant by publishing on or off to cgo600/light/set.
All entities are registered in Home Assistant automatically via MQTT discovery. The topic prefix (cgo600) is configurable.
The bridge only sees the bike when it reconnects (typically on departure or arrival), so it infers rides from the time and distance between two check-ins. Each inferred ride records its window, distance, duration, battery delta, and average/max speed. Rides are stored locally on the ESP32 and shown on the dashboard's Rides tab.
- ESP32 dev board β recommended: ESP32-WROOM-32U (external antenna connector for better BLE range). This is what the project runs on in practice; other ESP32 boards will work but WROOM-32U is the tested configuration.
- USB cable for initial flashing
Download the four .bin files from the latest release and flash with esptool:
esptool.py --chip esp32 --baud 460800 \
write_flash \
0x1000 cgo600-bootloader.bin \
0x8000 cgo600-partitions.bin \
0x10000 cgo600-firmware.bin \
0x3D0000 cgo600-littlefs.binInstall PlatformIO, then from the repo root:
pio run -d firmware -t upload
pio run -d firmware -t uploadfsAdd --upload-port <port> if auto-detection fails:
pio run -d firmware -t upload --upload-port <port>- After flashing, the ESP32 creates a WiFi access point called CGO600-Setup.
- Connect your phone or laptop to that network.
- A captive portal opens β select your WiFi and enter the password.
- The device connects to your network and is usually reachable at http://cgo600.local/ on networks that support mDNS. Otherwise, use the ESP32 IP address shown by your router.
After initial USB flashing, updates can be done over WiFi. Use cgo600.local when mDNS works, or the ESP32 IP address otherwise:
pio run -d firmware -t upload --upload-port cgo600.localIf you changed the web interface:
pio run -d firmware -t buildfs
pio run -d firmware -t uploadfs --upload-port cgo600.localThe CGO600 exposes a Nordic UART Service (NUS) over BLE. Commands are written to the RX characteristic; telemetry replies arrive as notifications on the TX characteristic. The protocol details below come from reverse engineering and may differ across bike/display firmware versions.
Read 0x52 [len] [field_ids...] [CRC16_lo] [CRC16_hi] 0x45
Write 0x57 [len] [payload...] [CRC16_lo] [CRC16_hi] 0x45
CRC16 is computed over all bytes from the start byte up to (but not including) the CRC bytes, using the same lookup table as the Python reference implementation.
| ID | Field | Decoding |
|---|---|---|
0x07 |
Serial number | ASCII string |
0x11 |
Speed | uint16 BE Γ· 10 β km/h |
0x12 |
Max speed | uint16 BE Γ· 10 β km/h |
0x13 |
Average speed | uint16 BE Γ· 10 β km/h |
0x14 |
Trip distance | uint16 BE Γ· 10 β km |
0x15 |
Trip duration | uint16 BE β seconds |
0x16 |
Remaining range | uint16 BE Γ· 10 β km |
0x17 |
Total range | uint16 BE Γ· 10 β km |
0x1A |
Battery | uint8 β % |
0x21 |
Light | uint8 (0 = off, 1 = on) |
firmware/
platformio.ini Build configuration
partitions.csv Custom partition layout
src/
main.cpp WiFi, mDNS, OTA, orchestration
config.h/.cpp Persistent configuration (NVS)
protocol.h/.cpp Frame building, parsing, CRC16
bike_state.h/.cpp Telemetry data model and field decoding
ble_client.h/.cpp BLE scan, connect, handshake, polling
mqtt_ha.h/.cpp MQTT publishing and Home Assistant discovery
ride_log.h/.cpp Ride inference and local history storage
web_server.h/.cpp HTTP API and web interface serving
logger.h/.cpp Logging helpers
reset_reason.h Boot/reset reason decoding
data/
index.html Dashboard shell (single-page app)
app.js Dashboard logic and rendering
i18n.js UI translations
style.css Shared styling
config.html Redirect shim (/config.html β /#settings)
manifest.json PWA manifest
sw.js Service worker (offline shell cache)
| Partition | Size | Purpose |
|---|---|---|
| app0 | 1.9 MB | Firmware (active) |
| app1 | 1.9 MB | Firmware (OTA staging) |
| spiffs | 192 KB | Web interface (LittleFS) |
| nvs | 20 KB | Configuration storage |
The partition label is spiffs for ESP32 compatibility, but PlatformIO builds a LittleFS image for it.


