Heart Rate ESP32 and MAX30102 With IPS 0.96 LCD Development Kit TTGO

AED 208.95

Low stock


The TTGO Heart rate programming development kit is a reference design that comes with an enclosure similar to commercial products. It features the ESP32 dual-core Tensilica SoC with WiFi 4 and Bluetooth connectivity, a small 0.96-inch IPS LCD via ST7735 SPI controller, a Maxim Integrated MAX30102 pulse oximeter and heart-rate sensor, and an MPU6050 6-axis accelerometer and gyroscope. It also includes a PCF8563 RTC, a user button, and a 200mAh battery that charges through the micro USB port.


Package Includes:

  • 1 x Heart Rate ESP32 and MAX30102 With IPS 0.96 LCD Development Kit TTGO
  • 1 x Small USB Cable



  • The MAX30102 pulse oximeter and heart rate sensor can measure heart rate and oxygen saturation levels through the fingertip or earlobe.
  • The MPU6050 6-axis accelerometer and gyroscope can measure motion and orientation, making this kit useful for fitness tracking and other motion-related applications.
  • The PCF8563 RTC (real-time clock) enables the kit to keep accurate time even when not connected to the internet.
  • The kit supports both AP (access point) and client modes for wireless connectivity, allowing it to connect to a home or office network or act as its own network for local data collection.
  • The 200mAh battery provides portable power for the kit, allowing it to be used in situations where a power outlet is not available.
  • The compact design and enclosure make it easy to use and transport and the included LCD screen allows for easy visualization of heart rate and oxygen saturation data.
  • The kit is compatible with both the Arduino IDE and the PlatformIO IDE, making it accessible to developers with varying levels of experience.



The TTGO Heart rate programming development kit is a compact and convenient reference design that includes an enclosure similar to commercial products. It features an ESP32 dual-core Tensilica SoC with WiFi 4 and Bluetooth connectivity, a small 0.96-inch IPS LCD via ST7735 SPI controller, a Maxim Integrated MAX30102 pulse oximeter and heart-rate sensor, and an MPU6050 6-axis accelerometer and gyroscope. It also includes a PCF8563 RTC, a user button, and a 200mAh battery that charges through the micro USB port. This kit provides an easy-to-use solution for heart rate and pulse oximetry monitoring with wireless connectivity and a compact design. The development kit also includes a "HeartRateMonitor" sketch that starts a web server accessible in either AP or client mode. The sketch reads data from the sensors and the RTC and displays the results on the LCD or in a web browser. The webpage displays two values and charts - one for heart rate and the other for blood oxygen level (SpO2), or oxygen saturation.


Principle of Work:

The TTGO Heart rate programming development kit works by utilizing the Maxim Integrated MAX30102 pulse oximeter and heart rate sensor, as well as the MPU6050 6-axis accelerometer and gyroscope, to measure the user's heart rate, blood oxygen level (SpO2), and motion. These measurements are then displayed on the 0.96-inch IPS LCD via an ST7735 SPI controller. The ESP32 dual-core Tensilica SoC provides WiFi 4 and Bluetooth connectivity, which allows the data to be transmitted wirelessly to other devices. The PCF8563 RTC ensures accurate timekeeping, and the user button provides a way to interact with the device. The 200mAh battery powers the device and charges via a micro USB port. The device's software is programmed using the Arduino IDE and PlatformIO IDE,


Pinout of the Module:




  1. Fitness tracking: With the heart rate sensor, this development kit can be used to track a user's heart rate during workouts, allowing for more accurate fitness monitoring.
  2. Wearable devices: The compact size and built-in display make it ideal for use in wearable devices such as smartwatches and fitness bands.
  3. Health monitoring: The heart rate sensor and Bluetooth connectivity can be used to monitor a user's health and track any abnormalities, making it useful in medical applications.
  4. Home automation: The ESP32 WiFi connectivity can be used to control and monitor smart home devices such as lights, thermostats, and security cameras.
  5. Internet of Things (IoT): The ESP32 WiFi and Bluetooth connectivity make it useful for IoT applications, allowing for wireless data transmission and communication with other devices.



