Community project

Med Matrix Sentinel Wearable

ESP32
Photo of Med Matrix Sentinel Wearable
Generated with AI

daksh pareek

Last updated September 6, 2026

The Med Matrix Sentinel is a wearable health monitoring device built around an ESP32 that tracks vital signs, environmental conditions, and motion to detect potential health emergencies. It combines a pulse oximetry sensor, temperature monitoring, air quality detection, and accelerometer-based fall detection into a compact wrist-worn form factor that communicates via Bluetooth Low Energy.

This guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions for building the Sentinel from scratch. The firmware handles sensor multiplexing across multiple I2C devices, processes heart rate and blood oxygen data, evaluates risk conditions, and triggers haptic feedback alerts or emergency notifications through an SOS button interface.

Wiring diagram

Wiring diagram for Med Matrix Sentinel Wearable

Gather all the parts

QtyComponent
1

MAX30102

MAX30102

High-sensitivity pulse oximeter and heart-rate sensor by Analog Devices (Maxim). Measures SpO2 and heart rate via PPG (photoplethysmography) using integrated red and IR LEDs with photodetector. Communicates over I2C at fixed 7-bit address 0x57. The IC uses a 1.8V core rail and separate LED supply; typical maker breakout modules regulate from a 3.3V input and provide suitable I2C pull-ups. Place sensor directly against skin for accurate PPG readings.

1

Haptic Driver

DRV2605L

DRV2605L haptic motor driver breakout for ERM/LRA vibration motors. Controlled over I2C at 0x5A, uses an internal waveform library, and drives the motor through OUT+/OUT- instead of direct MCU GPIO.

1

8 mm coin vibration motor

8 mm ERM coin motor

The thin round motor that can be felt through the wrist strap during warnings and SOS.

1

Push Button

momentary push button

Momentary push button switch

1

ADXL345 Accelerometer

ADXL345

Digital ADXL345 triple-axis accelerometer breakout with I2C/SPI interface, STEMMA QT/Qwiic connectors, on-board 3.3 V regulator, and logic-level shifting for 3 V or 5 V microcontrollers.

1

TMP117 Temperature Sensor

TMP117

Texas Instruments TMP117 high-accuracy, low-power digital temperature sensor with I2C/SMBus interface. Common breakouts default to address 0x48 and are used when a precise calibrated temperature reading is more important than a cheap DHT-style reading.

1

TP4056 Li-Ion/LiPo charger module with protection

USB-C, protected

TP4056 single-cell Li-Ion/LiPo linear charger module, 5V USB input, 1A charge current (programmable). Common variants ship with DW01 protection. Pair with battery_lipo_storage for the cell.

1

3.3v Buck Boost

3.3 V, 1 A

TI TPS63030/TPS63031 high-efficiency single-inductor buck-boost converter family with 1A switches. Used to hold a regulated rail when battery voltage crosses above and below the target output.

1

Bosch BMV080

PM1 / PM2.5 / PM10

Bosch Sensortec BMV080 ultra-mini fanless particulate matter (PM1 / PM2.5 / PM10) air quality sensor. Communicates over I2C (default) or SPI, selectable via on-board jumper on the SparkFun breakout. 3.3V supply. World's smallest PM sensor; firmware integration relies on the Bosch BMV080 SDK plus the SparkFun BMV080 Arduino Library wrapper.

1

3.7 V 1500 mAh high-discharge LiPo battery

3.7 V, 1500 mAh, protected, ≥3 A continuous discharge

A flat rechargeable battery that supplies the wearable and can safely provide short cellular transmit bursts.

1

BME280

BME280 (I2C, 0x77)

Bosch BME280 environmental sensor on the exact Adafruit 2652 breakout. Supports I2C via SCK/SCL and SDI/SDA, optional SDO address select, and SPI pins when needed.

1

TCA9548A I2C Multiplexer Module

TCA9548A I2C switch

