VitalSense - A TinyML Smart Stethoscope Exploration

VitalSense: An EdgeAI smart stethoscope exploration on XIAO nRF52 with pre-trained Edge Impulse model for edge heart/lung anomaly detection.

IntermediateShowcase (no instructions)12 hours81
VitalSense - A TinyML Smart Stethoscope Exploration

Things used in this project

Hardware components

Seeed Studio XIAO nRF52840 Sense (XIAO BLE Sense)
Seeed Studio XIAO nRF52840 Sense (XIAO BLE Sense)
×1
USB-C cable
×1
Generic (cheap) Acoustic Stethoscope
×1

Software apps and online services

Arduino IDE
Arduino IDE
Edge Impulse Studio
Edge Impulse Studio
Android Studio
Android Studio
PhysioNet - The CirCor DigiScope Phonocardiogram Dataset
Fusion
Autodesk Fusion
ICBHI 2017 Challenge-Respiratory Sound Database

Hand tools and fabrication machines

3D Printer (generic)
3D Printer (generic)

Story

Read more

Custom parts and enclosures

DIY Tube-to-Mic Enclosure

We designed and 3D printed a simple custom enclosure/adapter to physically secure the rubber tube of the 5€ stethoscope directly over the XIAO nRF52 Sense microphone hole, trying to create an airtight acoustic seal.

Code

[XIAO Firmware] -> Main_Stethoscope_Inference.ino

C/C++
Embedded C++ firmware for the Seeed Studio XIAO microcontroller. This file manages real-time audio acquisition via the PDM microphone, validates the chest sound periodicity (BPM tracking), and runs the Edge Impulse Machine Learning classifier (chest_sound_classifier.h). It communicates with the Android application using the Bluetooth Low Energy (BLE) UART service to receive measurement triggers and transmit final diagnostic results.
#include <bluefruit.h>
#include <PDM.h> 
#include <chest_sound_classifier.h> 

#define LED_PIN LED_GREEN

// ====== GLOBAL OBJECTS & STATE VARIABLES ======
BLEUart bleuart;
bool isConnected = false;

// ----- TIMING VARIABLES FOR 5-SECOND DELAYED RESPONSE -----
String lastReceived = "";           
unsigned long lastReceiveTime = 0;  
bool waitingToReply = false;        

// ====== AUDIO SAMPLING CONFIGURATION ======
#define AUDIO_SAMPLING_FREQUENCY       16000 
#define TOTAL_MEASUREMENT_SECONDS      20    

bool snemanjeAktivno = false;
unsigned long zacetekSnemanjaMilli = 0;
unsigned long zadnjiUtripMilli = 0; 
bool stanjeLedMedMeritvijo = false;
String tipMeritve = ""; 

unsigned int stevecIzpisa = 0;

// ====== EDGE IMPULSE BUFFER & CONFIDENCE THRESHOLD ======
const float CONFIDENCE_THRESHOLD = 0.70; 

float mikrofon_buffer[EI_CLASSIFIER_RAW_SAMPLE_COUNT];
unsigned long indeks_bufferja = 0;

// Callback function to provide signal data to the Edge Impulse classifier
int get_signal_data(size_t offset, size_t length, float *out_ptr) {
    memcpy(out_ptr, mikrofon_buffer + offset, length * sizeof(float));
    return 0;
}

// Function to initialize and start the audio recording session
void zazeni_snemanje() {
  zacetekSnemanjaMilli = millis(); 
  zadnjiUtripMilli = millis();
  stanjeLedMedMeritvijo = false;   
  stevecIzpisa = 0;
  indeks_bufferja = 0; 
  memset(mikrofon_buffer, 0, sizeof(mikrofon_buffer));
  
  snemanjeAktivno = true;
  
  // If the command targets real microphone inference
  if (tipMeritve == "heart_TV_inferencing") {
    PDM.onReceive(onPDMdata);
    if (!PDM.begin(1, AUDIO_SAMPLING_FREQUENCY)) {
      Serial.println("[MICROPHONE] Error starting PDM!");
    } else {
      PDM.setGain(20);
      Serial.println("[MICROPHONE] Real-time recording started for STV.");
    }
  } else {
    // Handling for simulated modes or other measurement positions
    Serial.println("[SIMULATION] Starting 20s simulation for: " + tipMeritve);
  }
}

// Function to safely stop the recording session
void zaustavi_snemanje() {
  if (tipMeritve == "heart_TV_inferencing") {
    PDM.end();
    Serial.println("[MICROPHONE] Real-time recording stopped/finished.");
  } else {
    Serial.println("[SIMULATION] Simulation stopped/finished.");
  }
  snemanjeAktivno = false;
  if (isConnected) {
    digitalWrite(LED_PIN, HIGH);
  }
}

