Heart-Rate sensor bridge for Waterrower
HR-Bridge
A tiny nRF52840 bridge that turns your Apple Watch heart rate into the wired pulse signal an old WaterRower monitor expects.
The nRF52840 bridge acts as a Bluetooth LE central: it connects to the standard Heart Rate Service exposed by the HeartCast+ iPhone app, and re-emits every heartbeat as a short electrical pulse on the ring conductor of a 3.5 mm TRS plug wired into the rower’s heart-rate jack.
How it works
- HeartCast+ on the iPhone receives live heart rate from the Apple Watch and re-broadcasts it as a standard BLE Heart Rate Service (
0x180D) with a Heart Rate Measurement characteristic (0x2A37). - HR-Bridge scans for any advertiser exposing that service, connects, and subscribes to measurement notifications.
- For each notification it derives the beat-to-beat interval (preferring the RR interval when present, falling back to
60000 / BPM) and schedules a pulse. - Each beat produces a 30 ms active-high pulse on the output pin, wired to the middle (ring) pin of a 3.5 mm TRS connector. The onboard LED mirrors the pulse for a visible heartbeat.
- The WaterRower monitor reads those pulses exactly as it would from its original wired heart-rate receiver.
Why this exists: My WaterRower monitor accept heart rate from a wired receiver over a 3.5 mm jack that expects one pulse per beat. An Apple Watch can’t talk to that jack directly. HeartCast+ exposes the watch’s heart rate as a normal BLE Heart Rate peripheral, and this bridge translates it into the pulse the rower understands.
Hardware
| Part | Notes |
|---|---|
| nRF52840 board | Pro Micro / nice!nano-compatible form factor (the red board in the photos). Any board supported by the Adafruit nRF52 Arduino core with the UF2 bootloader works. |
| 3.5 mm TRS pigtail/plug | Plugs into the rower’s heart-rate jack. |
| USB-C cable | For flashing. |
Wiring
| Signal | Function | Goes to |
|---|---|---|
| Pulse output | OUT_PIN (2) - I/O-010 |
Ring (middle) of the 3.5 mm TRS plug |
| Ground | BAT- |
Sleeve of the TRS plug |
| Power Supply | BAT+ |
Tip of the TRS plug |
Firmware behavior
- BLE central, scan filtered to the Heart Rate service UUID so it only reports HR advertisers; auto-rescans on disconnect.
- HR Measurement parsing per the Bluetooth spec: reads the flags byte, handles both 8- and 16-bit BPM formats, skips the energy-expended field when present, and extracts RR interval(s).
- RR-first timing: RR intervals (in 1/1024 s units, converted to ms) drive beat spacing when available; otherwise
60000 / BPMis used. - 30 ms pulse per beat on
OUT_PIN, mirrored on the LED. - Stale-signal safety: if no data arrives for
STALE_TIMEOUT(5 s) — or on disconnect — output stops, so the monitor doesn’t see a frozen “heartbeat.” - Low power: DC-DC regulator enabled (
sd_power_dcdc_mode_set), TX power set to-20 dBm(the phone is right there), and a ~150 ms connection interval requested to keep radio traffic down.
Power usage
The board consumes on average 750 uA during the heart-rate measurement, which allows the board to be powered by the WaterRower monitor (which exposes 3.3V on the tip of the TRS connector).
Tunable parameters (main.cpp)
| Constant | Default | Purpose |
|---|---|---|
OUT_PIN |
2 |
Pulse output pin (→ TRS ring) |
LED_PIN |
24 |
Heartbeat indicator LED |
PULSE_MS |
30 |
Pulse width in milliseconds |
STALE_TIMEOUT |
5000 |
Stop pulsing after this many ms without data |
DEBUG |
undefined | Define to enable USB serial logging (bpm, rr_ms) |
Build & flash (PlatformIO)
The project targets the nRF52840 via the Nordic platform and the Adafruit nRF52 Arduino framework. Bluefruit (bluefruit.h) ships with that framework, so lib_deps is empty.
[env:nice_nano]
platform = https://github.com/maxgerhardt/platform-nordicnrf52
board = adafruit_feather_nrf52840
framework = arduino
monitor_speed = 115200
upload_protocol = nrfutil ; serial DFU via the UF2 bootloader
Flash it:
- Build and upload:
pio run -t upload # if the upload fails, put the board into bootloader mode by double-tapping reset (shorting RST to GND). - (Optional) Watch serial output — first uncomment/define
DEBUGinmain.cpp, rebuild, then:pio device monitor
Usage
- Install HeartCast+ on the iPhone and make sure it’s receiving heart rate from the Apple Watch and broadcasting as a BLE Heart Rate device.
- Power the HR-Bridge (USB-C or battery).
- Plug the 3.5 mm TRS connector into the WaterRower monitor’s heart-rate jack.
- Start rowing. The onboard LED should blink once per heartbeat, and the monitor should display your heart rate.
Troubleshooting
- No heartbeat / LED never blinks — Confirm HeartCast+ is actively advertising the Heart Rate service and the watch is streaming. The bridge only connects to advertisers exposing
0x180D. - Connects to the wrong device — The bridge connects to the first Heart Rate advertiser it finds. If a chest strap or another HR broadcaster is nearby and also advertising, power it off or move it out of range. (Add address/name filtering in
scan_callbackif you need to pin it to one device.) - Monitor shows no HR but LED blinks — Check the TRS wiring: signal must be on the ring, ground on the sleeve. Verify with the plug fully seated.
- Erratic / doubled readings — Some monitors are sensitive to pulse width; try adjusting
PULSE_MS. - Reading “freezes” after signal loss — Expected to stop, not freeze: after
STALE_TIMEOUTthe output goes idle. If it isn’t clearing, confirmlastDataMsis updating (enableDEBUG).
Source-code
The whole project is built with platformio:
├── platformio.ini # Build/upload configuration
├── src/
│ └── main.cpp # Firmware
└── README.md
The platformio.ini:
[env:nice_nano]
platform = https://github.com/maxgerhardt/platform-nordicnrf52
board = adafruit_feather_nrf52840
framework = arduino
monitor_speed = 115200
upload_protocol = nrfutil
lib_deps = # no libs required
The src/main.cpp code:
#include <Arduino.h>
#include <bluefruit.h>
// ---- Config ----
#define OUT_PIN 2
#define LED_PIN 24
#undef DEBUG
static const uint32_t PULSE_MS = 30;
static const uint32_t STALE_TIMEOUT = 5000;
// ---- BLE central: Heart Rate service + measurement characteristic ----
BLEClientService hrms(UUID16_SVC_HEART_RATE); // 0x180D
BLEClientCharacteristic hrmc(UUID16_CHR_HEART_RATE_MEASUREMENT); // 0x2A37
static volatile uint32_t beatInterval = 0; // ms to next beat; 0 = no signal
static volatile uint32_t lastDataMs = 0;
void serialDebug(const char* message)
{
#ifdef DEBUG
Serial.println(message);
#endif
}
// HR handling
void onBeat(uint16_t bpm, uint16_t rr_ms)
{
#ifdef DEBUG
Serial.printf("%d, %d\n", bpm, rr_ms);
#endif
if (rr_ms > 0) {
beatInterval = rr_ms; // use RR directly
} else if (bpm > 0) {
beatInterval = 60000UL / bpm; // fallback when no RR
} else {
return;
}
lastDataMs = millis();
}
// Bluefruit notify callback: note the (chr, data, uint16_t len) signature
void hrm_notify_callback(BLEClientCharacteristic* chr, uint8_t* data, uint16_t len)
{
// see https://mariam.qa/post/hr-ble/
if (len < 2) {
return;
}
uint8_t flags = data[0];
size_t i = 1;
uint16_t bpm = (flags & 0x01) ? (data[i] | (data[i + 1] << 8)) : data[i];
i += (flags & 0x01) ? 2 : 1;
if (flags & 0x08) {
i += 2; // skip energy-expended
}
uint16_t rr_ms = 0;
if (flags & 0x10) { // RR interval(s) present
while (i + 1 < len) { // keep the most recent
uint16_t rr = data[i] | (data[i + 1] << 8);
i += 2;
rr_ms = ((uint32_t)rr * 1000) / 1024; // 1/1024 s -> ms
}
}
onBeat(bpm, rr_ms);
}
void servicePulse()
{
static uint32_t nextBeat = 0, pulseOff = 0;
static bool activePulse = false;
uint32_t now = millis();
if (beatInterval == 0 || now - lastDataMs > STALE_TIMEOUT) {
if (activePulse) {
digitalWrite(LED_PIN, LOW);
digitalWrite(OUT_PIN, LOW);
activePulse = false;
}
return;
}
if (activePulse && now >= pulseOff) {
digitalWrite(LED_PIN, LOW);
digitalWrite(OUT_PIN, LOW);
activePulse = false;
}
if (!activePulse && (int32_t)(now - nextBeat) >= 0) {
digitalWrite(LED_PIN, HIGH);
digitalWrite(OUT_PIN, HIGH);
activePulse = true;
pulseOff = now + PULSE_MS;
nextBeat = now + beatInterval;
}
}
// ---- BLE callbacks ----
void scan_callback(ble_gap_evt_adv_report_t* report)
{
Bluefruit.Central.connect(report); // scanner pauses during connect
}
void connect_callback(uint16_t conn_handle)
{
if (!hrms.discover(conn_handle)) { // find 0x180D
serialDebug("HR service not found");
Bluefruit.disconnect(conn_handle);
return;
}
if (!hrmc.discover()) { // find 0x2A37 within it
serialDebug("HR measurement characteristic not found");
Bluefruit.disconnect(conn_handle);
return;
}
if (hrmc.enableNotify()) {
serialDebug("Subscribed to HR notifications");
Bluefruit.Connection(conn_handle)->requestConnectionParameter(120);
} else {
serialDebug("Couldn't enable notify");
}
}
void disconnect_callback(uint16_t conn_handle, uint8_t reason)
{
(void)conn_handle;
(void)reason;
serialDebug("Disconnected, rescanning...");
beatInterval = 0; // stop pulsing on dropout
}
void setup()
{
#ifdef DEBUG
Serial.begin(115200);
#endif
sd_power_dcdc_mode_set(NRF_POWER_DCDC_ENABLE);
pinMode(LED_PIN, OUTPUT);
pinMode(OUT_PIN, OUTPUT);
digitalWrite(LED_PIN, LOW);
digitalWrite(OUT_PIN, LOW);
Bluefruit.begin(0, 1);
Bluefruit.setTxPower(-20);
Bluefruit.setName("HR-Bridge");
hrms.begin();
hrmc.setNotifyCallback(hrm_notify_callback);
hrmc.begin();
Bluefruit.Central.setConnectCallback(connect_callback);
Bluefruit.Central.setDisconnectCallback(disconnect_callback);
Bluefruit.Scanner.setRxCallback(scan_callback);
Bluefruit.Scanner.restartOnDisconnect(true);
Bluefruit.Scanner.filterUuid(hrms.uuid); // only report HR advertisers
Bluefruit.Scanner.setInterval(1600, 160);
Bluefruit.Scanner.start(0); // 0 = scan forever
}
void loop()
{
servicePulse();
delay(10);
}
License
MIT
Not affiliated with WaterRower, Apple, or HeartCast+. “HeartCast+” and the Apple Watch are used only as the heart-rate source; this project just translates a standard BLE Heart Rate stream into the pulse a WaterRower monitor expects.