a small I2C switch that keeps the MAX30102 and particle sensor, which use the same address, from interfering with each other.

Assemble it in 5 steps

1. Place the watch parts in the case

Put the MAX30102 sensor through the opening on the back of the watch so its clear window rests gently against the wrist. Put the TMP117 beside it, away from the battery. Place the BME280 and BMV080 behind separate mesh-covered side openings so outside air can reach them. Place the small TCA9548A switch inside the case near the sensor boards.

  • A soft black foam ring around the MAX30102 opening blocks stray light and helps pulse readings during movement.
  • Keep both air openings clear of glue, strap material, and clothing.
  • Do not make medical decisions from the optical readings; this prototype looks for unusual patterns and alerts.
  • Do not seal the BME280 or BMV080 inside the case, because trapped warm air gives incorrect local-air readings.

2. Build the safe battery path

Connect LiPo POSITIVE to TP4056 B+ (battery power) and LiPo NEGATIVE to TP4056 B− (battery ground). Connect TP4056 OUT+ to the 3.3 V regulator VIN (power), and TP4056 OUT− to regulator GND (ground). Use the regulator VOUT as the watch 3V3 rail and join all GND connections together.

  • Use the TP4056 USB-C socket when charging the battery.
  • Before attaching the watch electronics, measure the regulator output with a multimeter; it should be close to 3.3 V.
  • Do not connect the LiPo directly to the XIAO charging pads; use the TP4056 board so the battery is charged safely.
  • Stop using the battery if it becomes hot, swollen, punctured, or damaged; a damaged LiPo can catch fire.

3. Wire the shared I2C sensor wires

Connect TCA9548A VCC to 3V3 (power), GND to GND (ground), SDA to XIAO GPIO6 (data), and SCL to XIAO GPIO7 (clock). Connect TCA9548A SD0 to MAX30102 SDA and SC0 to MAX30102 SCL (separate sensor data and clock). Connect TCA9548A SD1 to BMV080 SDA and SC1 to BMV080 SCL (separate particle-sensor data and clock). Connect DRV2605L, ADXL345, TMP117, and BME280 SDA to GPIO6 and SCL to GPIO7 (shared data and clock). Connect each module power pin to 3V3 and each ground pin to GND.

  • Keep the GPIO6 and GPIO7 wires short and run them together.
  • Leave the MAX30102 INT pin unconnected for this prototype.
  • All sensors use 3.3 V only; a higher voltage can damage them.
  • The TCA9548A is required because MAX30102 and BMV080 use the same I2C address; bypassing it stops the watch from talking reliably to both sensors.

4. Wire the SOS button and vibration motor

Connect one SOS button leg to XIAO GPIO4 (signal) and the other leg to GND (ground). Connect the two coin-motor leads to DRV2605L OUT+ and OUT− (motor drive).

  • A two-second button hold requests help and makes the watch vibrate.
  • Either motor lead can go to either motor output because the vibration direction does not matter.
  • Do not connect the coin motor directly to a XIAO pin; the DRV2605L drives it safely.

5. Close and test the screenless watch

Leave the TP4056 USB-C socket reachable for charging, keep the BME280 and BMV080 mesh vents open, and close the case without squeezing battery wires. Wear the MAX30102 against the wrist, pair the watch with the nearby phone over Bluetooth, and test a two-second SOS-button hold.

  • The vibration motor is the only on-watch alert; there is no display.
  • The paired phone must be nearby and connected to mobile data or Wi-Fi to forward an SOS to someone remotely.
  • Without GSM, the watch cannot independently send a remote emergency message; without a nearby paired phone it can only vibrate locally.
  • Do not wear or charge the open prototype in rain or near flammable vapors.

Review all connections

1. Connections between "lipo_battery" and "ESP32"

Functionlipo_batteryESP32
powerPOSITIVETP4056 Li-Ion/LiPo charger module with protection B+EXT
groundNEGATIVETP4056 Li-Ion/LiPo charger module with protection B-EXT