// ====== SIGNAL VALIDATION (HEART RATE FREQUENCY CHECK) ======
bool je_veljaven_srcni_signal() {
  float vsota_amp = 0;
  // Calculate average amplitude to detect silence or disconnected hardware
  for (size_t i = 0; i < EI_CLASSIFIER_RAW_SAMPLE_COUNT; i++) {
    vsota_amp += abs(mikrofon_buffer[i]);
  }
  float povprecna_amplituda = vsota_amp / EI_CLASSIFIER_RAW_SAMPLE_COUNT;

  if (povprecna_amplituda < 15.0) {
    Serial.println("[VALIDATION] Error: Signal is too quiet (silence detected).");
    return false;
  }

  // Peak detection logic to estimate BPM based on rhythm periodicity
  float meja_udarca = povprecna_amplituda * 1.5;
  unsigned int stevilo_udarcev = 0;
  bool nad_mejo = false;
  unsigned long vzorcev_od_zadnjega_udarca = 0;
  unsigned long min_razmik_vzorcev = AUDIO_SAMPLING_FREQUENCY * 0.25; // Debounce mechanism to prevent double counting

  for (size_t i = 0; i < EI_CLASSIFIER_RAW_SAMPLE_COUNT; i++) {
    vzorcev_od_zadnjega_udarca++;
    
    if (abs(mikrofon_buffer[i]) > meja_udarca) {
      if (!nad_mejo && (vzorcev_od_zadnjega_udarca > min_razmik_vzorcev)) {
        stevilo_udarcev++;
        nad_mejo = true;
        vzorcev_od_zadnjega_udarca = 0;
      }
    } else {
      if (abs(mikrofon_buffer[i]) < (meja_udarca * 0.8)) {
        nad_mejo = false; 
      }
    }
  }

  // Calculate estimated Beats Per Minute (BPM)
  float trajanje_bufferja_sekund = (float)EI_CLASSIFIER_RAW_SAMPLE_COUNT / AUDIO_SAMPLING_FREQUENCY;
  float izracunan_bpm = (stevilo_udarcev / trajanje_bufferja_sekund) * 60.0;

  Serial.print("[VALIDATION] Detected peaks in window: "); Serial.print(stevilo_udarcev);
  Serial.print(" -> Estimated heart rate: "); Serial.print(izracunan_bpm); Serial.println(" BPM");

  // Filter out anomalies that do not match a valid human heart rate
  if (izracunan_bpm < 45.0 || izracunan_bpm > 170.0) {
    Serial.println("[VALIDATION] REJECTED: Sound rhythm does not match a human heart!");
    return false;
  }

  Serial.println("[VALIDATION] CONFIRMED: Signal periodicity matches a heart pattern.");
  return true;
}

// ====== EDGE IMPULSE INFERENCE MODEL ANALYSIS ======
String analiziraj_meritev_z_modelom() {
  if (!je_veljaven_srcni_signal()) {
    return "neveljavna meritev"; // Returns invalid measurement if verification fails
  }

  signal_t signal;
  signal.total_length = EI_CLASSIFIER_RAW_SAMPLE_COUNT;
  signal.get_data = &get_signal_data;

  ei_impulse_result_t result = { 0 };

  // Execute the machine learning classifier
  EI_IMPULSE_ERROR r = run_classifier(&signal, &result, false);
  if (r != EI_IMPULSE_OK) {
    Serial.println("[MODEL] Error running the classifier!");
    return "neveljavna meritev";
  }

  float vrednost_healthy = 0.0;
  float vrednost_murmur = 0.0;

  Serial.println("\n====================================");
  Serial.println("[MODEL] CLASSIFICATION RESULTS FOR STV:");
  
  // Parse through prediction values for each label
  for (size_t i = 0; i < EI_CLASSIFIER_LABEL_COUNT; i++) {
    String ime_razreda = result.classification[i].label;
    float verjetnost = result.classification[i].value;

    Serial.print("  -> Class ["); Serial.print(ime_razreda); Serial.print("]: ");
    Serial.print(verjetnost * 100.0); Serial.println("%");

    if (ime_razreda == "healthy") {
        vrednost_healthy = verjetnost; 
    } else if (ime_razreda == "murmur") {
        vrednost_murmur = verjetnost;
    }
  }

  // Final evaluation based on the confidence threshold
  String koncni_niz = "";
  if (vrednost_murmur >= CONFIDENCE_THRESHOLD) {
    koncni_niz = "murmur";
  } 
  else if (vrednost_healthy >= CONFIDENCE_THRESHOLD) {
    koncni_niz = "healthy";
  } 
  else {
    koncni_niz = "neveljavna meritev";
  }

  Serial.print("[MODEL] FINAL DECISION: "); Serial.println(koncni_niz);
  return koncni_niz;
}

// ====== BLE CALLBACK EVENTS ======
void connect_callback(uint16_t conn_handle) {
  isConnected = true;
  digitalWrite(LED_PIN, HIGH);   
  delay(500);
  bleuart.print("XIAO_OK\n"); // Acknowledge successful handshake to the Android app
  Serial.println("[BLE] Connection established!");
}