No real Circuit is needed this module can work directly by connecting it to a smartphone.




  1. Install the correct serial port driver CP210X Driver
  2. From the Link Change src/main.cpp to src.ino
  3. Copy the files in the lib directory to ~/Arduino/libraries, Windows users copy to Documents/Arduino/libraries
  4. Double-click to open src/src.ino
  5. Change the port to the correct port and select upload



Factory Test for LilyGo-HeartRate-Kit

#include "ArduinoOTA.h"
#include "Wire.h"
#include "WiFi.h"
#include "ESPmDNS.h"
#include "WiFiUdp.h"
#include "TFT_eSPI.h"
#include "OneButton.h"
#include "pcf8563.h"

#include "MAX30105.h"
#include "MPU6050.h"
#include "heartRate.h"
#include "esp_adc_cal.h"
#include "image.h"

// Has been defined in the TFT_eSPI library
// #define TFT_RST             26
// #define TFT_MISO            -1
// #define TFT_MOSI            19
// #define TFT_SCLK            18
// #define TFT_CS              5
// #define TFT_DC              23
// #define TFT_BL              27

#define  I2C_SDA_PIN             21
#define  I2C_SCL_PIN             22
#define  RTC_INT_PIN             34
#define  BATT_ADC_PIN            35
#define  VBUS_PIN                37
#define  LED_PIN                 33
#define  CHARGE_PIN              32
#define  BUTTON_PIN              38
#define  MPU_INT                 39
#define  HEATRATE_SDA            15
#define  HEATRATE_SCL            13
#define  HEATRATE_INT            4

#define  ARDUINO_OTA_UPDATE      //! Enable this line use OTA update

#define  WIFI_SSID               "Xiaomi"
#define  WIFI_PASSWD             "12345678"

TFT_eSPI    tft = TFT_eSPI();  // Invoke library, pins defined in User_Setup.h
PCF8563_Class rtc;
MPU6050     mpu;
OneButton   button(BUTTON_PIN, true);
MAX30105    particleSensor;

bool        freefallDetected = false;
int         freefallBlinkCount = 0;
char        buff[256];
bool        rtcIrq = false;
bool        initial = 1;
uint8_t     func_select = 0;
uint8_t     omm = 99;
uint8_t     xcolon = 0;
uint32_t    targetTime = 0;       // for next 1 second timeout
uint32_t    colour = 0;
int         vref = 1100;
bool        charge_indication = false;
uint8_t     hh, mm, ss ;

const uint8_t RATE_SIZE = 4; //Increase this for more averaging. 4 is good.
uint8_t     rates[RATE_SIZE]; //Array of heart rates
uint8_t     rateSpot = 0;
long        lastBeat = 0; //Time at which the last beat occurred
float       beatsPerMinute;
int         beatAvg;
bool        find_max30105 = false;
bool        showError = false;

void drawProgressBar(uint16_t x0, uint16_t y0, uint16_t w, uint16_t h, uint8_t percentage, uint16_t frameColor, uint16_t barColor);
void enterDeepsleep(void);