2. Connections between "lipo_charger" and "ESP32"

Functionlipo_chargerESP32
powerIN+USB-C 5 V charging sourceEXT
groundIN-GND
powerOUT+3.3v Buck Boost VINEXT
groundOUT-GND

3. Connections between "logic_regulator_3v3" and "ESP32"

Functionlogic_regulator_3v3ESP32
groundGNDGND
powerVOUT3V3

4. Connections between "ppg_max30102" and "ESP32"

Functionppg_max30102ESP32
powerVCC3V3
groundGNDGND

5. Connections between "haptic_drv2605l" and "ESP32"

Functionhaptic_drv2605lESP32
powerVIN3V3
groundGNDGND
i2cSDAGPIO 6
i2cSCLGPIO 7
dataOUT+8 mm coin vibration motor M+EXT
dataOUT-8 mm coin vibration motor M-EXT

6. Connections between "imu_adxl345" and "ESP32"

Functionimu_adxl345ESP32
powerVCC3V3
groundGNDGND
i2cSDAGPIO 6
i2cSCLGPIO 7

7. Connections between "temp_tmp117" and "ESP32"

Functiontemp_tmp117ESP32
powerVIN3V3
groundGNDGND
i2cSDAGPIO 6
i2cSCLGPIO 7

8. Connections between "pm_bmv080" and "ESP32"

Functionpm_bmv080ESP32
powerVDD3V3
groundGNDGND

9. Connections between "sos_button" and "ESP32"

Functionsos_buttonESP32
groundGNDGND
digitalSIGNALGPIO 4

10. Connections between "env_bme280" and "ESP32"

Functionenv_bme280ESP32
powerVCC3V3
groundGNDGND
i2cSDAGPIO 6
i2cSCLGPIO 7

11. Connections between "i2c_mux" and "ESP32"

Functioni2c_muxESP32
powerVCC3V3
groundGNDGND
i2cSDAGPIO 6
i2cSCLGPIO 7
i2cSD0MAX30102 SDAEXT
i2cSC0MAX30102 SCLEXT
i2cSD1Bosch BMV080 SDAEXT
i2cSC1Bosch BMV080 SCLEXT

Deploy the firmware

#include <Arduino.h>
#include <Wire.h>
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#include <Adafruit_TCA9548A.h>
#include <Adafruit_DRV2605.h>
#include <Adafruit_ADXL345_U.h>
#include <Adafruit_TMP117.h>
#include <Adafruit_BME280.h>
#include <SparkFun_BMV080_Arduino_Library.h>
#include <MAX30105.h>
#include "heartRate.h"


// Forward declarations
void selectMuxChannel(uint8_t channel);
int pm25ToAqi(float p);
void buzz(uint8_t effect);
void publish();
void setRisk(const String &nextRisk);
void emergency(const char *reason);
void sampleVitalsAndMotion();
void sampleAir();
void evaluate();
void setupBle();

constexpr int I2C_SDA = 6;
constexpr int I2C_SCL = 7;
constexpr int SOS_BUTTON_PIN = 4;
constexpr uint8_t PPG_CHANNEL = 0;
constexpr uint8_t PM_CHANNEL = 1;
constexpr uint8_t BMV080_ADDR = 0x57;
constexpr uint8_t BME280_ADDR = 0x77;
constexpr uint32_t SAMPLE_MS = 25;
constexpr uint32_t AIR_MS = 1000;
constexpr uint32_t SOS_HOLD_MS = 2000;
constexpr float FALL_G = 2.35f;
constexpr float HIGH_SKIN_C = 38.0f;
constexpr float HIGH_AMBIENT_C = 40.0f;
constexpr uint32_t FINGER_IR_MIN = 50000;
const char BLE_SERVICE_UUID[] = "6b5b1c4d-6b86-4d4b-8b19-5f1c3a020001";
const char BLE_STATUS_UUID[] = "6b5b1c4d-6b86-4d4b-8b19-5f1c3a020002";