void disconnect_callback(uint16_t conn_handle, uint8_t reason) {
  isConnected = false;
  Serial.println("[BLE] Connection disconnected.");
  waitingToReply = false;
  lastReceived = "";
  snemanjeAktivno = false;
  if (tipMeritve == "heart_TV_inferencing") {
    PDM.end();
  }
  Bluefruit.Advertising.start(); // Resume advertising for re-connection
}

// ====== INCOMING BLUETOOTH DATA HANDLER (UART RX) ======
void uart_rx_callback(uint16_t conn_handle) {
  char buf[64];
  int len = bleuart.read(buf, sizeof(buf) - 1);
  buf[len] = 0;

  String msg = String(buf);
  
  Serial.print("[BLE RECEIVED] Raw string: [");
  Serial.print(msg);
  Serial.println("]");

  msg.trim(); 
  if (msg.length() == 0) return;

  // ====== ANATOMICAL POSITION COMMAND PARSING ======
  bool veljavenUkaz = false;
  if (msg == "SL") { tipMeritve = "anterior_left_inferencing"; veljavenUkaz = true; }
  else if (msg == "SD") { tipMeritve = "anterior_right_inferencing"; veljavenUkaz = true; }
  else if (msg == "SAV") { tipMeritve = "heart_AV_inferencing"; veljavenUkaz = true; }
  else if (msg == "SPV") { tipMeritve = "Heart_PV_inferencing"; veljavenUkaz = true; }
  else if (msg == "STV") { tipMeritve = "heart_TV_inferencing"; veljavenUkaz = true; } 
  else if (msg == "ZL") { tipMeritve = "posterior_left_inferencing"; veljavenUkaz = true; }
  else if (msg == "ZD") { tipMeritve = "posterior_right_inferencing"; veljavenUkaz = true; }
  else if (msg == "SAV_T_OK") { tipMeritve = "Heart AV inferencing TEST OK"; veljavenUkaz = true; }
  else if (msg == "SAV_T_NOK") { tipMeritve = "Heart AV inferencing TEST NI OK"; veljavenUkaz = true; }

  if (veljavenUkaz) {
    zazeni_snemanje();
  }
  else if (msg == "CANCEL") {
    Serial.println("[BLE] Received STOP command!");
    zaustavi_snemanje(); 
    bleuart.print("STOP\n");
  }
  else {
    // If it's a generic diagnostic echo message, schedule a delayed automatic response
    lastReceived = msg;
    lastReceiveTime = millis();
    waitingToReply = true;   
  }
}

// ====== SETUP / INITIALIZATION ======
void setup() {
  Serial.begin(115200);
  unsigned long startSetup = millis();
  while (!Serial && (millis() - startSetup < 2000)); // Non-blocking serial timeout fallback

  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, LOW);

  // Initialize BLE Stack and configure GAP Profile
  Bluefruit.begin();
  Bluefruit.setName("PicoW_STOP");
  Bluefruit.Periph.setConnectCallback(connect_callback);
  Bluefruit.Periph.setDisconnectCallback(disconnect_callback);

  // Initialize BLE UART Service
  bleuart.begin();
  bleuart.setRxCallback(uart_rx_callback);

  // Setup Advertising parameters
  Bluefruit.Advertising.addFlags(BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE);
  Bluefruit.Advertising.addTxPower();
  Bluefruit.Advertising.addName();
  Bluefruit.Advertising.addService(bleuart);
  Bluefruit.Advertising.restartOnDisconnect(true);
  Bluefruit.Advertising.setInterval(160, 160);  
  Bluefruit.Advertising.start();
}

// ====== MAIN EXECUTION LOOP ======
void loop() {
  unsigned long trenutniMillis = millis();

  // 1. Manage active data sampling routine
  if (snemanjeAktivno && isConnected) {
    // Toggle status LED every 100ms during live measurements (blinking state)
    if (trenutniMillis - zadnjiUtripMilli >= 100) {
      zadnjiUtripMilli = trenutniMillis;
      stanjeLedMedMeritvijo = !stanjeLedMedMeritvijo;
      digitalWrite(LED_PIN, stanjeLedMedMeritvijo ? HIGH : LOW);
    }

    // Check if the required observation window duration has finished
    if (trenutniMillis - zacetekSnemanjaMilli >= (TOTAL_MEASUREMENT_SECONDS * 1000)) {
      zaustavi_snemanje();
      String koncniOdgovor = "";

      if (tipMeritve == "heart_TV_inferencing") {
        koncniOdgovor = analiziraj_meritev_z_modelom();
      } else {
        koncniOdgovor = "neveljavna meritev"; // Simulated modes directly return fallback
      }
      bleuart.print(koncniOdgovor + "\n"); // Broadcast final verdict to the Android client
    }
  }

  // 2. Handle standard eco feedback latency protocol
  if (waitingToReply && isConnected) {
    if (trenutniMillis - lastReceiveTime >= 5000) {
      String response = lastReceived + "OK";
      bleuart.print(response); bleuart.print("\n");
      waitingToReply = false; lastReceived = "";
    }
  }
  
  // 3. Manage background system LED states
  if (!snemanjeAktivno) {
    if (!isConnected) {
      // Slow pulsing flash pattern when waiting for an active central BLE client
      static unsigned long zadnjiUtripBrezPovezave = 0;
      if (trenutniMillis - zadnjiUtripBrezPovezave >= 300) {
        zadnjiUtripBrezPovezave = trenutniMillis;
        digitalWrite(LED_PIN, !digitalRead(LED_PIN));
      }
    } else {
      digitalWrite(LED_PIN, HIGH);  // Solid ON state when connected but idle
    }
  }
}

