RunTrack32: Smart Running Assistant

  • Author: Alexandra Cornea
  • Email: alexandra.cornea01@stud.acs.upb.ro
  • Master: SRIC1

Introduction

The RunTracker32 Android application is a real-time health and location monitoring tool designed to interface with a custom Bluetooth-enabled fitness device. The app displays live data such as heart rate (BPM), blood oxygen level (SpO₂), GPS coordinates, and movement speed (in km/h), allowing users to track their vitals and position during physical activity. Additionally, the app offers quick access to Google Maps for viewing the current location.

Hardware Design

The system is built around an ESP32 development board, which serves as the central controller. It integrates two key sensors:

  • GPS module (Neo-6M) – for tracking geographic coordinates
  • Pulse oximeter (MAX30100) – for measuring heart rate and SpO₂ levels

The sensors are powered via the breadboard's power rails, and communication is established using serial (for GPS) and I2C (for MAX30100).

GPS (Neo-6M) Connections

 TX   →  GPIO16  —  Serial RX (ESP32 receives)
 RX   →  GPIO17  —  Serial TX (ESP32 sends)
 VCC  →  3.3V    —  Power
 GND  →  GND     —  Ground

MAX30100 Connections

 SDA  →  GPIO21  —  I2C Data
 SCL  →  GPIO22  —  I2C Clock
 VCC  →  3.3V    —  Power
 GND  →  GND     —  Ground

Software Design

The system consists of two main software components: the embedded firmware running on the ESP32, developed using the Arduino IDE, and the Android application built with Kotlin. The ESP32 collects real-time health and location data from connected sensors, formats the information, and transmits it over Bluetooth. On the other side, the Android app receives this data, parses it, and presents it in a user-friendly interface for live monitoring.

Arduino IDE Code

The firmware running on the ESP32 is responsible for collecting sensor data and sending it via Bluetooth. The setup initializes three main components: the MAX30100 pulse oximeter for measuring heart rate (BPM) and oxygen saturation (SpO₂), the Neo-6M GPS module for providing location data, and Bluetooth Serial for transmitting all collected values to the Android app.

In the loop() function, the ESP32 continuously reads GPS data over serial in the NMEA 0183 format, focusing on $GPRMC sentences. These sentences contain comma-separated values representing time, fix status, latitude, longitude, and more. For example, from $GPRMC,200142.00,A,4426.63451,N,02603.24242,E,…, the latitude value 4426.63451 and direction N are decoded into decimal degrees (44.443908) for easier mapping. At the same time, BPM and SpO₂ values are read once per second and all the data is sent over Bluetooth in a human-readable format.

#include <Wire.h>
#include "MAX30100_PulseOximeter.h"
#include "BluetoothSerial.h"
 
// Define the RX and TX pins for Serial 2
#define RXD 16
#define TXD 17
#define SDA 21
#define SCL 22
#define GPS_BAUD 9600

// Create an instance of the HardwareSerial class for Serial 2
HardwareSerial gpsSerial(2);

String gpsBuffer = "";

// Pulse sensor
PulseOximeter pox;
unsigned long lastPulseRead = 0;
const unsigned long pulseReadInterval = 1000; // 1 sec

BluetoothSerial SerialBT;

void setup() {
  // Serial Monitor
  Serial.begin(57600);

  SerialBT.begin("RunTracker32");
  Serial.println("Bluetooth started...");
  
  // Start Serial 2 with the defined RX and TX pins and a baud rate of 9600
  gpsSerial.begin(GPS_BAUD, SERIAL_8N1, RXD, TXD);
  Serial.println("GPS Reader started...");
  
  Wire.begin(SDA, SCL);

  if (!pox.begin()) {
    Serial.println("Failed to initialize MAX30100");
    while (1);
  }

  pox.setIRLedCurrent(MAX30100_LED_CURR_7_6MA);
  Serial.println("Pulse Oximeter started...");
}

void loop() {
  while (gpsSerial.available() > 0) {
    // get the byte data from the GPS
    char gpsData = gpsSerial.read();
    
    if (gpsData == '\n') {
      processGPSLine(gpsBuffer);
      gpsBuffer = "";
    } else if (gpsData != '\r') {
      gpsBuffer += gpsData;
    }
  }

  // --- Pulse sensor ---
  pox.update();

  if (millis() - lastPulseRead > pulseReadInterval) {
    lastPulseRead = millis();
    Serial.print("BPM: ");
    Serial.print(pox.getHeartRate());
    Serial.print("  SpO2: ");
    Serial.println(pox.getSpO2());

    SerialBT.print("BPM: ");
    SerialBT.print(pox.getHeartRate());
    SerialBT.print("  SpO2: ");
    SerialBT.println(pox.getSpO2());
  }
}