Adafruit_TCA9548A i2cMux;
Adafruit_DRV2605 haptic;
Adafruit_ADXL345_Unified accel(12345);
Adafruit_TMP117 tmp117;
Adafruit_BME280 bme;
SparkFunBMV080 bmv080;
MAX30105 ppg;
BLECharacteristic *statusChar = nullptr;
bool muxOK = false;
bool hapticOK = false;
bool accelOK = false;
bool tmpOK = false;
bool bmeOK = false;
bool pmOK = false;
bool ppgOK = false;
bool sos = false;
bool alertSent = false;
uint32_t buttonAt = 0;
uint32_t lastSample = 0;
uint32_t lastAir = 0;
uint32_t lastBeat = 0;
float hr = 0;
float spo2 = 0;
float skinC = 0;
float ambientC = 0;
float humidity = 0;
float pm25 = 0;
float motionG = 1;
int aqi = -1;
String risk = "STARTING";
String alertReason = "";

void selectMuxChannel(uint8_t channel) {
  if (!muxOK) return;
  i2cMux.closeAll();
  i2cMux.openChannel(channel);
}

int pm25ToAqi(float p) {
  if (p < 0) return -1;
  struct Band { float low; float high; int aqiLow; int aqiHigh; };
  const Band bands[] = {{0, 12, 0, 50}, {12.1f, 35.4f, 51, 100}, {35.5f, 55.4f, 101, 150}, {55.5f, 150.4f, 151, 200}, {150.5f, 250.4f, 201, 300}, {250.5f, 350.4f, 301, 400}, {350.5f, 500.4f, 401, 500}};
  p = floorf(p * 10.0f) / 10.0f;
  if (p > 500.4f) return 500;
  for (const auto &band : bands) {
    if (p >= band.low && p <= band.high) {
      return round(((float)(band.aqiHigh - band.aqiLow) / (band.high - band.low)) * (p - band.low) + band.aqiLow);
    }
  }
  return -1;
}

void buzz(uint8_t effect) {
  if (!hapticOK) return;
  haptic.setWaveform(0, effect);
  haptic.setWaveform(1, 0);
  haptic.go();
}

void publish() {
  if (!statusChar) return;
  String message = "state=" + risk + ",reason=" + alertReason + ",hr=" + String(hr, 0) + ",spo2=" + String(spo2, 0) + ",skinC=" + String(skinC, 1) + ",ambientC=" + String(ambientC, 1) + ",humidity=" + String(humidity, 0) + ",pm25=" + String(pm25, 1) + ",aqi=" + String(aqi);
  statusChar->setValue(message.c_str());
  statusChar->notify();
}

void setRisk(const String &nextRisk) {
  if (nextRisk == risk) return;
  risk = nextRisk;
  if (risk == "SOS") buzz(82);
  else if (risk == "ALERT") buzz(47);
  publish();
}

void emergency(const char *reason) {
  if (alertSent) return;
  alertSent = true;
  alertReason = reason;
  setRisk("SOS");
}

void sampleVitalsAndMotion() {
  if (ppgOK) {
    selectMuxChannel(PPG_CHANNEL);
    ppg.check();
    while (ppg.available()) {
      uint32_t ir = ppg.getIR();
      if (ir > FINGER_IR_MIN && checkForBeat(ir)) {
        uint32_t now = millis();
        if (lastBeat != 0) {
          float bpm = 60.0f / ((now - lastBeat) / 1000.0f);
          if (bpm >= 35.0f && bpm <= 220.0f) hr = (hr == 0) ? bpm : (0.8f * hr + 0.2f * bpm);
        }
        lastBeat = now;
      }
      if (ir <= FINGER_IR_MIN) {
        hr = 0;
        spo2 = 0;
        lastBeat = 0;
      }
      ppg.nextSample();
    }
  }
  if (accelOK) {
    sensors_event_t event;
    accel.getEvent(&event);
    motionG = sqrtf(event.acceleration.x * event.acceleration.x + event.acceleration.y * event.acceleration.y + event.acceleration.z * event.acceleration.z) / SENSORS_GRAVITY_STANDARD;
    if (motionG >= FALL_G) emergency("possible fall");
  }
  if (tmpOK) {
    sensors_event_t event;
    tmp117.getEvent(&event);
    if (event.temperature > 15 && event.temperature < 45) skinC = event.temperature;
  }
}