// ====== PDM MICROPHONE DATA HARDWARE INTERRUPT ======
void onPDMdata() {
  short dummyBuffer[64]; 
  int bytesAvailable = PDM.available();
  
  while (bytesAvailable > 0) {
    int toRead = bytesAvailable;
    if (toRead > sizeof(dummyBuffer)) toRead = sizeof(dummyBuffer);
    
    PDM.read(dummyBuffer, toRead);
    int skrcenoVzorcev = toRead / 2; // Converting bytes array to short (16-bit) samples size

    // Stream incoming audio frames directly into the machine learning classification array
    for (int i = 0; i < skrcenoVzorcev; i++) {
      if (indeks_bufferja < EI_CLASSIFIER_RAW_SAMPLE_COUNT) {
        mikrofon_buffer[indeks_bufferja] = (float)dummyBuffer[i];
        indeks_bufferja++;
      }
      
      // Plotting/Printing a downsampled preview to Serial Monitor for visual debugging
      stevecIzpisa++;
      if (stevecIzpisa >= 64) { 
        Serial.println(dummyBuffer[i]);
        stevecIzpisa = 0;
      }
    }
    bytesAvailable = PDM.available();
  }
}

[Android App] -> MainActivity.java

Java
Core controller class for the Android companion application. This file establishes a asynchronous Bluetooth Low Energy (BLE) Generic Attribute Profile (GATT) connection with the XIAO microcontroller using the Nordic UART Service (NUS). It handles runtime permission architecture for modern Android APIs, handles BLE packet fragmentation by buffering incoming data streams, triggers specialized anatomical diagnostic modes (pulmonary and cardiac regions) via short hexadecimal-mapped string commands, and manages interactive UI state transitions based on live notification events from the stethoscope hardware.
package com.example.stoparica;

import android.Manifest;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCallback;
import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.BluetoothGattDescriptor;
import android.bluetooth.BluetoothGattService;
import android.bluetooth.BluetoothManager;
import android.bluetooth.le.BluetoothLeScanner;
import android.bluetooth.le.ScanCallback;
import android.bluetooth.le.ScanResult;
import android.content.pm.PackageManager;
import android.graphics.Color;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.ProgressBar;
import android.widget.Spinner;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.AppCompatButton;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import java.util.UUID;

import com.example.stetoskop.R;

/**
 * MainActivity: The core controller for the Stethoscope Android Application.
 *
 * This class manages the entire lifecycle of a Bluetooth Low Energy (BLE) connection
 * between an Android smartphone and a Raspberry Pi Pico W (acting as the Stethoscope).
 *
 * KEY BLE CONCEPTS USED:
 * 1. GATT (Generic Attribute Profile): Defines how data is organized and transferred.
 * 2. Services: Logical groups of data (e.g., UART Service).
 * 3. Characteristics: Specific data points within a service (e.g., RX for commands, TX for data).
 * 4. NUS (Nordic UART Service): A proprietary BLE profile used to emulate a serial port.
 *
 * This code is optimized for educational project showcases on Hackster.io.
 */
public class MainActivity extends AppCompatActivity {

    private static final String TAG = "BLE_STETOSKOP";

    // Request code used for identifying runtime permission results
    private static final int PERM_REQ = 1;

    // Scan period duration (30 seconds) to prevent infinite scanning and battery drain
    private static final long SCAN_TIMEOUT = 30000;

    // The unique Bluetooth name of our stethoscope device defined in the Pico W firmware
    private static final String TARGET_DEVICE_NAME = "PicoW_STOP";

    /**
     * NORDIC UART SERVICE (NUS) UUIDs:
     * These universally unique identifiers are used to target the specific "Serial-over-BLE"
     * implementation on our peripheral device.
     */
    // The main NUS Service identifier
    private static final UUID UART_SERVICE_UUID = UUID.fromString("6e400001-b5a3-f393-e0a9-e50e24dcca9e");

    // RX Characteristic (Write): Used by the Android app to send command strings to the Pico W
    private static final UUID RX_CHAR_UUID = UUID.fromString("6e400002-b5a3-f393-e0a9-e50e24dcca9e");