void processGPSLine(String line) {
  if (line.startsWith("$GPRMC")) {
    Serial.println("GPRMC line: " + line);
    String parts[12];
    int index = 0;
    int fromIndex = 0;
    int commaIndex;

    while ((commaIndex = line.indexOf(',', fromIndex)) != -1 && index < 12) {
      parts[index++] = line.substring(fromIndex, commaIndex);
      fromIndex = commaIndex + 1;
    }

    // Extract latitude and longitude (if available)
    String latRaw = parts[3];
    String latDir = parts[4];
    String lonRaw = parts[5];
    String lonDir = parts[6];

    if (latRaw != "" && lonRaw != "") {
      float lat = convertToDecimalDegrees(latRaw, latDir);
      float lon = convertToDecimalDegrees(lonRaw, lonDir);
      Serial.print("Latitude: ");
      Serial.print(lat, 6);
      Serial.print("  Longitude: ");
      Serial.println(lon, 6);

      SerialBT.print("Latitude: ");
      SerialBT.print(lat, 6);
      SerialBT.print("  Longitude: ");
      SerialBT.println(lon, 6);
    } else {
      Serial.println("Waiting for valid GPS fix...");
    }
  }
}

float convertToDecimalDegrees(String raw, String dir) {
  int degreeLength = (dir == "N" || dir == "S") ? 2 : 3;
  
  float deg = raw.substring(0, degreeLength).toFloat();
  float min = raw.substring(degreeLength).toFloat();
  float result = deg + (min / 60.0);
  
  if (dir == "S" || dir == "W")
    result = -result;
  return result;
}

Mobile App Code

The Android application is built using Kotlin and connects to the ESP32 via Bluetooth to display real-time fitness data. Upon launch, the app searches for a bonded device named RunTracker32, establishes a Bluetooth socket connection, and listens for incoming data in a background thread.

The listenForData() function reads Bluetooth messages, and once a newline character is detected, the message is passed to parseAndDisplay(). This function uses regular expressions to extract values for heart rate (BPM), oxygen saturation (SpO₂), and GPS coordinates. These values are then shown in the app's user interface.

private fun listenForData() {
  val input: InputStream? = bluetoothSocket?.inputStream
  val buffer = ByteArray(1024)
  val messageBuilder = StringBuilder()

  while (true) {
    val bytes = input?.read(buffer) ?: break
    val readMessage = String(buffer, 0, bytes)

    messageBuilder.append(readMessage)

    if (readMessage.contains("\n")) {
      val fullMessage = messageBuilder.toString().trim()
      messageBuilder.clear()

      runOnUiThread {
        parseAndDisplay(fullMessage)
      }
    }
  }
}
private fun parseAndDisplay(message: String) {
  val bpmRegex = Regex("""BPM:\s*([\d.]+)""")
  val spo2Regex = Regex("""SpO2:\s*(\d+)""")
  val latRegex = Regex("""Latitude:\s*([\d.]+)""")
  val lonRegex = Regex("""Longitude:\s*([\d.]+)""")

  val bpm = bpmRegex.find(message)?.groupValues?.get(1) ?: "?"
  val spo2 = spo2Regex.find(message)?.groupValues?.get(1) ?: "?"
  val latStr = latRegex.find(message)?.groupValues?.get(1) ?: ""
  val lonStr = lonRegex.find(message)?.groupValues?.get(1) ?: ""

  pulseText.text = getString(R.string.bpm_format, bpm)
  spo2Text.text = getString(R.string.spo2_format, spo2)

  if (latStr.isNotEmpty() && lonStr.isNotEmpty()) {
    gpsText.text = getString(R.string.location_format, latStr, lonStr)
    lastLat = latStr
    lastLon = lonStr

    val lat = latStr.toDoubleOrNull()
    val lon = lonStr.toDoubleOrNull()
    val now = System.currentTimeMillis()

    if (lat != null && lon != null) {
      if (lastLatValue != null && lastLonValue != null && lastTimestamp != null) {
        val speed = calculateSpeedKmH(
          lastLatValue!!, lastLonValue!!, lastTimestamp!!,
          lat, lon, now
        )
        speedText.text = getString(R.string.speed_format, "%.2f".format(speed))
      }
      lastLatValue = lat
      lastLonValue = lon
      lastTimestamp = now
    }
  }
}

To estimate speed, the app implements a Haversine-based function, calculateSpeedKmH(), which computes the distance between two sets of latitude/longitude coordinates and divides it by the time difference. This allows the app to display an approximate speed in kilometers per hour (km/h), updated every time a new valid GPS coordinate is received.

private fun calculateSpeedKmH(
  lat1: Double, lon1: Double, time1: Long,
  lat2: Double, lon2: Double, time2: Long
): Double {
  val deltaTimeSec = (time2 - time1) / 1000.0
  if (deltaTimeSec == 0.0) return 0.0

  val R = 6371.0

  val dLat = Math.toRadians(lat2 - lat1)
  val dLon = Math.toRadians(lon2 - lon1)
  val a = sin(dLat / 2).pow(2.0) +
      cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) *
      sin(dLon / 2).pow(2.0)
  val c = 2 * atan2(sqrt(a), sqrt(1 - a))
  val distanceKm = R * c

  return distanceKm / (deltaTimeSec / 3600.0)
}

Demonstration and Results

To illustrate the functionality of the system, the images and logs offer a glimpse into how the components interact in practice, with real-time data flowing from the hardware to the mobile app.

The Android application displays real-time values such as heart rate, oxygen saturation, speed, and GPS location in a clean interface.

Serial logs from the Arduino console show how data is continuously read from the GPS and pulse oximeter sensors.

the image shows the MAX30100 sensor in use for pulse measurement, alongside the GPS module actively providing location and speed data.

References

iothings/proiecte/2025sric/runtrack32.txt · Last modified: 2025/05/29 02:31 by alexandra.cornea01
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0