Hardware components | ||||||
![]() |
| × | 1 | |||
| × | 1 | ||||
| × | 1 | ||||
Software apps and online services | ||||||
![]() |
| |||||
![]() |
| |||||
![]() |
| |||||
| ||||||
![]() |
| |||||
| ||||||
Hand tools and fabrication machines | ||||||
![]() |
| |||||
Our student project started with a question: Can we hear and analyze heart and lung sounds using a cheap stethoscope and an affordable microcontroller? We didn't aim to build a certified medical device. We just wanted to see if a basic DIY setup could capture body sounds well enough for a machine learning model to recognize them.
The HardwareTo capture the acoustic signals, we bought a standard mechanical stethoscope for just 5 Euros. We cut off the metal earpieces and coupled the remaining rubber tube directly onto the XIAO nRF52840 Sense module, positioning it right over the tiny onboard digital microphone hole. We secured it with a custom enclosure to guide the sound waves straight to the sensor.
We literally cut off the metal earpieces, took the remaining rubber tube, and positioned it as close as humanly possible to the tiny microphone hole on the XIAO module. We sealed it tightly to ensure the acoustic signal from the chest piece traveled straight to the electronic sensor.
Why is there a larger board in our photos?In our project photos, you see the XIAO module plugged into a larger board. This is the NatureGuard IoT board, a development platform designed by the Faculty of Electrical Engineering, University of Ljubljana (UL FE) for teaching basic IoT concepts. It adds features like LoRaWAN and an SD card slot.
Note for the community: We only used the core capabilities of the XIAO nRF52840 Sense (the mic and Bluetooth). You do not need the full NatureGuard board to replicate this project; a standalone XIAO module works exactly the same way!The Datasets
Instead of building complex custom neural networks, our main focus was to set up a clean, direct dataset and see what results Edge Impulse could give us out of the box. And since we couldn't record clean medical data with our DIY setup right away, we trained our models using two biomedical datasets:
- Heart Sounds: The CirCor DigiScope Phonocardiogram Dataset (PhysioNet). https://physionet.org/content/circor-heart-sound/1.0.3/training_data/#files-panel
- Lung Sounds: The ICBHI 2017 Challenge Respiratory Sound Database. https://www.kaggle.com/datasets/nimalanparameshwaran/icbhi-2017-challenge-respiratory-sound-database
We manually tagged the files and quickly noticed that raw medical datasets are heavily unbalanced. To ensure the AI wouldn't just guess one class all the time, we trimmed the data. We removed excess files until we had a clean 50/50 balance —roughly 50% of recordings represented healthy subjects, and 50% represented subjects with anomalies/problems.
Training and Test Data SplitTo properly evaluate if our model was actually learning or just memorizing, we used a standard 80/20 train/test split. This means Edge Impulse used 80% of our balanced dataset to train the model, while keeping 20% completely separate as "Test data" to validate the accuracy afterwards.
The BIG Issues: Dataset Size & Heavy Sample OverlappingEven after successfully training the model, we faced two major bottlenecks in the software phase that limited our real-world accuracy:
- Heavy Sample Overlapping: Because the original recordings contain continuous audio where anomalies happen in short bursts, slicing them into windows created samples that were too similar to one another.
- Limited Dataset Duration: After strictly balancing the classes and removing the excess unbalanced data, we were left with only 22 minutes of total audio recordings for the model to learn from. This lack of data variety made it difficult for the model to generalize well when exposed to real-world, live acoustic signals.
Once the training was complete, we went straight to the Deployment tab, selected Arduino Library, and downloaded the compiled code as a ZIP folder.
This gave us our models—a fixed library containing all the weights from the training session. We imported this ZIP directly into the Arduino IDE so that the XIAO microcontroller could run the inference locally and completely offline.
Why It Didn't Work in Real LifeWhile our digital IoT pipeline worked —the XIAO microcontroller successfully sampled audio, ran the local TinyML library, and was ready to push data via Bluetooth—the actual real-world testing failed to deliver accurate diagnostic results.
When we placed our stethoscope on a human chest, the model could not reliably detect a heartbeat or breathing pattern. This failure came down to three major real-world factors:
- The Overwhelming Acoustic Noise: Professional digital stethoscopes are highly engineered devices. They use specialized acoustic chambers, multi-stage bandpass filters to isolate body frequencies (20 - 2000 Hz), and active noise cancellation. Our 5-Euro rubber tube had none of that. It acted like an open straw that captured everything: ambient room reflections, hand tremors, and—worst of all—the massive friction noise of the rubber tube rubbing against skin.
- Microcontroller Microphone Limitations: The onboard digital microphone on the XIAO nRF52840 Sense is not designed for delicate auscultation. Without an external analog amplifier and proper acoustic shielding, the microphone couldn't capture the faint, internal sounds of the human body over the background noise.
- The Software Bottleneck (Small Dataset & Overlapping): As mentioned earlier, having only 22 minutes of total training data meant our model lacked the depth to understand variance. Combined with the heavy sample overlapping from the source datasets, the neural network was essentially "blind" when exposed to a noisy, chaotic real-world environment. The signal-to-noise ratio was simply too poor for our model to find the signal.
Even though we didn't build a working medical diagnostic tool, this university project was an incredible and valuable engineering journey. It taught us several critical lessons about EdgeAI and IoT development:
- The "Garbage In, Garbage Out" Rule: This is the golden rule of machine learning. No matter how much you clean your dataset or how optimized your model is in Edge Impulse, an AI cannot extract meaningful insights from a destroyed, noisy analog signal. Good hardware and physical acoustics must come first.
- Full-Stack IoT Understanding: We successfully used the entire pipeline. We learned how to source and balance public biomedical data, train a TinyML model, deploy it locally on constrained hardware, and architect a Bluetooth Low Energy communication system.
- The Value of Prototyping: A 5-Euro stethoscope and a microcontroller are perfect for a low-cost educational experiment. It showed us exactly where the boundaries between simple "maker" hacks and professional medical engineering lie.
[XIAO Firmware] -> Main_Stethoscope_Inference.ino
C/C++#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
Javapackage 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<?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<?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>










_t9PF3orMPd.png?auto=compress%2Cformat&w=40&h=40&fit=fillmax&bg=fff&dpr=2)



Comments