    // TX Characteristic (Notify): Used by the Pico W to push measurement data back to the app
    private static final UUID TX_CHAR_UUID = UUID.fromString("6e400003-b5a3-f393-e0a9-e50e24dcca9e");

    /**
     * Client Characteristic Configuration Descriptor (CCCD):
     * A mandatory descriptor required to "subscribe" to characteristic notifications.
     * Writing a specific bitmask to this descriptor tells the peripheral to start sending data updates.
     */
    private static final UUID CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");

    // Core Android Bluetooth components
    private BluetoothAdapter bluetoothAdapter;
    private BluetoothLeScanner bleScanner;
    private BluetoothGatt bluetoothGatt;
    private BluetoothGattCharacteristic rxChar;

    // State flags for internal workflow tracking
    private boolean scanning = false;
    private boolean isMeasuring = false;

    // UI and Threading helpers
    private Handler timeoutHandler = new Handler(Looper.getMainLooper());
    private TextView tvStatus, tvMeasurementResult;
    private Button btnRescan;
    private AppCompatButton btnAction;
    private ProgressBar measurementLoader;
    private Spinner spinnerMeritev;

    /**
     * BLE Data Buffer:
     * BLE transmission is limited by the Maximum Transmission Unit (MTU), typically 20-23 bytes.
     * Large results from the stethoscope (e.g., multi-digit numbers or status strings)
     * often arrive in multiple packets. This StringBuilder collects chunks until a
     * termination character (\n) is received, signaling a complete message.
     */
    private StringBuilder bleBuffer = new StringBuilder();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // UI Initialization: Binding Java variables to layout elements
        tvStatus = findViewById(R.id.tv_status);
        tvMeasurementResult = findViewById(R.id.tv_measurement_result);
        btnRescan = findViewById(R.id.btn_ponovno_iskanje);
        btnAction = findViewById(R.id.btn_start_measurement);
        measurementLoader = findViewById(R.id.measurement_loader);
        spinnerMeritev = findViewById(R.id.spinner_meritev);

        // Setup manual scan trigger
        btnRescan.setOnClickListener(v -> startScan());

        // Setup the measurement toggle logic (Start or Cancel)
        btnAction.setOnClickListener(v -> {
            if (!isMeasuring) {
                int pos = spinnerMeritev.getSelectedItemPosition();
                if (pos == 0) { // Index 0 is the "Select type" prompt
                    Toast.makeText(this, "Please select a measurement type", Toast.LENGTH_SHORT).show();
                    return;
                }
                startMeasurement(pos);
            } else {
                cancelMeasurement();
            }
        });

        // Setup the Spinner (dropdown menu) using an array defined in resources
        ArrayAdapter<CharSequence> adapter = ArrayAdapter.createFromResource(this,
                R.array.meritve_array, R.layout.spinner_item);
        adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
        spinnerMeritev.setAdapter(adapter);

