Heart-Rate sensor bridge for Waterrower


Bild zu Post '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

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
Bild zu Post 'Heart-Rate sensor bridge for Waterrower'

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

Bild zu Post 'Heart-Rate sensor bridge for Waterrower'

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.