bool setupMAX30105(void)
    // Initialize sensor
    if (!particleSensor.begin(Wire1, 400000)) { //Use default I2C port, 400kHz speed
        Serial.println("MAX30105 was not found");
        return false;
    particleSensor.setup(); //Configure sensor with default settings
    particleSensor.setPulseAmplitudeRed(0x0A); //Turn Red LED to low to indicate sensor is running
    particleSensor.setPulseAmplitudeGreen(0); //Turn off Green LED
    find_max30105 = true;
    return true;

void loopMAX30105(void)
    if (!find_max30105 && !showError) {
        tft.drawString("No detected sensor", 20, 30);
        showError = true;

    if (showError) {

    long irValue = particleSensor.getIR();

    if (checkForBeat(irValue) == true) {
        //We sensed a beat!
        long delta = millis() - lastBeat;
        lastBeat = millis();

        beatsPerMinute = 60 / (delta / 1000.0);

        if (beatsPerMinute < 255 && beatsPerMinute > 20) {
            rates[rateSpot++] = (uint8_t)beatsPerMinute; //Store this reading in the array
            rateSpot %= RATE_SIZE; //Wrap variable

            //Take average of readings
            beatAvg = 0;
            for (uint8_t x = 0 ; x < RATE_SIZE ; x++)
                beatAvg += rates[x];
            beatAvg /= RATE_SIZE;

    if (targetTime < millis()) {
        snprintf(buff, sizeof(buff), "IR=%lu BPM=%.2f", irValue, beatsPerMinute);
        tft.drawString(buff, 0, 0);
        snprintf(buff, sizeof(buff), "Avg BPM=%d", beatAvg);
        tft.drawString(buff, 0, 16);
        targetTime += 1000;
    if (irValue < 50000 ) {
        digitalWrite(LED_PIN, LOW);
    } else {
        digitalWrite(LED_PIN, !digitalRead(LED_PIN));

void checkSettings(void)

    Serial.print(" * Sleep Mode:            ");
    Serial.println(mpu.getSleepEnabled() ? "Enabled" : "Disabled");

    Serial.print(" * Clock Source:          ");
    switch (mpu.getClockSource()) {
    case MPU6050_CLOCK_KEEP_RESET:     Serial.println("Stops the clock and keeps the timing generator in reset"); break;
    case MPU6050_CLOCK_EXTERNAL_19MHZ: Serial.println("PLL with external 19.2MHz reference"); break;
    case MPU6050_CLOCK_EXTERNAL_32KHZ: Serial.println("PLL with external 32.768kHz reference"); break;
    case MPU6050_CLOCK_PLL_ZGYRO:      Serial.println("PLL with Z axis gyroscope reference"); break;
    case MPU6050_CLOCK_PLL_YGYRO:      Serial.println("PLL with Y axis gyroscope reference"); break;
    case MPU6050_CLOCK_PLL_XGYRO:      Serial.println("PLL with X axis gyroscope reference"); break;
    case MPU6050_CLOCK_INTERNAL_8MHZ:  Serial.println("Internal 8MHz oscillator"); break;

    Serial.print(" * Accelerometer:         ");
    switch (mpu.getRange()) {
    case MPU6050_RANGE_16G:            Serial.println("+/- 16 g"); break;
    case MPU6050_RANGE_8G:             Serial.println("+/- 8 g"); break;
    case MPU6050_RANGE_4G:             Serial.println("+/- 4 g"); break;
    case MPU6050_RANGE_2G:             Serial.println("+/- 2 g"); break;

    Serial.print(" * Accelerometer offsets: ");
    Serial.print(" / ");
    Serial.print(" / ");


void doInt(void)
    freefallBlinkCount = 0;
    freefallDetected = true;

bool setupMPU6050(void)
    if (!mpu.begin(MPU6050_SCALE_2000DPS, MPU6050_RANGE_16G)) {
        Serial.println("setupMPU FAIL!!!");
        return false;

    // If you want, you can set accelerometer offsets
    // mpu.setAccelOffsetX();
    // mpu.setAccelOffsetY();
    // mpu.setAccelOffsetZ();

    // Calibrate gyroscope. The calibration must be at rest.
    // If you don't want calibrate, comment this line.

    // Set threshold sensivty. Default 3.
    // If you don't want use threshold, comment this line or set 0.





    pinMode(MPU_INT, INPUT);
    attachInterrupt(MPU_INT, doInt, CHANGE);

    return true;

void loopIMU(void)
    tft.setTextColor(TFT_GREEN, TFT_BLACK);
    Vector normAccel = mpu.readNormalizeAccel();
    Vector normGyro = mpu.readNormalizeGyro();
    snprintf(buff, sizeof(buff), "--  ACC  GYR");
    tft.drawString(buff, 0, 0);
    snprintf(buff, sizeof(buff), "x %.2f  %.2f", normAccel.XAxis, normGyro.XAxis);
    tft.drawString(buff, 0, 16);
    snprintf(buff, sizeof(buff), "y %.2f  %.2f", normAccel.YAxis, normGyro.YAxis);
    tft.drawString(buff, 0, 32);
    snprintf(buff, sizeof(buff), "z %.2f  %.2f", normAccel.ZAxis, normGyro.ZAxis);
    tft.drawString(buff, 0, 48);
    if (freefallDetected) {
        freefallDetected = false;
        Activites act = mpu.readActivites();
        if (act.isFreeFall) {
            digitalWrite(LED_PIN, !digitalRead(LED_PIN));
            if (freefallBlinkCount == 20) {
                digitalWrite(LED_PIN, !digitalRead(LED_PIN));

bool setupWiFi(void)
    if (WiFi.waitForConnectResult() != WL_CONNECTED) {
        Serial.println("Connected FAIL");
        return false;
    return true;

void setupOTA(void)
    // Port defaults to 3232
    // ArduinoOTA.setPort(3232);

    // Hostname defaults to esp3232-[MAC]

    // No authentication by default
    // ArduinoOTA.setPassword("admin");

    // Password can be set with it's md5 value as well
    // MD5(admin) = 21232f297a57a5a743894a0e4a801fc3
    // ArduinoOTA.setPasswordHash("21232f297a57a5a743894a0e4a801fc3");

    ArduinoOTA.onStart([]() {
        String type;
        if (ArduinoOTA.getCommand() == U_FLASH)
            type = "sketch";
        else // U_SPIFFS
            type = "filesystem";

        // NOTE: if updating SPIFFS this would be the place to unmount SPIFFS using SPIFFS.end()
        Serial.println("Start updating " + type);
        tft.drawString("Updating...", tft.width() / 2 - 20, 55 );
    .onEnd([]() {
    .onProgress([](unsigned int progress, unsigned int total) {
        // Serial.printf("Progress: %u%%\r", (progress / (total / 100)));
        int percentage = (progress / (total / 100));
        tft.setTextPadding(tft.textWidth(" 888% "));
        tft.drawString(String(percentage) + "%", 145, 35);
        drawProgressBar(10, 30, 120, 15, percentage, TFT_WHITE, TFT_BLUE);
    .onError([](ota_error_t error) {
        Serial.printf("Error[%u]: ", error);
        if (error == OTA_AUTH_ERROR) Serial.println("Auth Failed");
        else if (error == OTA_BEGIN_ERROR) Serial.println("Begin Failed");
        else if (error == OTA_CONNECT_ERROR) Serial.println("Connect Failed");
        else if (error == OTA_RECEIVE_ERROR) Serial.println("Receive Failed");
        else if (error == OTA_END_ERROR) Serial.println("End Failed");

        tft.drawString("Update Failed", tft.width() / 2 - 20, 55 );
        initial = 1;
        targetTime = millis() + 1000;
        omm = 99;


void loopOTA(void)

void setupMonitor()
    esp_adc_cal_characteristics_t adc_chars;
    esp_adc_cal_value_t val_type = esp_adc_cal_characterize((adc_unit_t)ADC_UNIT_1, (adc_atten_t)ADC1_CHANNEL_7, (adc_bits_width_t)ADC_WIDTH_BIT_12, 1100, &adc_chars);
    //Check type of calibration value used to characterize ADC
    if (val_type == ESP_ADC_CAL_VAL_EFUSE_VREF) {
        Serial.printf("eFuse Vref:%u mV", adc_chars.vref);
        vref = adc_chars.vref;
    } else if (val_type == ESP_ADC_CAL_VAL_EFUSE_TP) {
        Serial.printf("Two Point --> coeff_a:%umV coeff_b:%umV\n", adc_chars.coeff_a, adc_chars.coeff_b);
    } else {
        Serial.println("Default Vref: 1100mV");
    pinMode(CHARGE_PIN, INPUT);
    attachInterrupt(CHARGE_PIN, [] {
        charge_indication = true;
    }, CHANGE);

    if (digitalRead(CHARGE_PIN) == LOW) {
        charge_indication = true;

bool setupRTC(void)
    if (Wire.endTransmission() != 0) {
        return false;


    attachInterrupt(RTC_INT_PIN, [] {
        rtcIrq = 1;
    }, FALLING);

    //Just test rtc interrupt start...
    rtc.setDateTime(2019, 4, 7, 9, 5, 58);
    for (;;) {
        if (rtcIrq) {
            rtcIrq = 0;
    //Just test rtc interrupt end ...

    //Check if the RTC clock matches, if not, use compile time

    RTC_Date datetime = rtc.getDateTime();
    hh = datetime.hour;
    mm = datetime.minute;
    ss = datetime.second;

    return true;

void clickHandle(void)
    func_select = func_select % 3;
    if (func_select == 0) {
        initial = 1;
        targetTime = 0;
        omm = 99;
        if (digitalRead(CHARGE_PIN) == LOW) {
            charge_indication = true;
    showError = false;

void setup(void)

    tft.pushImage(0, 0,  160, 80, ttgo);

    pinMode(LED_PIN, OUTPUT);
    Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN);


    tft.setCursor(0, 0);

    if (!setupMPU6050()) {
        tft.println("Check MPU6050 FAIL");
    } else {
        tft.println("Check MPU6050 PASS");

    if (!setupMAX30105()) {
        tft.println("Check MAX30105 FAIL");
    } else {
        tft.println("Check MAX30105 PASS");

    if (!setupRTC()) {
        tft.println("Check PCF8563 FAIL");
    } else {
        tft.println("Check PCF8563 PASS");

    tft.print("Correction Vref=");
    tft.println(" mv");

    if (!setupWiFi()) {
        tft.println("Starting WiFi FAIL,Restarting...");
    } else {




    func_select = 0;
    targetTime = 0;
    tft.setTextColor(TFT_YELLOW, TFT_BLACK); // Note: the new fonts do not draw the background colour


String getVoltage()
    uint16_t v = analogRead(BATT_ADC_PIN);
    float battery_voltage = ((float)v / 4095.0) * 2.0 * 3.3 * (vref / 1000.0);
    return String(battery_voltage) + "V";

void page1()
    if (charge_indication) {
        charge_indication = false;
        if (digitalRead(CHARGE_PIN) == LOW) {
            tft.pushImage(140, 55, 16, 16, charge);
        } else {
            tft.fillRect(140, 55, 16, 16, TFT_BLACK);

    if (targetTime < millis()) {
        RTC_Date datetime = rtc.getDateTime();
        hh = datetime.hour;
        mm = datetime.minute;
        ss = datetime.second;
        // Serial.printf("hh:%d mm:%d ss:%d\n", hh, mm, ss);
        targetTime = millis() + 1000;
        if (ss == 0 || initial) {
            initial = 0;
            tft.setTextColor(TFT_GREEN, TFT_BLACK);
            tft.setCursor (8, 60);
            tft.print(__DATE__); // This uses the standard ADAFruit small font
        tft.setTextColor(TFT_BLUE, TFT_BLACK);
        tft.drawCentreString(getVoltage(), 120, 65, 1);

        // Update digital time
        uint8_t xpos = 6;
        uint8_t ypos = 0;
        if (omm != mm) { // Only redraw every minute to minimise flicker
            // Uncomment ONE of the next 2 lines, using the ghost image demonstrates text overlay as time is drawn over it
            tft.setTextColor(0x39C4, TFT_BLACK);  // Leave a 7 segment ghost image, comment out next line!
            //tft.setTextColor(TFT_BLACK, TFT_BLACK); // Set font colour to black to wipe image
            // Font 7 is to show a pseudo 7 segment display.
            // Font 7 only contains characters [space] 0 1 2 3 4 5 6 7 8 9 0 : .
            tft.drawString("88:88", xpos, ypos, 7); // Overwrite the text to clear it
            tft.setTextColor(0xFBE0, TFT_BLACK); // Orange
            omm = mm;

            if (hh < 10) xpos += tft.drawChar('0', xpos, ypos, 7);
            xpos += tft.drawNumber(hh, xpos, ypos, 7);
            xcolon = xpos;
            xpos += tft.drawChar(':', xpos, ypos, 7);
            if (mm < 10) xpos += tft.drawChar('0', xpos, ypos, 7);
            tft.drawNumber(mm, xpos, ypos, 7);

        if (ss % 2) { // Flash the colon
            tft.setTextColor(0x39C4, TFT_BLACK);
            xpos += tft.drawChar(':', xcolon, ypos, 7);
            tft.setTextColor(0xFBE0, TFT_BLACK);
        } else {
            tft.drawChar(':', xcolon, ypos, 7);

void loop()
    switch (func_select) {
    case 0:
    case 1:
        if (targetTime < millis()) {
            targetTime += 200;
    case 2:

void enterDeepsleep()
    tft.setTextColor(TFT_GREEN, TFT_BLACK);
    tft.drawString("Press again to wake up",  tft.width() / 2, tft.height() / 2 );
    Serial.println("Go to Sleep");
    esp_sleep_enable_ext1_wakeup(GPIO_SEL_38, ESP_EXT1_WAKEUP_ALL_LOW);

void drawProgressBar(uint16_t x0, uint16_t y0, uint16_t w, uint16_t h, uint8_t percentage, uint16_t frameColor, uint16_t barColor)
    if (percentage == 0) {
        tft.fillRoundRect(x0, y0, w, h, 3, TFT_BLACK);
    uint8_t margin = 2;
    uint16_t barHeight = h - 2 * margin;
    uint16_t barWidth = w - 2 * margin;
    tft.drawRoundRect(x0, y0, w, h, 3, frameColor);
    tft.fillRect(x0 + margin, y0 + margin, barWidth * percentage / 100.0, barHeight, barColor);


Technical Details: 


  • ESP32-D0WDQ6-V3, 240 MHz dual-core Tensilica LX6 microcontroller with 600 DMIPS
  • 520KB SRAM
  • 448KB ROM
  • WiFi and Bluetooth support


  • 0.96 inch IPS LCD display with 80x160 resolution


  • MAX30102 heart rate sensor
  • LIS2DH12 3-axis accelerometer


  • WiFi 802.11b/g/n
  • Bluetooth v4.2 BR/EDR and BLE
  • Micro USB port for programming and power

RTC: PCF8563

Customizable buttons

Battery: 200mah Charging

voltage and current: 5V,500mah





  • The LilyGo-HeartRate-Kit and the MAX30102 heart rate sensor both have the capability to measure heart rate, but they differ in terms of the features and ease of use they offer.
  • The LilyGo-HeartRate-Kit is a development kit that includes an ESP32 microcontroller, a MAX30102 heart rate sensor, an IPS LCD display, and WiFi and Bluetooth connectivity. This makes it easier to get started with heart rate monitoring and provides additional features like wireless connectivity, display capabilities, and motion detection using an accelerometer. Although the kit doesn't include an SD card slot, the WiFi and Bluetooth capabilities make it possible to transmit data to a remote server or device for storage or analysis.
  • Using only the MAX30102 heart rate sensor, on the other hand, requires more expertise and effort to set up and use. The sensor is a small board that contains the heart rate sensor and some supporting components, and it needs to be connected to a microcontroller or computer to read and process the data. This would require designing or using an existing circuit to connect the sensor to the microcontroller or computer, and writing code to read the sensor data and process it to calculate heart rate. Although the sensor does not provide features like display capabilities or wireless connectivity, it is a good option for those who already have experience with microcontrollers and are comfortable with circuit design and programming.
  • So, the LilyGo-HeartRate-Kit provides a more complete and user-friendly solution for heart rate monitoring, with additional features like display capabilities and wireless connectivity, while using only the MAX30102 heart rate sensor requires more expertise and effort to set up and use.