        // UI UX logic: Clear previous results when a new body part is selected
        spinnerMeritev.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
            @Override
            public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
                if (!isMeasuring) tvMeasurementResult.setText("");
            }
            @Override
            public void onNothingSelected(AdapterView<?> parent) {}
        });

        // Initialize state and Bluetooth hardware check
        resetUIState();
        initBluetooth();
    }

    /**
     * Resets the UI to an idle/disconnected state.
     * Uses runOnUiThread because Bluetooth callbacks arrive on background threads.
     */
    private void resetUIState() {
        runOnUiThread(() -> {
            isMeasuring = false;
            measurementLoader.setVisibility(View.GONE);
            btnAction.setText("Start\nMeasurement");
            // Only allow starting a measurement if GATT is connected and characteristics discovered
            btnAction.setEnabled(bluetoothGatt != null);
            btnAction.setAlpha(bluetoothGatt != null ? 1.0f : 0.5f);
        });
    }

    /**
     * Sends the appropriate command code to the stethoscope via BLE.
     * @param position Index of the measurement type selected in the dropdown.
     */
    private void startMeasurement(int position) {
        if (bluetoothGatt == null || rxChar == null) return;

        // Map selection index to firmware-defined shorthand codes
        String code;
        switch (position) {
            case 1: code = "SL"; break; // Heart - Sinister (Left)
            case 2: code = "SD"; break; // Heart - Dexter (Right)
            case 3: code = "SAV"; break; // Heart - Aortic Valve
            case 4: code = "SPV"; break; // Heart - Pulmonary Valve
            case 5: code = "STV"; break; // Heart - Tricuspid Valve
            case 6: code = "ZL"; break; // Lungs - Left
            case 7: code = "ZD"; break; // Lungs - Right
            case 8: code = "SAV_T_OK"; break; // Test measurement mode (Success)
            case 9: code = "SAV_T_NOK"; break; // Test measurement mode (Fail)
            default: return;
        }

        isMeasuring = true;
        tvMeasurementResult.setText("Performing measurement...");
        tvMeasurementResult.setTextColor(ContextCompat.getColor(this, R.color.blue_primary));
        measurementLoader.setVisibility(View.VISIBLE);
        btnAction.setText("Cancel");

        // Clear the buffer before initiating a new data flow
        bleBuffer.setLength(0);
        sendBleMessage(code);
    }

    /**
     * Aborts the measurement session by sending a "CANCEL" command to the peripheral.
     */
    private void cancelMeasurement() {
        sendBleMessage("CANCEL");
        resetUIState();
        tvMeasurementResult.setText("Measurement canceled");
        tvMeasurementResult.setTextColor(ContextCompat.getColor(this, R.color.status_disconnected));
    }

    /**
     * Helper to write a UTF-8 string to the RX characteristic of the BLE peripheral.
     */
    private void sendBleMessage(String msg) {
        if (rxChar == null || bluetoothGatt == null) return;
        rxChar.setValue(msg.getBytes());
        if (ActivityCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED) {
            bluetoothGatt.writeCharacteristic(rxChar);
            Log.i(TAG, "Sent UART Command: " + msg);
        }
    }

    /**
     * BluetoothGattCallback: The asynchronous engine for BLE events.
     * All connection updates, service discovery results, and incoming data arrive here.
     */
    private final BluetoothGattCallback gattCallback = new BluetoothGattCallback() {

        /**
         * Triggered when the device connects or disconnects.
         */
        @Override
        public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
            if (newState == BluetoothGatt.STATE_CONNECTED) {
                updateStatusUI("Connected", true);
                // Step 2: Connection is physical; now we must discover the logical services
                if (ActivityCompat.checkSelfPermission(MainActivity.this, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED) {
                    gatt.discoverServices();
                }
            } else if (newState == BluetoothGatt.STATE_DISCONNECTED) {
                updateStatusUI("Disconnected", false);
                rxChar = null;
                resetUIState();
                if (bluetoothGatt != null) {
                    // Close the GATT connection to free up the Bluetooth hardware resources
                    bluetoothGatt.close();
                    bluetoothGatt = null;
                }
            }
        }

        /**
         * Triggered when the service discovery (gatt.discoverServices()) completes.
         */
        @Override
        public void onServicesDiscovered(BluetoothGatt gatt, int status) {
            if (status == BluetoothGatt.GATT_SUCCESS) {
                // Look for the specific UART service
                BluetoothGattService service = gatt.getService(UART_SERVICE_UUID);
                if (service != null) {
                    // Find the characteristics inside the service
                    rxChar = service.getCharacteristic(RX_CHAR_UUID);
                    BluetoothGattCharacteristic txChar = service.getCharacteristic(TX_CHAR_UUID);

                    if (txChar != null && ActivityCompat.checkSelfPermission(MainActivity.this, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED) {
                        // 1. Enable local notifications for this characteristic
                        gatt.setCharacteristicNotification(txChar, true);

                        // 2. Write to the CCCD descriptor to tell the peripheral to start sending updates
                        BluetoothGattDescriptor desc = txChar.getDescriptor(CCCD_UUID);
                        if (desc != null) {
                            desc.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
                            gatt.writeDescriptor(desc);
                        }
                    }
                }
            }
        }

        /**
         * Triggered whenever the Pico W peripheral "notifies" the app with new data.
         */
        @Override
        public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
            if (TX_CHAR_UUID.equals(characteristic.getUuid())) {
                String data = characteristic.getStringValue(0);
                if (data == null) return;

                // Accumulate data chunks into our buffer
                bleBuffer.append(data);

                // Detection: If data contains newline or carriage return, the message is complete
                if (data.contains("\n") || data.contains("\r")) {
                    final String response = bleBuffer.toString().trim();
                    bleBuffer.setLength(0); // Reset for next measurement session

                    // Ignore verification messages ("XIAO_OK") and show actual numeric results
                    if (!response.isEmpty() && !response.equals("XIAO_OK")) {
                        runOnUiThread(() -> {
                            isMeasuring = false;
                            measurementLoader.setVisibility(View.GONE);
                            btnAction.setText("Start\nMeasurement");
                            tvMeasurementResult.setText("Value: " + response);
                            tvMeasurementResult.setTextColor(Color.BLACK);
                        });
                    }
                }
            }
        }
    };

    /**
     * Updates the connection status indicator and UI button availability.
     */
    private void updateStatusUI(String message, boolean connected) {
        runOnUiThread(() -> {
            tvStatus.setText(message);
            tvStatus.setTextColor(connected ?
                ContextCompat.getColor(this, R.color.status_connected) :
                ContextCompat.getColor(this, R.color.status_disconnected));

            // Hide the rescan button when a connection is active
            btnRescan.setVisibility(connected ? View.GONE : View.VISIBLE);

            // Toggle accessibility of the measurement button
            btnAction.setEnabled(connected);
            btnAction.setAlpha(connected ? 1.0f : 0.5f);
        });
    }

    /**
     * Verifies Bluetooth availability and proceeds with scanning or permission requests.
     */
    private void initBluetooth() {
        BluetoothManager manager = (BluetoothManager) getSystemService(BLUETOOTH_SERVICE);
        if (manager != null) bluetoothAdapter = manager.getAdapter();

        // If Bluetooth is disabled, we cannot proceed with scanning
        if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) {
            requestPermissionsAndStart();
        } else {
            startScan();
        }
    }

    /**
     * Starts the Low Energy scan process to find the stethoscope device.
     */
    private void startScan() {
        if (scanning) return;

        bleScanner = bluetoothAdapter.getBluetoothLeScanner();

        // Guard against missing permissions
        if (bleScanner == null || ActivityCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) {
            requestPermissionsAndStart();
            return;
        }

        scanning = true;
        updateStatusUI("Searching for Stethoscope...", false);
        bleScanner.startScan(scanCallback);

        // Safety: Automatically stop the scan after a predefined timeout to conserve battery
        timeoutHandler.postDelayed(() -> { if (scanning) stopScan(); }, SCAN_TIMEOUT);
    }

    /**
     * Safely stops the ongoing BLE scan.
     */
    private void stopScan() {
        if (!scanning) return;
        scanning = false;
        if (ActivityCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED && bleScanner != null) {
            bleScanner.stopScan(scanCallback);
        }
    }

    /**
     * ScanCallback: Handles every device discovered during the scan.
     */
    private final ScanCallback scanCallback = new ScanCallback() {
        @Override
        public void onScanResult(int callbackType, ScanResult result) {
            BluetoothDevice device = result.getDevice();

            // Filter results by the specific target name "PicoW_STOP"
            if (ActivityCompat.checkSelfPermission(MainActivity.this, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED &&
                device.getName() != null && device.getName().equals(TARGET_DEVICE_NAME)) {

                Log.d(TAG, "Found target device! Stopping scan and connecting...");
                stopScan();
                // Initiate the GATT connection process
                bluetoothGatt = device.connectGatt(MainActivity.this, false, gattCallback);
            }
        }
    };

    /**
     * Dynamic Permission Request: Handles the complexity of Android versioning.
     * Android 12+ (API 31) introduced BLUETOOTH_SCAN/CONNECT permissions.
     * Older versions require ACCESS_FINE_LOCATION for discovery.
     */
    private void requestPermissionsAndStart() {
        String[] perms;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            perms = new String[]{Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT};
        } else {
            perms = new String[]{Manifest.permission.ACCESS_FINE_LOCATION};
        }
        ActivityCompat.requestPermissions(this, perms, PERM_REQ);
    }

    /**
     * Handles the user's response to the permission request dialog.
     */
    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        if (requestCode == PERM_REQ && grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
            startScan();
        } else {
            Toast.makeText(this, "Bluetooth permissions are essential for this app to work.", Toast.LENGTH_LONG).show();
        }
    }

    /**
     * Cleanup: Ensure connections are terminated when the Activity is closed.
     */
    @Override
    protected void onDestroy() {
        super.onDestroy();
        if (bluetoothGatt != null && ActivityCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED) {
            bluetoothGatt.disconnect();
            bluetoothGatt.close();
        }
        stopScan();
    }
}

