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.
The system is built around an ESP32 development board, which serves as the central controller. It integrates two key sensors:
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
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.
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; }
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) }
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.