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.
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.
Signal architecture
Two BLE roles and one UART input converge on a single 1-second data pipeline tick.
The BLE client (inbound) and BLE server (outbound) run concurrently on the same radio, arbitrated by a windowed scan schedule — see Connection states below.
Pin reference
Minimal footprint by design — four functional pins beyond the ESP32-C3's own USB/power lines.
| Pin | Symbol | Mode | Function |
|---|---|---|---|
| GPIO6 | RX_PIN | Serial1 RX | Hardware UART input — ingests speed telemetry as newline-terminated text |
| GPIO5 | TX_PIN | Serial1 TX | Hardware UART output — currently unused by the bridge logic, reserved |
| GPIO8 | LED_PIN | RMT / NeoPixel | Single WS2812B data line — drives the connection-status pixel |
| GPIO3 | WARNING_HEARTRATE_PIN | OUTPUT | Driven HIGH while heart rate > 114 BPM, LOW otherwise |
BLE profile
The server side exposes two standard Bluetooth SIG profiles so any client — Zwift included — recognizes them without custom pairing logic.
| Service | UUID | Characteristic | UUID | Properties |
|---|---|---|---|---|
| Running Speed & Cadence | 0x1814 | RSC Measurement | 0x2A53 | NOTIFY |
| Running Speed & Cadence | 0x1814 | Sensor Location | 0x2A5D | READ (static, value = 1) |
| Heart Rate | 0x180D | Heart Rate Measurement | 0x2A37 | NOTIFY |
| Client Characteristic Config (both chars) | CCCD | 0x2902 | ||
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 };
Connection states
Two independent booleans — connectedToSensor and deviceConnectedToZwift — combine into four operating states, rendered live on the NeoPixel every 250ms blink tick.
strap search
pairing standby
connectedToSensor = true) but Zwift hasn't connected to the server yet.Zwift fallback
heart_rate_in is forced to a fixed placeholder value rather than live data — see Notes.full system active
Function reference
Telemetry ingestion
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
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.
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.
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.
.stop() on the scanner — avoids a known crash vector on Arduino-ESP32 core 3.2.0Data pipeline
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.
Pass-through mapping from raw inputs to outbound values. Also drives WARNING_HEARTRATE_PIN HIGH when heart rate exceeds 114 BPM.
Zwift server maintenance
Watches deviceConnectedToZwift for a falling edge. On disconnect, waits, then explicitly calls pServer->startAdvertising() so the server is immediately re-discoverable.
delay(500) before restarting advertisingTuning 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) { … }
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."
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.
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.
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.
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.
handleSensorBackgroundScanning()