[Android App] -> activity_main.xml

XML
Android XML user interface layout designed using a responsive ConstraintLayout architecture. It features a custom toolbar-style header with an overlapping floating Spinner dropdown menu for dynamic body region selection. The center console utilizes a layered FrameLayout that stacks an indeterminate ProgressBar animations directly over a circular AppCompatButton to provide active, non-blocking visual feedback during the 20-second stethoscope sampling window. The bottom layer is anchored with an elevated CardView enclosing a LinearLayout that serves as a dedicated real-time hardware status bar and manual Bluetooth rescan control dashboard.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/white"
    tools:context="com.example.stoparica.MainActivity">

    <!-- Header area -->
    <View
        android:id="@+id/header_bg"
        android:layout_width="0dp"
        android:layout_height="100dp"
        android:background="@color/blue_primary"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/tv_title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Stetoskop BT"
        android:textColor="@color/white"
        android:textSize="24sp"
        android:textStyle="bold"
        android:layout_marginTop="24dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <Spinner
        android:id="@+id/spinner_meritev"
        android:layout_width="0dp"
        android:layout_height="56dp"
        android:layout_marginHorizontal="24dp"
        android:layout_marginTop="-28dp"
        android:background="@drawable/spinner_bg"
        android:elevation="4dp"
        android:paddingHorizontal="12dp"
        android:spinnerMode="dropdown"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/header_bg" />

    <!-- Gumb in Vrteča obroba -->
    <FrameLayout
        android:id="@+id/button_container"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toTopOf="@+id/tv_measurement_result"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/spinner_meritev"
        app:layout_constraintVertical_chainStyle="packed">

        <ProgressBar
            android:id="@+id/measurement_loader"
            android:layout_width="240dp"
            android:layout_height="240dp"
            android:layout_gravity="center"
            android:indeterminate="true"
            android:visibility="gone"
            android:indeterminateDrawable="@drawable/spinning_border" />

        <androidx.appcompat.widget.AppCompatButton
            android:id="@+id/btn_start_measurement"
            android:layout_width="220dp"
            android:layout_height="220dp"
            android:layout_gravity="center"
            android:text="Prični\nmeritev"
            android:textColor="@color/white"
            android:textSize="20sp"
            android:textStyle="bold"
            android:textAllCaps="true"
            android:gravity="center"
            android:background="@drawable/round_button_bg"
            android:elevation="8dp" />
    </FrameLayout>

    <TextView
        android:id="@+id/tv_measurement_result"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="32dp"
        android:layout_marginHorizontal="24dp"
        android:text=""
        android:textColor="@android:color/black"
        android:textSize="24sp"
        android:textStyle="bold"
        android:gravity="center"
        app:layout_constraintBottom_toTopOf="@+id/layout_status"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/button_container" />

    <!-- Statusna vrstica na dnu -->
    <androidx.cardview.widget.CardView
        android:id="@+id/layout_status"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        app:cardCornerRadius="12dp"
        app:cardElevation="4dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal"
            android:padding="16dp"
            android:gravity="center_vertical"
            android:background="@color/blue_light">

            <TextView
                android:id="@+id/tv_status"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:text="Povezava ni vzpostavljena"
                android:textColor="@android:color/black"
                android:textStyle="bold"
                android:textSize="14sp" />

            <androidx.appcompat.widget.AppCompatButton
                android:id="@+id/btn_ponovno_iskanje"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Išči"
                android:background="@color/blue_primary"
                android:textColor="@color/white"
                android:textSize="12sp"
                android:minHeight="0dp"
                android:minWidth="0dp"
                android:paddingHorizontal="16dp"
                android:paddingVertical="8dp" />
        </LinearLayout>
    </androidx.cardview.widget.CardView>