void sampleAir() {
  if (millis() - lastAir < AIR_MS) return;
  lastAir = millis();
  if (bmeOK) {
    float nextAmbientC = bme.readTemperature();
    float nextHumidity = bme.readHumidity();
    if (!isnan(nextAmbientC) && nextAmbientC > -20 && nextAmbientC < 70) ambientC = nextAmbientC;
    if (!isnan(nextHumidity) && nextHumidity >= 0 && nextHumidity <= 100) humidity = nextHumidity;
  }
  if (pmOK) {
    selectMuxChannel(PM_CHANNEL);
    if (bmv080.readSensor()) {
      pm25 = bmv080.PM25();
      aqi = pm25ToAqi(pm25);
    }
  }
  publish();
}

void evaluate() {
  if (sos || alertSent) return;
  bool risky = hr >= 130 || (hr >= 35 && hr <= 40) || skinC >= HIGH_SKIN_C || ambientC >= HIGH_AMBIENT_C || aqi >= 151;
  alertReason = risky ? "health, heat, or air-quality warning" : "";
  setRisk(risky ? "ALERT" : "OK");
}

void setupBle() {
  BLEDevice::init("MedMatrix-Sentinel");
  BLEServer *server = BLEDevice::createServer();
  BLEService *service = server->createService(BLE_SERVICE_UUID);
  statusChar = service->createCharacteristic(BLE_STATUS_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY);
  statusChar->addDescriptor(new BLE2902());
  service->start();
  BLEAdvertising *advertising = BLEDevice::getAdvertising();
  advertising->addServiceUUID(BLE_SERVICE_UUID);
  advertising->start();
}

void setup() {
  Serial.begin(115200);
  pinMode(SOS_BUTTON_PIN, INPUT_PULLUP);
  Wire.begin(I2C_SDA, I2C_SCL);
  Wire.setClock(400000);
  muxOK = i2cMux.begin(0x70, &Wire);
  if (muxOK) {
    selectMuxChannel(PPG_CHANNEL);
    ppgOK = ppg.begin(Wire, I2C_SPEED_FAST);
    if (ppgOK) ppg.setup(0x1F, 4, 2, 100, 411, 4096);
    selectMuxChannel(PM_CHANNEL);
    pmOK = bmv080.begin(BMV080_ADDR, Wire);
    if (pmOK) {
      bmv080.init();
      pmOK = bmv080.setMode(SF_BMV080_MODE_CONTINUOUS);
    }
    i2cMux.closeAll();
  }
  hapticOK = haptic.begin();
  if (hapticOK) haptic.selectLibrary(1);
  accelOK = accel.begin();
  if (accelOK) accel.setRange(ADXL345_RANGE_16_G);
  tmpOK = tmp117.begin();
  bmeOK = bme.begin(BME280_ADDR, &Wire);
  setupBle();
  setRisk("OK");
  buzz(1);
}

void loop() {
  uint32_t now = millis();
  if (digitalRead(SOS_BUTTON_PIN) == LOW) {
    if (!buttonAt) buttonAt = now;
    if (!sos && now - buttonAt >= SOS_HOLD_MS) {
      sos = true;
      emergency("SOS button held");
    }
  } else {
    buttonAt = 0;
    sos = false;
  }
  if (now - lastSample >= SAMPLE_MS) {
    lastSample = now;
    sampleVitalsAndMotion();
    sampleAir();
    evaluate();
  }
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik