Firmware for a Seeed Studio XIAO ESP32-C6 that lets a Google Pixel Watch send its heart rate to gym equipment the watch refuses to talk to directly — such as the Horizon 7.0 AT treadmill.
The Pixel Watch can broadcast heart rate over Bluetooth LE, but Google restricts it to a list of approved equipment brands (Peloton, Concept2, Echelon, Hydrow, Tonal, Wahoo, Zwift, Life Fitness, Matrix, Schwinn, NordicTrack). Horizon isn't on that list, so the two will never pair no matter what you do at either end.
The treadmill, meanwhile, is perfectly happy to talk to a generic BLE chest strap. So this sits in the middle and translates:
- Central role — connects to the watch's broadcast heart rate service (
0x180D). - Peripheral role — re-advertises that data as an ordinary, unencrypted chest strap the console can find and connect to.
Both roles run at once on the single radio.
Machine has no Bluetooth at all? This bridge impersonates a chest strap over BLE, so the equipment still needs a radio. If your console predates Bluetooth entirely and listens for an uncoded Polar strap instead, see hr-bridge-coil — it transmits the 5.4 kHz magnetic pulse those machines were built for.
hr_bridge_nimble/
hr_bridge_nimble.ino the firmware — flash this
tools/
ws2812_test/
ws2812_test.ino standalone LED smoke test, no Bluetooth
docs/
wiring.svg wiring diagram (PNG copy alongside it)
The Arduino IDE requires a sketch to live in a folder of the same name, which is
why hr_bridge_nimble.ino sits one level down. Open that file directly and the
IDE does the right thing.
| Item | Notes |
|---|---|
| Seeed Studio XIAO ESP32-C6 | Any ESP32 with BLE 5 dual-role would work; the C6 is chosen for size |
| Google Pixel Watch 2/3/4, Fitbit Charge 6, or Fitbit Ace | Anything that does standard BLE HR broadcast |
| A BLE-capable treadmill or exercise machine | Must accept a heart-rate strap |
| 1 × WS2812B RGB LED (optional) | Status indicator |
| 1 × 330 Ω resistor (optional) | In series with the LED data line |
| 3D-printed enclosure (optional) | Translucent PETG diffuses the LED nicely |
Product photos and the full pinout for the board are on Seeed's XIAO ESP32-C6 wiki page.
| WS2812B pin | Connect to |
|---|---|
| DIN | D1 (GPIO1) — a 330 Ω series resistor is cheap insurance |
| VCC | 3V3 — not 5V, see below |
| GND | GND |
Power the LED from 3.3 V, not 5 V. A WS2812B needs its data line above 0.7 × VDD to register a logic high. At 5 V that's 3.5 V, and the ESP32 can only output 3.3 V — it usually works, right up until it intermittently doesn't. At 3.3 V the threshold drops to 2.3 V and the margin problem disappears. One LED at low brightness is a few milliamps, well within the onboard regulator.
Watch the data direction. WS2812Bs have a DIN and a DOUT and only work one way. If you cut one from a reel, the arrows printed on the strip show which way data flows — your wire from the C6 goes to the end the arrows point away from. Wiring to DOUT measures perfectly and lights nothing.
Reserved pins: GPIO3 and GPIO14 drive the onboard RF switch — GPIO3 LOW
enables it, GPIO14 selects the onboard ceramic antenna (LOW) or an external
u.FL antenna (HIGH). Don't use them for anything else.
If the LED doesn't light, flash tools/ws2812_test/ws2812_test.ino first. It
does nothing but cycle colors, so it separates a wiring problem from a firmware
problem in about ten seconds. Set SWEEP_ALL_PINS to 1 in that sketch to walk
every broken-out pin in turn.
This uses the NimBLE-Arduino library by h2zero (v2.x), not the BLE wrapper
bundled with the ESP32 board package. The two cannot coexist in one sketch — do
not add BLEDevice.h.
- Arduino IDE → Tools → Manage Libraries…
- Install NimBLE-Arduino by h2zero (2.x).
- Set the board to Seeed XIAO ESP32-C6.
- Open
hr_bridge_nimble/hr_bridge_nimble.inoand upload.
The status LED needs no library — neopixelWrite() is built into arduino-esp32
3.x. If your core is older and it won't compile, install Adafruit NeoPixel.
(FastLED's ESP32-C6 support has historically been the shakier of the two.)
- Turn on heart rate broadcasting on the watch and leave it on. On a Pixel Watch: swipe down twice, tap the Connected Fitness icon, Connect.
- Power the bridge. Many treadmills switch their USB port off in standby, so waking the console will boot it. A separate USB charger also works and keeps the bridge always-on.
- Wait for a heart rate. Green or amber on the LED means the watch is feeding it. About 10 seconds.
- Put the console in strap mode. On the Horizon 7.0 AT, press and hold the Bluetooth button until the display reads "heart rate monitor only". The console connects to the bridge on its own — the bridge never initiates.
You have to do step 4 every time the console powers up. It boots into tablet/app mode by default, and in that mode it isn't scanning for a strap at all, so nothing the bridge does can reach it. Horizon's documentation confirms this: a power reset re-enables tablet communication.
The LED on the console's Bluetooth button means connected, not mode. It lights when the console has actually found a strap. You can't make it come on with better button technique — if it's dark, nothing was found.
Pairing a strap disables the console's tablet channel. That's by design, not a fault. Hold the button for five seconds or power cycle to get it back.
Color says what is connected; motion says whether it's settled. Blinking means still waiting on something, solid means both links are up — which stays readable in peripheral vision, through a tinted case, or if the amber/green pair is hard for you to distinguish.
| Indicator | Meaning | What to do |
|---|---|---|
| Red, solid | Nothing connected | Check the watch is broadcasting |
| Blue, blinking | Watch only | Hold the treadmill's Bluetooth button |
| Magenta, blinking | Treadmill only | Check the watch |
| Amber, solid | Both up, reading has gone stale | Watch dropped out — console may hang up soon |
| Green, solid | Everything working | Run |
Magenta rather than yellow for treadmill-only is deliberate: yellow and amber are hard to tell apart on a diffused 5050 at a glance, and they mean opposite things.
USB serial at 115200 baud. Logging is event-driven — it prints when a link state changes, plus one heartbeat line a minute when idle.
| Key | Action |
|---|---|
i |
Print status now |
v |
Toggle verbose logging (per-reading detail) |
s |
Force a rescan for the watch |
d |
Drop the current watch link |
a |
Stop and restart advertising |
p |
Pause/resume watch scanning |
r |
Reboot |
A normal startup looks like:
=== Pixel Watch -> treadmill HR bridge ===
Ready. Advertising as a heart rate strap.
Watch connected, waiting for first reading...
Heart rate flowing: 64 bpm
>>> TREADMILL CONNECTED
[ 92s] change watch=1 console=1 adv=0 scan=0 hr=64 age=0s free=353k
adv=0 while console=1 is normal — Bluetooth stops advertising once a
peripheral connection exists.
These are logged on every disconnect and are the fastest way to tell what happened:
| Code | Meaning |
|---|---|
0x213 |
The peer terminated the connection deliberately |
0x216 |
We terminated it (a deliberate drop by this firmware) |
0x208 |
Supervision timeout — the link was starved, not ended on purpose |
All tuning constants sit in one block at the top of the sketch.
| Constant | Default | Purpose |
|---|---|---|
BRIDGE_NAME |
"HRM-Bridge" |
Advertised name |
LED_PIN |
1 (D1) |
WS2812B data pin |
LED_BRIGHTNESS |
20 |
0–255. Low on purpose — this ends up at eye level |
NOTIFY_INTERVAL_MS |
1000 |
Consoles expect roughly 1 Hz |
HR_STALE_MS |
20000 |
Past this, the contact bit is cleared |
FIRST_HR_GRACE_MS |
180000 |
Time allowed for the first reading on a new link |
ZOMBIE_MS |
30000 |
A link that was streaming and went quiet |
USE_EXTERNAL_ANTENNA |
false |
Set true for a u.FL antenna |
The console can't find the bridge. Check adv= in the status line. If it
reads 1, the bridge is discoverable and the problem is the console's mode or
scan window — cycle out of heart-rate mode and back in to reopen it. If the
console has a remembered strap from an older firmware build, power-cycle the
treadmill to clear it.
Split the problem with nRF Connect. Install it on a phone, scan for
HRM-Bridge, connect, and subscribe to the heart rate characteristic. If the
phone can pull a heart rate off the bridge, the bridge is a working strap and
anything still wrong is on the treadmill's side. If it can't, it's the firmware.
One test, and the search space halves.
Some consoles filter by device name. If nothing appears in the console's
list, try setting BRIDGE_NAME to "Polar H10" — a name most equipment
recognizes out of the box.
The watch connects but sends nothing. Give it a moment — the watch accepts a
connection before it starts actually sending. In practice the first reading turns
up within about 10 seconds of connecting, or of you switching broadcasting on.
FIRST_HR_GRACE_MS is set to three minutes anyway; the margin costs nothing and
means a slow start is never mistaken for a dead link. Reconnecting while you wait
doesn't help — it makes the watch start over.
Everything drops together mid-run. Usually the watch went quiet first, the reading went stale, and the console then gave up. Amber on the LED is the warning that this is about to happen.
Things that are the way they are for a reason, so future-you doesn't "fix" them:
- No security or bonding. A real chest strap is completely open. Demanding pairing is what stops a gym console connecting at all.
- The device is matched by service UUID, never by address. The watch uses a resolvable private address that changes every session — an address remembered from last time is worthless.
- Never send 0 bpm. The console hangs up within seconds of the first zero.
When the reading goes stale the firmware holds the last value and clears the
sensor-contact flag (
0x04instead of0x06), which is the spec's way of saying the number is no longer trustworthy. Keeps the link alive without pretending stale data is current. - The CCCD is left to NimBLE. Adding a
0x2902descriptor by hand creates a duplicate and can leave a console unable to subscribe. - The advertising payload is built by hand rather than with
setName()/addServiceUUID(), because the hand-built version is what the console demonstrably accepts. - No BLE calls from inside BLE callbacks. Everything is deferred to
loop()via flags. - Timestamps are re-read after any blocking call.
connectToWatch()blocks for a second or more, so amillis()value captured before it is stale afterwards — and unsigned subtraction against a newer timestamp underflows into a huge number that trips every staleness check instantly. - The LED is only written when the color changes. The WS2812 protocol is bit-banged with interrupts briefly masked, which is not something to do fifty times a second next to a BLE stack.
- The watch keeps its broadcast session alive for roughly an hour after the bridge disappears, so between workouts on the same day it stays warm and you don't have to touch it again.
- A heart rate shows up about 10 seconds after the link comes up, whether that's the bridge booting or you switching broadcasting on at the watch.
- Range on the onboard ceramic antenna is comfortably across a room.
- The treadmill also broadcasts FTMS (
0x1826) — speed, incline, distance, pace. Currently unused. Whether it stays available while a strap is paired is untested.
MIT.