</androidx.constraintlayout.widget.ConstraintLayout>

[Android App] -> AndroidManifest.xml

XML
Essential configuration manifest file (AndroidManifest.xml) for the Android application. It defines the core component mapping, application meta-data, and implements a backward-compatible dual-tier Bluetooth security architecture. It explicitly manages legacy hardware access parameters for Android 11 and older (utilizing scoped maxSdkVersion="30" for fine location and administrative filters), alongside highly granular modern Android 12+ (API 31+) permissions. This includes the BLUETOOTH_SCAN configuration enhanced with the neverForLocation flag to optimize user privacy, and strict hardware enforcement via uses-feature declarations to target only physical devices containing active Bluetooth Low Energy (BLE) modules.
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <!-- 1. Legacy Bluetooth Permissions: Necessary for devices running Android 11 (API 30) or older.
         On these versions, these permissions were sufficient for basic Bluetooth operations. -->
    <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />

    <!-- 2. Modern Bluetooth Permissions (Android 12+ / API 31+):
         With Android 12, Google introduced more granular permissions to enhance user privacy. -->

    <!-- BLUETOOTH_SCAN: Allows the app to search for nearby Bluetooth Low Energy (BLE) devices.
         The 'neverForLocation' flag indicates that the app does not use BLE scan results to
         derive the user's physical location, allowing it to scan without requiring GPS to be enabled. -->
    <uses-permission
        android:name="android.permission.BLUETOOTH_SCAN"
        android:usesPermissionFlags="neverForLocation"
        tools:targetApi="s" />

    <!-- BLUETOOTH_CONNECT: Allows the app to establish a connection and exchange data
         with the discovered peripheral device (in our case, the Pico W stethoscope). -->
    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

    <!-- 3. Location Permission: This is a legacy requirement for BLE discovery on Android 11 and below.
         Starting from Android 12, this is no longer required for BLE if the 'neverForLocation' flag is used. -->
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />

    <!-- Mandatory Feature Declaration:
         This ensures the app is only discoverable on the Google Play Store for devices
         that physically support Bluetooth Low Energy hardware. -->
    <uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.Stetoskop">

        <!-- The main entry point for the application, handling UI and BLE logic. -->
        <activity
            android:name=".MainActivity"
            android:exported="true"
            tools:ignore="MissingClass">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

    </application>

</manifest>

Credits

Janez Ovsec
1 project • 0 followers
Luka Mali
27 projects • 32 followers
Maker Pro, prototyping enthusiast, head of MakerLab, a lecturer at the University of Ljubljana, founder.
Adamič Matej
1 project • 0 followers
BLAŽKA JANŽIČ
1 project • 0 followers

Comments