Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

HR Bridge

Buy Me a Coffee

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 finished bridge mounted on the treadmill console, status LED lit green

Why this is needed

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.

Repository layout

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.

Hardware

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.

Status LED wiring

Wiring diagram: XIAO ESP32-C6 to WS2812B status LED

WS2812B pin Connect to
DIN D1 (GPIO1) — a 330 Ω series resistor is cheap insurance
VCC 3V3not 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.

Software setup

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.

  1. Arduino IDE → Tools → Manage Libraries…
  2. Install NimBLE-Arduino by h2zero (2.x).
  3. Set the board to Seeed XIAO ESP32-C6.
  4. Open hr_bridge_nimble/hr_bridge_nimble.ino and 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.)

Daily use

  1. 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.
  2. 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.
  3. Wait for a heart rate. Green or amber on the LED means the watch is feeding it. About 10 seconds.
  4. 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.

Status LED

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.

Serial console

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.

Disconnect reason codes

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

Configuration

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

Troubleshooting

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.

Design notes

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 (0x04 instead of 0x06), 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 0x2902 descriptor 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 a millis() 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.

Useful behavior worth knowing

  • 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.

License

MIT.

About

BLE bridge that lets a Google Pixel Watch send heart rate to gym equipment it won't pair with directly, like the Horizon 7.0 AT treadmill. Seeed XIAO ESP32-C6.

Topics

Resources

Stars

4 stars

Watchers

1 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages