Ryan Malloy e3642cbc4e Initial commit: Heltec Wireless Tracker documentation site
Astro/Starlight documentation covering hardware specs, pinout,
schematics, GNSS protocol, LoRa frequencies, and getting started
guides. Includes extracted datasheet images and Docker deployment.
2026-02-21 18:39:46 -07:00

7.8 KiB

Monitor Heart Rate on SnapEmu with Wireless Tracker

Overview

This project enables real-time heart rate monitoring using a wireless tracker connected to a heart rate sensor. Data transmits via LoRaWAN protocol to the SnapEmu platform for visualization and analysis.

Hardware Components Required

Component Purpose
Heltec Wireless Tracker Main controller + LoRa TX
HT-M7603 Indoor LoRa Gateway LoRaWAN network gateway
MAX30102 Heart Rate Sensor Pulse oximetry sensor
DuPont Wires Connections

Hardware Configuration

Pin Connections

The MAX30102 sensor connects to the Heltec Wireless Tracker using I2C:

MAX30102 Pin Wireless Tracker Pin Description
VCC 3V3 Power (3.3V)
GND GND Ground
SDA GPIO45 I2C Data
SCL GPIO46 I2C Clock

Note: GPIO45 and GPIO46 are recommended as they don't have conflicting built-in functions.

Software Setup

Step 1: Configure HT-M7603 Gateway

Configure your LoRa gateway connection to SnapEmu platform:

Step 2: Register Tracker Node

Register your wireless tracker device on SnapEmu:

Step 3: Install Libraries

Required Arduino libraries:

  • Wire.h (built-in)
  • MAX30102_PulseOximeter.h
  • LoRaWan_APP.h (Heltec library)

Step 4: Sensor Test Code

First, test the sensor standalone:

#include <Wire.h>
#include "MAX30102_PulseOximeter.h"

#define REPORTING_PERIOD_MS 1000
PulseOximeter pox;
uint32_t tsLastReport = 0;

void onBeatDetected() {
    Serial.println("Beat detected!");
}

void setup() {
    Serial.begin(115200);
    Wire.begin(45, 46);  // SDA=GPIO45, SCL=GPIO46
    Serial.print("Initializing MAX30102..");
    delay(3000);

    if (!pox.begin()) {
        Serial.println("FAILED");
        for(;;);
    } else {
        Serial.println("SUCCESS");
    }

    pox.setIRLedCurrent(MAX30102_LED_CURR_7_6MA);
    pox.setOnBeatDetectedCallback(onBeatDetected);
}

void loop() {
    pox.update();

    if (millis() - tsLastReport > REPORTING_PERIOD_MS) {
        float heartRate = pox.getHeartRate();
        float spo2 = pox.getSpO2();

        if (heartRate > 0) {
            Serial.print("Heart rate: ");
            Serial.print(heartRate);
            Serial.print(" bpm / ");
        } else {
            Serial.print("Heart rate: N/A / ");
        }

        if (spo2 > 0) {
            Serial.print("SpO2: ");
            Serial.print(spo2);
            Serial.println(" %");
        } else {
            Serial.println("SpO2: N/A");
        }

        tsLastReport = millis();
    }
}

Step 5: Full LoRaWAN Code

Complete code with LoRaWAN transmission:

#include <Wire.h>
#include "MAX30102_PulseOximeter.h"
#include "LoRaWan_APP.h"

#define REPORTING_PERIOD_MS 1000

// OTAA Parameters - Replace with your values from SnapEmu
uint8_t devEui[] = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xA8 };
uint8_t appEui[] = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
uint8_t appKey[] = { 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88,
                     0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88 };

// LoRaWAN Configuration
uint16_t userChannelsMask[6] = { 0x00FF, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000 };
LoRaMacRegion_t loraWanRegion = ACTIVE_REGION;
DeviceClass_t loraWanClass = CLASS_A;
uint32_t appTxDutyCycle = 15000;  // 15 second transmit interval
bool overTheAirActivation = true;
bool loraWanAdr = true;
bool isTxConfirmed = true;
uint8_t appPort = 2;
uint8_t confirmedNbTrials = 4;

PulseOximeter pox;
uint32_t tsLastReport = 0;

static void prepareTxFrame(uint8_t port) {
    pox.update();
    float heartRate = pox.getHeartRate();
    float spo2 = pox.getSpO2();
    unsigned char *puc;

    appDataSize = 0;

    // Packet header
    appData[appDataSize++] = 0x04;
    appData[appDataSize++] = 0x00;
    appData[appDataSize++] = 0x0A;
    appData[appDataSize++] = 0x02;

    // Heart rate (4 bytes float)
    puc = (unsigned char *)(&heartRate);
    appData[appDataSize++] = puc[0];
    appData[appDataSize++] = puc[1];
    appData[appDataSize++] = puc[2];
    appData[appDataSize++] = puc[3];

    // Separator
    appData[appDataSize++] = 0x12;

    // SpO2 (4 bytes float)
    puc = (unsigned char *)(&spo2);
    appData[appDataSize++] = puc[0];
    appData[appDataSize++] = puc[1];
    appData[appDataSize++] = puc[2];
    appData[appDataSize++] = puc[3];

    Serial.print("TX: HR=");
    Serial.print(heartRate);
    Serial.print(" SpO2=");
    Serial.println(spo2);
}

void onBeatDetected() {
    Serial.println("♥ Beat!");
}

void setup() {
    Serial.begin(115200);
    Wire.begin(45, 46);
    Mcu.begin(HELTEC_BOARD, SLOW_CLK_TPYE);

    Serial.print("Initializing MAX30102..");
    delay(3000);

    if (!pox.begin()) {
        Serial.println("FAILED");
        for(;;);
    }
    Serial.println("SUCCESS");

    pox.setIRLedCurrent(MAX30102_LED_CURR_27_1MA);
    pox.setOnBeatDetectedCallback(onBeatDetected);

    LoRaWAN.init(loraWanClass, loraWanRegion);
    LoRaWAN.setDefaultDR(3);
}

void loop() {
    pox.update();

    // Local display
    if (millis() - tsLastReport > REPORTING_PERIOD_MS) {
        float heartRate = pox.getHeartRate();
        float spo2 = pox.getSpO2();

        Serial.print("HR: ");
        Serial.print(heartRate > 0 ? String(heartRate) : "N/A");
        Serial.print(" bpm | SpO2: ");
        Serial.print(spo2 > 0 ? String(spo2) : "N/A");
        Serial.println("%");

        tsLastReport = millis();
    }

    // LoRaWAN state machine
    switch (deviceState) {
        case DEVICE_STATE_INIT:
        case DEVICE_STATE_JOIN:
            LoRaWAN.join();
            break;
        case DEVICE_STATE_SEND:
            prepareTxFrame(appPort);
            LoRaWAN.send();
            deviceState = DEVICE_STATE_CYCLE;
            break;
        case DEVICE_STATE_CYCLE:
            txDutyCycleTime = appTxDutyCycle + randr(-APP_TX_DUTYCYCLE_RND, APP_TX_DUTYCYCLE_RND);
            LoRaWAN.cycle(txDutyCycleTime);
            deviceState = DEVICE_STATE_SLEEP;
            break;
        case DEVICE_STATE_SLEEP:
            LoRaWAN.sleep(loraWanClass);
            break;
        default:
            deviceState = DEVICE_STATE_INIT;
            break;
    }
}

Data Decoding on SnapEmu

Custom decoder function for the payload format:

function Decoder(bytes, port) {
    var decoded = {};

    if (bytes.length >= 13) {
        // Extract heart rate (bytes 4-7)
        var hrBytes = new Uint8Array([bytes[4], bytes[5], bytes[6], bytes[7]]);
        var hrView = new DataView(hrBytes.buffer);
        decoded.heartRate = hrView.getFloat32(0, true);

        // Extract SpO2 (bytes 9-12)
        var spo2Bytes = new Uint8Array([bytes[9], bytes[10], bytes[11], bytes[12]]);
        var spo2View = new DataView(spo2Bytes.buffer);
        decoded.spo2 = spo2View.getFloat32(0, true);
    }

    return decoded;
}

Platform Visualization

SnapEmu displays:

  • Real-time heart rate graph
  • SpO2 level monitoring
  • Historical data (up to 1 month)
  • Mobile app + web interface

Troubleshooting

Issue Solution
No heartbeat detected Ensure finger is properly placed, adjust LED current
Erratic readings Clean sensor, reduce movement, check power supply
LoRa join failed Verify gateway connection, check credentials
Data not showing on platform Check decoder function, verify port number

References

License: GPL3+