This shows you the differences between two versions of the page.
iothings:proiecte:2025sric:runtrack32 [2025/05/29 00:04] alexandra.cornea01 [Introduction] |
iothings:proiecte:2025sric:runtrack32 [2025/05/29 02:31] (current) alexandra.cornea01 [Demonstration and Results] |
||
---|---|---|---|
Line 10: | Line 10: | ||
==== Hardware Design ==== | ==== 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). | ||
+ | |||
+ | {{:iothings:proiecte:2025sric:runtracker.jpg?570|}} | ||
+ | |||
+ | **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 ==== | ==== 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 === | === 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. | ||
+ | |||
+ | <code> | ||
+ | #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; | ||
+ | } | ||
+ | |||
+ | </code> | ||
+ | |||
=== Mobile App Code === | === Mobile App Code === | ||
- | ==== Setup ==== | + | 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. | ||
+ | |||
+ | <code> | ||
+ | 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) | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | </code> | ||
+ | |||
+ | <code> | ||
+ | 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 | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | </code> | ||
+ | |||
+ | 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. | ||
+ | |||
+ | <code> | ||
+ | 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) | ||
+ | } | ||
+ | </code> | ||
+ | |||
+ | ==== 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. | ||
+ | |||
+ | {{:iothings:proiecte:2025sric:app.jpg?300|}} | ||
+ | |||
+ | Serial logs from the Arduino console show how data is continuously read from the GPS and pulse oximeter sensors. | ||
+ | |||
+ | {{:iothings:proiecte:2025sric:monitor.png?570|}} | ||
- | ==== Results ==== | + | the image shows the MAX30100 sensor in use for pulse measurement, alongside the GPS module actively providing location and speed data. |
+ | {{:iothings:proiecte:2025sric:testspeed.jpg?570|}} | ||
==== References ==== | ==== References ==== | ||
+ | * [[https://randomnerdtutorials.com/esp32-neo-6m-gps-module-arduino/|ESP32 with NEO-6M GPS Module (Arduino IDE)]] | ||
+ | * [[https://www.electronicwings.com/esp32/max30100-pulse-oximeter-interfacing-with-esp32|MAX30100 Pulse Oximeter Interfacing with ESP32]] | ||
+ | * [[https://www.ridgesolutions.ie/index.php/2013/11/14/algorithm-to-calculate-speed-from-two-gps-latitude-and-longitude-points-and-time-difference/|Algorithm to calculate speed from two GPS latitude and longitude points and time difference]] |