Heart-Rate sensor bridge for Waterrower


Water Rower

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

Apple Watch(HR sensor)HeartCast+(iPhone, BLE peripheral)HR-Bridge(nRF52840, BLE central)WaterRowermonitorwatch HR APIBLE Heart Rate Service0x180D / 0x2A37 (notify)3.5 mm TRS30 ms pulse per beat (ring)
  1. 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).
  2. HR-Bridge scans for any advertiser exposing that service, connects, and subscribes to measurement notifications.
  3. 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.
  4. 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.
  5. 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

IMG_3922


Firmware behavior

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

power usage image from ppk2

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:

  1. 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).
    
  2. (Optional) Watch serial output — first uncomment/define DEBUG in main.cpp, rebuild, then:
    pio device monitor
    

Usage

  1. 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.
  2. Power the HR-Bridge (USB-C or battery).
  3. Plug the 3.5 mm TRS connector into the WaterRower monitor’s heart-rate jack.
  4. Start rowing. The onboard LED should blink once per heartbeat, and the monitor should display your heart rate.

Troubleshooting


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.

Kommentare per Mail an post@wolfgang-jung.net.