Embedded firmware · ESP32-C3 · C++/Arduino · BLE

Zwift BLE Bridge

Dual-role BLE firmware for an ESP32-C3: it connects outward as a client to a physical heart-rate strap while simultaneously running a server that re-broadcasts unified Running Speed & Cadence and Heart Rate data to Zwift — with a crash-hardened, non-blocking scan cycle and a NeoPixel that reports link health at a glance.

MCU: ESP32-C3 Radio: dual-mode BLE (client + server) Status: 1× WS2812B NeoPixel Core: Arduino-ESP32 v3.2.0+ github.com/amd858/esp32c3-zwift-ble-bridge ↗
01 · What it does

Overview

Zwift expects a single BLE peripheral per data type. This firmware sits between an existing physical heart-rate strap and Zwift, holding two independent BLE roles open on one chip at once: a client connection that subscribes to the strap's standard Heart Rate service, and a server that advertises Running Speed & Cadence (RSC) plus a re-broadcast Heart Rate service for Zwift to pair with.

Speed data doesn't come from a wheel sensor — it arrives as plain text over a UART line (Serial1), scaled and clamped, then packed into the RSC characteristic every second. This makes the bridge a natural fit for treadmill or trainer setups where speed is already being computed by another controller and just needs to reach Zwift over BLE.

The scanning logic is deliberately conservative: rather than holding the radio in a continuous scan (which starves outbound notifications and has triggered stack crashes on newer ESP32 Arduino cores), it opens a 1-second scan window every 5 seconds and leaves the radio free the rest of the time for the outbound server link.

02 · Signal flow

Signal architecture

Two BLE roles and one UART input converge on a single 1-second data pipeline tick.

Signal flow diagram A physical heart rate strap connects to the ESP32-C3 as a BLE client. A UART line feeds simulated speed. The ESP32-C3 runs a BLE server that notifies Zwift with RSC and HR data, and drives a NeoPixel status LED plus a warning pin. Physical HR strap (BLE 0x180D) UART speed feed (Serial1) BLE client scan ESP32-C3 1s data pipeline tick Zwift app (BLE server link) RSC 0x1814 + HR 0x180D notify NeoPixel status LED Warning pin (HR > 114 BPM)

The BLE client (inbound) and BLE server (outbound) run concurrently on the same radio, arbitrated by a windowed scan schedule — see Connection states below.

03 · Hardware map

Pin reference

Minimal footprint by design — four functional pins beyond the ESP32-C3's own USB/power lines.

PinSymbolModeFunction
GPIO6RX_PINSerial1 RXHardware UART input — ingests speed telemetry as newline-terminated text
GPIO5TX_PINSerial1 TXHardware UART output — currently unused by the bridge logic, reserved
GPIO8LED_PINRMT / NeoPixelSingle WS2812B data line — drives the connection-status pixel
GPIO3WARNING_HEARTRATE_PINOUTPUTDriven HIGH while heart rate > 114 BPM, LOW otherwise
04 · GATT services

BLE profile

The server side exposes two standard Bluetooth SIG profiles so any client — Zwift included — recognizes them without custom pairing logic.

ServiceUUIDCharacteristicUUIDProperties
Running Speed & Cadence0x1814RSC Measurement0x2A53NOTIFY
Running Speed & Cadence0x1814Sensor Location0x2A5DREAD (static, value = 1)
Heart Rate0x180DHeart Rate Measurement0x2A37NOTIFY
Client Characteristic Config (both chars)CCCD0x2902

RSC Measurement buffer (10 bytes)

uint8_t rscBuffer[10] = {
  3,                        // flags: inst speed + stride present
  speed_lo, speed_hi,          // inst_speed, uint16 LE, 1/256 m/s units
  inst_cadence,                // uint8, steps/min
  stride_lo, stride_hi,        // inst_stride_length, uint16 LE, cm
  dist0, dist1, dist2, dist3   // total_distance, uint32 LE
};

Heart Rate Measurement buffer (2 bytes)

uint8_t hrmBuffer[2] = {
  0x00,          // flags: UINT8 BPM format
  heart_rate_out  // BPM value
};
05 · Link health

Connection states

Two independent booleans — connectedToSensor and deviceConnectedToZwift — combine into four operating states, rendered live on the NeoPixel every 250ms blink tick.

Blinking blue
strap search
Neither link is up. Hub is powered on and running its windowed scan, hunting for the strap's HR advertisement.
Blinking amber
pairing standby
Strap is bound (connectedToSensor = true) but Zwift hasn't connected to the server yet.
Solid purple
Zwift fallback
Zwift is connected but the strap isn't. heart_rate_in is forced to a fixed placeholder value rather than live data — see Notes.
Solid green
full system active
Both links are up. Live strap data is passing straight through to Zwift every second.
06 · Function reference

Function reference

Telemetry ingestion

handleUartIngestion()

Drains Serial1 byte-by-byte, buffering into a line until \n. Parses the line as a float, scales it by 0.125, and clamps it into simulatedKmph if it falls within 0–40 km/h.

Sensor connection & scanning

handleSensorBackgroundScanning()

If not connected, opens a non-blocking 1-second scan window every 5 seconds via BLEDevice::getScan()->start(1, false) — deliberately short to avoid starving the outbound server connection.

AdvertisedDeviceCallbacks::onResult(device)

Filters scan results to devices advertising the Heart Rate service (0x180D). In lock mode, rejects any MAC that doesn't case-insensitively match targetMacAddress; in auto mode, accepts the first match found.

connectToPhysicalStrap()

Creates a fresh BLEClient, connects, resolves the HR service and characteristic, and registers a notify callback with CCCD 0x2902 enabled. Explicitly deletes any stale client pointer first.

Never calls .stop() on the scanner — avoids a known crash vector on Arduino-ESP32 core 3.2.0

Data pipeline

runDataPipelineExecution()

Runs once per second. Converts simulatedKmph to RSC's 1/256 m/s fixed-point speed units, packages the RSC and HR buffers, and calls notify() on both characteristics if Zwift is connected.

processFitnessData(hr_in, hr_out, spd_out)

Pass-through mapping from raw inputs to outbound values. Also drives WARNING_HEARTRATE_PIN HIGH when heart rate exceeds 114 BPM.

Zwift server maintenance

handleZwiftConnectionStateTracking()

Watches deviceConnectedToZwift for a falling edge. On disconnect, waits, then explicitly calls pServer->startAdvertising() so the server is immediately re-discoverable.

Uses a blocking delay(500) before restarting advertising
07 · Tuning constants

Tuning constants

All configuration lives at the top of the sketch as plain #defines and globals — no runtime config UI.

Hardware pins

#define RX_PIN 6
#define TX_PIN 5
#define LED_PIN 8
#define WARNING_HEARTRATE_PIN 3

Sensor addressing

// Lock mode — rigid bind, prevents cross-talk
const String targetMacAddress = "ed:92:5c:9c:06:a2";

// Auto mode — take the first HR strap found
const String targetMacAddress = "";

Scan schedule

pBLEScan->setInterval(1349);
pBLEScan->setWindow(449);
// windowed scan: 1s scan every 5000ms
if (millis() - lastScanRestart >= 5000) { … }

Safety & scaling

// HR warning threshold
if (heart_rate_in > 114) warningPin = HIGH;

// UART speed scale factor
simulatedKmph = raw * 0.125;  // clamped 0–40 km/h

// data pipeline cadence
if (millis() - lastBleSendTime >= 1000) { … }
08 · Notes & known quirks

Notes & known quirks

Fallback heart rate is a fixed value, not a simulation: when the strap is unlinked, heart_rate_in is hard-set to 1 rather than a randomized 50–60 BPM pulse. The purple LED state still reports it as "fallback simulation."

See runDataPipelineExecution()

One blocking delay remains in the loop: handleZwiftConnectionStateTracking() calls delay(500) before restarting advertising, despite the rest of the firmware being fully non-blocking.

See handleZwiftConnectionStateTracking()

Explicit heap cleanup instead of RAII: pGlobalClient and myPhysicalSensor are manually deleted before every reassignment, so an accidental strap battery pull re-enters scan mode cleanly without a manual reset.

See connectToPhysicalStrap(), MageneClientCallbacks::onDisconnect()

TX_PIN is wired but idle: Serial1 is initialized full-duplex on pins 5/6, but the firmware only ever reads from it — outbound telemetry on TX_PIN is unused in this revision.

See setup()

No .stop() on the BLE scanner by design: a documented workaround for Arduino-ESP32 core 3.2.0, where explicitly stopping an active scan was a reliable crash vector. The windowed 1s/5s schedule lets scans self-terminate instead.

See handleSensorBackgroundScanning()