Community project

UV Alert Wristband Logger

ESP32
Photo of UV Alert Wristband Logger
Generated with AI

Era Lazaga

Published October 30, 2026

This wristband logger continuously monitors ultraviolet radiation and logs exposure data to a microSD card, alerting the wearer when UV intensity reaches unsafe levels. Built around an ESP32 microcontroller, the device pairs an LTR390 UV sensor with a real-time clock, OLED display, and onboard data storage to create a portable UV tracking system that runs for hours on a small LiPo battery.

The guide provides a complete wiring diagram, full parts list with sourcing notes, and step-by-step assembly instructions for soldering the sensor breakouts, display, microSD reader, and alert components onto perfboard. Included firmware handles sensor calibration, SD logging, real-time display updates, and multi-level UV alerts via buzzer and NeoPixel indicator, with all configuration parameters exposed for customization.

Wiring diagram

Loading diagram…

Gather all the parts

QtyComponent
1

Adafruit LTR390 UV + Ambient-Light Sensor Breakout

A small light sensor board that measures UV-sensitive light and visible ambient light.

1

SSD1306 OLED

0.96 inch, 128x64, I2C, white

0.96 inch 128x64 OLED display with I2C interface

1

3.7 V 350 mAh LiPo Battery

A small rechargeable pouch battery that powers the wristband; use a single-cell pack and connect it only through the charger module.

1

TP4056 Li-Ion/LiPo charger module with protection

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

Breadboard-friendly SPDT Slide Switch

Compact through-hole SPDT slide switch with three pins for direct breadboard insertion.

1

TPS63020 Adjustable Buck-Boost Converter Module

A battery-powered converter module set to provide regulated 5 V for the board input and NeoPixel logic.

1

Piezo Buzzer

Small passive piezo buzzer (TDK PS1240) for beeps, tones and alerts. It has no built-in oscillator, so drive one lead with a 3-30 V square wave from a microcontroller pin using tone() or PWM and connect the other lead to ground. It is loudest around 4 kHz and works from about 2 to 10 kHz; driving both leads in antiphase roughly doubles the volume.

1

DS3231 Real-Time Clock Module (3.3 V I2C)

A real-time clock board that keeps date and time and has a coin-cell backup holder for use while the wristband is off.

1

CR1220 3 V Coin Cell

A small backup cell that keeps the clock module's time while the wristband is switched off.

1

3.3 V microSD SPI Breakout with Card Socket

A microSD reader module with a card socket and 3.3 V-only power and signal pins.

1

microSDHC Card, 8 GB, FAT32

A small removable memory card that stores timestamped UV readings in the reader module.

1

Small isolated-pad prototype perfboard

A small soldering board that holds the modules and short connecting wires inside the wristband.

1

28–30 AWG insulated hookup wire

Thin insulated wire used for the short low-voltage connections between the modules.

1

Wristband or wearable enclosure

A wearable strap or small enclosure that secures the electronics without pressing bare metal against skin.

1

10 kΩ TP4056 Charge-Current Resistor

10 kΩ; replaces TP4056 RPROG

This small resistor lowers the charger’s current for the 350 mAh LiPo battery.

1

3 V Coin Vibration Motor, 10 mm, 70 mA

A small coin motor that gives a silent physical alert when ultraviolet exposure is high.

1

NPN Transistor (2N2222 / BC547)

General-purpose NPN bipolar junction transistor (BJT) in TO-92 package. Used as a low-side switch to drive loads (e.g. buzzers) whose current exceeds ESP32 GPIO source capability (~12 mA). A ~1 kΩ base resistor is placed between the ESP32 GPIO and the transistor base. The collector connects to the load negative terminal (buzzer –), and the emitter connects to GND. When the GPIO goes HIGH (3.3 V), the transistor saturates and switches the load on. Compatible with both 2N2222/2N2222A and BC547/BC547B variants. Add a flyback diode (e.g. 1N4148) across inductive load terminals.

1

1N4148 Signal Diode

Fast small-signal switching diode in DO-35 glass package. Forward voltage ~0.6–0.7 V at low current, reverse breakdown 75 V, max forward current 300 mA, reverse recovery time 4 ns. Used here in an anti-parallel pair across the TL072 op-amp feedback loop (D1: anode→OPAMP_OUT, cathode→OPAMP_IN_NEG; D2: anode→OPAMP_IN_NEG, cathode→OPAMP_OUT) to provide soft audio clipping.

1

330 Ω Series Resistor

330 Ω

A resistor that softens the NeoPixel data signal and helps protect the LED input.

1

330 Ω Motor-Driver Base Resistor

330 Ω

A resistor that limits current from the ESP32 GPIO into the motor-switching transistor.

1

100 nF Ceramic Bypass Capacitor

100 nF

A small capacitor that steadies the 74AHCT125 supply right at its power pins.

1

1000 µF, 10 V Electrolytic Capacitor

1000 µF, 10 V

A polarized capacitor that reduces short current dips on the NeoPixel 5 V supply.

1

74AHCT125 Quad Buffer / Level Shifter

Quad non-inverting buffer/line driver with 3-state outputs and active-low output-enable pins. In 5 V AHCT/HCT designs, 3.3 V MCU outputs are high enough for the TTL-level inputs, making it a common one-way 3.3 V to 5 V level shifter for WS2812/NeoPixel data and other fast digital lines.

1

Addressable Color Indicator Board

A small external signal board whose color changes to show the wristband's UV alert level.

Assemble it in 10 steps

1. Lay out the parts before soldering

Place the ESP32-C3 Super Mini and the small boards on the perfboard so the OLED faces outward and the LTR390 sensor can see daylight. Keep the battery pouch away from sharp perfboard edges, the motor away from the sensor, and the microSD socket accessible. Use red wire for positive power, black for ground, and other colors for signal wires.

  • Dry-fit parts and check the wristband still closes before soldering.
  • Do not bend, puncture, crush, or solder directly to the LiPo pouch; a damaged lithium battery can catch fire.

2. Wire the sensor, screen, and clock together

With the battery disconnected, connect the LTR390, OLED, and DS3231 to the same two signal wires: each SDA pin to GPIO0 (data), and each SCL pin to GPIO1 (clock). Connect LTR390 VIN, OLED VCC, and RTC VIN to the 3V3 rail (power); connect their GND pins to GND (ground).

  • The three devices share the two I2C wires; this is intentional because they use different addresses.
  • Keep these three modules on 3V3, not 5V; 5V on their signal pull-ups can damage the ESP32-C3.

3. Connect the microSD reader

Connect the card reader 3V pin to 3V3 (power), GND to GND (ground), CLK to GPIO4 (clock), MISO to GPIO5 (card-to-board data), MOSI to GPIO6 (board-to-card data), and CS to GPIO7 (card select). Slide the FAT32 microSD card into its socket until it clicks.

  • Keep these signal wires short; long loose wires can make the card unreliable.
  • This reader is 3.3 V only; do not connect its power or signal pins to 5V.

4. Install the 5 V data buffer and color indicator

Place the 74AHCT125N DIP-14 chip with its notch facing up. Connect chip pin 14 to 5V (chip power), pin 7 to GND (ground), pin 1 (/1OE) to GND (enable), pin 2 (1A) to GPIO10 (input data), and pin 3 (1Y) to one end of the pixel_data_resistor (buffered output). Connect the resistor’s other end to color_indicator DIN (color data). Connect color_indicator VCC to 5V (power) and GND to GND (ground). Tie unused inputs at pins 6, 9, and 12 to GND (quiet inputs); tie unused active-low enables at pins 4, 10, and 13 to 5V (disabled outputs). Leave the unused outputs at pins 5, 8, and 11 unconnected. Put logic_bypass_cap across chip pins 14 and 7 (supply smoothing), and connect pixel_bulk_cap positive to 5V and its striped negative lead to GND (LED supply smoothing).

  • Place the 100 nF capacitor close to chip pins 14 and 7. The 330 Ω resistor goes in series between chip pin 3 and the color indicator DIN pin.
  • Make sure the 74AHCT125N notch faces the correct way before soldering; reversed chip power can damage it. The 1000 µF capacitor’s striped side is negative and must go to GND.

5. Wire the vibration motor through its transistor

Connect vibration_motor M+ to 3V3 (motor power) and M- to motor_driver COLLECTOR (switched motor return). Connect motor_driver EMITTER to GND (ground). Connect GPIO20 to one end of motor_base_resistor and its other end to motor_driver BASE (limited control signal). Connect motor_flyback_diode CATHODE—the banded end—to vibration_motor M+ (motor positive), and ANODE to motor_driver COLLECTOR (motor return).

  • The motor should pulse together with the buzzer when the UV alert starts.
  • Never power the motor directly from GPIO20; the transistor carries the motor current. The diode band must face the motor’s positive lead or the circuit may short when switched on.

6. Connect the sounder

Connect buzzer_alert SIG to GPIO3 (tone signal) and its GND pin to GND (ground).

  • The passive piezo leads are not polarized; either lead may go to SIG or GND.
  • Do not connect the piezo signal pin to 5V; its drive signal comes from GPIO3.

7. Put the backup cell in the clock

Place the CR1220 in the RTC holder with its positive face matching the holder’s + mark. If your RTC board has separate BAT pads instead, connect the coin cell positive to BAT and negative to GND.

  • The coin cell keeps the RTC time while the wristband’s main switch is off.
  • Reversed coin-cell polarity can stop the clock or damage the RTC module.

8. Set up the charger for the small battery

Confirm the TP4056 board is the protected version. Replace its original RPROG resistor with charger_rprog (the 10 kΩ resistor) so the charge current is about 120 mA. Connect battery positive to TP4056 B+ (battery positive) and battery negative to B- (battery negative). Connect charger IN+ to a separate 5V charging input (charge positive) and IN- to GND (charge return).

  • The resistor replaces the tiny RPROG part on the charger board; it is not an extra resistor wired across the battery.
  • Do not charge this 350 mAh cell with the TP4056’s common 1 A setting; it can overheat or damage the battery. Stop using a swollen, hot, or punctured cell.

9. Connect the switch and set the converter output

Connect TP4056 OUT+ to switch_power COM (switched positive in), switch_power A to boost_5v VIN+ (battery power), and TP4056 OUT- to boost_5v VIN- (ground return); leave switch_power B unused. Before connecting the board, power the TPS63020 module and use a multimeter to set its output to exactly 5.0 V. Then connect boost_5v VOUT+ to the board 5V/VIN input and color_indicator VCC (regulated 5V), and connect VOUT- to common GND (return). Connect level_shifter VCC to 5V (chip power).

  • The TPS63020 module’s output selector or solder jumper must be set to 5V; its usual default may be 3.3V.
  • Do not connect the converter to the board until a meter reads 5.0 V. Do not power the board from USB while the battery converter is also connected; competing power sources can damage hardware.

10. Check the wires and secure the wristband

With the LiPo still disconnected, inspect both sides of the perfboard for solder bridges. Confirm every module ground joins GND, the sensor/display/RTC/microSD power pins are on 3V3, and only the color indicator and 74AHCT125 use the 5V rail. Make sure the motor turns on only through the transistor. Insulate exposed battery wires and secure the perfboard and battery in the wristband so nothing sharp presses into the LiPo.

  • For a first test, leave the LiPo disconnected and use the board’s USB connection; connect battery power only after the 5V output and wiring have been checked.
  • Do not wear the band while soldering or while checking for shorts, and do not charge it while wearing it.

Review all connections

1. Connections between "ltr390_uv" and "ESP32"

Functionltr390_uvESP32
powerVIN3V3
groundGNDGND
i2cSDAGPIO 0
i2cSCLGPIO 1

2. Connections between "oled_096" and "ESP32"

Functionoled_096ESP32
powerVCC3V3
groundGNDGND
i2cSDAGPIO 0
i2cSCLGPIO 1

3. Connections between "battery_350" and "ESP32"

Functionbattery_350ESP32
power+ → TP4056 Li-Ion/LiPo charger module with protection B+EXT
ground- → TP4056 Li-Ion/LiPo charger module with protection B-EXT

4. Connections between "charger_tp4056" and "ESP32"

Functioncharger_tp4056ESP32
groundIN-GND
powerOUT+ → Breadboard-friendly SPDT Slide Switch COMEXT
groundOUT- → TPS63020 Adjustable Buck-Boost Converter Module VIN-EXT
powerIN+ → USB 5V supplyEXT

5. Connections between "switch_power" and "ESP32"

Functionswitch_powerESP32
digitalA → TPS63020 Adjustable Buck-Boost Converter Module VIN+EXT

6. Connections between "boost_5v" and "ESP32"

Functionboost_5vESP32
powerVOUT+5V
groundVOUT-GND

7. Connections between "buzzer_alert" and "ESP32"

Functionbuzzer_alertESP32
pwmSIGGPIO 3
groundGNDGND

8. Connections between "rtc_ds3231" and "ESP32"

Functionrtc_ds3231ESP32
powerVIN3V3
groundGNDGND
i2cSDAGPIO 0
i2cSCLGPIO 1

9. Connections between "rtc_coin_cell" and "ESP32"

Functionrtc_coin_cellESP32
power+ → DS3231 Real-Time Clock Module (3.3 V I2C) BATEXT
ground-GND

10. Connections between "sd_module" and "ESP32"

Functionsd_moduleESP32
power3V3V3
groundGNDGND
spiCLKGPIO 4
spiMISOGPIO 5
spiMOSIGPIO 6
spiCSGPIO 7

11. Connections between "sd_card" and "ESP32"

Functionsd_cardESP32
dataCARD_EDGE → 3.3 V microSD SPI Breakout with Card Socket CARD_SLOTEXT

12. Connections between "level_shifter" and "ESP32"

Functionlevel_shifterESP32
powerVCC5V
groundGNDGND
digital1AGPIO 10
ground1OEGND
ground2AGND
power2OE5V
ground3AGND
power3OE5V
ground4AGND
power4OE5V

13. Connections between "pixel_bulk_cap" and "ESP32"

Functionpixel_bulk_capESP32
power+5V
ground-GND

14. Connections between "logic_bypass_cap" and "ESP32"

Functionlogic_bypass_capESP32
powerLEAD15V
groundLEAD2GND

15. Connections between "motor_base_resistor" and "ESP32"

Functionmotor_base_resistorESP32
dataP1GPIO 20
dataP2 → NPN Transistor (2N2222 / BC547) BASEEXT

16. Connections between "motor_driver" and "ESP32"

Functionmotor_driverESP32
groundEMITTERGND
digitalCOLLECTOR → 3 V Coin Vibration Motor, 10 mm, 70 mA M-EXT

17. Connections between "vibration_motor" and "ESP32"

Functionvibration_motorESP32
powerM+3V3

18. Connections between "pixel_data_resistor" and "ESP32"

Functionpixel_data_resistorESP32
dataP1 → 74AHCT125 Quad Buffer / Level Shifter 1YEXT
dataP2 → Addressable Color Indicator Board DINEXT

19. Connections between "motor_flyback_diode" and "ESP32"

Functionmotor_flyback_diodeESP32
dataCATHODE → 3 V Coin Vibration Motor, 10 mm, 70 mA M+EXT
dataANODE → NPN Transistor (2N2222 / BC547) COLLECTOREXT

20. Connections between "color_indicator" and "ESP32"

Functioncolor_indicatorESP32
powerVCC5V
groundGNDGND

Deploy the firmware

#include <Arduino.h>
#include <Wire.h>
#include <SPI.h>
#include <SD.h>
#include <math.h>
#include <Adafruit_LTR390.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Adafruit_NeoPixel.h>
#include <RTClib.h>

// ESP32-C3 SuperMini wiring
constexpr uint8_t I2C_SDA_PIN = 0;
constexpr uint8_t I2C_SCL_PIN = 1;
constexpr uint8_t BUZZER_PIN = 3;
constexpr uint8_t SD_SCK_PIN = 4;
constexpr uint8_t SD_MISO_PIN = 5;
constexpr uint8_t SD_MOSI_PIN = 6;
constexpr uint8_t SD_CS_PIN = 7;
constexpr uint8_t NEOPIXEL_PIN = 10;
constexpr uint8_t MOTOR_PIN = 20;

constexpr int SCREEN_WIDTH = 128;
constexpr int SCREEN_HEIGHT = 64;
constexpr uint8_t OLED_ADDRESS = 0x3C;
constexpr float UV_ALERT_UVI = 3.0f;
constexpr float UV_HIGH_UVI = 6.0f;
constexpr uint32_t LOG_INTERVAL_MS = 60000UL;

Adafruit_LTR390 ltr;
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);
Adafruit_NeoPixel pixel(1, NEOPIXEL_PIN, NEO_GRB + NEO_KHZ800);
RTC_DS3231 rtc;

bool sensorOk = false;
bool displayOk = false;
bool rtcOk = false;
bool sdOk = false;
bool haveUv = false;
bool haveAls = false;

enum SensorMode : uint8_t { MODE_UV, MODE_ALS };
SensorMode sensorMode = MODE_UV;
uint32_t sensorModeStarted = 0;
uint32_t uvRaw = 0;
uint32_t alsRaw = 0;
float uvIndexEstimate = 0.0f;
float luxEstimate = 0.0f;

int lastRenderedUviTenths = -999;
int lastRenderedLux = -999;
int lastRenderedCategory = -1;
int lastRenderedMinute = -1;
int lastLedCategory = -1;
uint32_t lastDisplayCheck = 0;
uint32_t lastLogAt = 0;
uint32_t lastAlertAt = 0;

bool buzzerPatternActive = false;
bool buzzerOn = false;
uint8_t beepCount = 0;
uint8_t beepTarget = 0;
uint32_t buzzerTransitionAt = 0;

int uvCategory(float value) {
  if (value < UV_ALERT_UVI) return 0;
  if (value < UV_HIGH_UVI) return 1;
  return 2;
}

void updatePixel() {
  if (!sensorOk || !haveUv) return;
  const int category = uvCategory(uvIndexEstimate);
  if (category == lastLedCategory) return;

  if (category == 0) {
    pixel.setPixelColor(0, pixel.Color(0, 0, 36));
  } else if (category == 1) {
    pixel.setPixelColor(0, pixel.Color(36, 22, 0));
  } else {
    pixel.setPixelColor(0, pixel.Color(40, 0, 0));
  }
  pixel.show();
  lastLedCategory = category;
}

void renderDashboard() {
  if (!displayOk) return;

  const int category = haveUv ? uvCategory(uvIndexEstimate) : 0;
  const int uviTenths = haveUv ? static_cast<int>(lroundf(uvIndexEstimate * 10.0f)) : -1;
  const int luxShown = haveAls ? static_cast<int>(lroundf(luxEstimate)) : -1;
  int currentMinute = -1;
  DateTime clockNow(2000, 1, 1, 0, 0, 0);
  if (rtcOk) {
    clockNow = rtc.now();
    currentMinute = clockNow.hour() * 60 + clockNow.minute();
  }

  if (uviTenths == lastRenderedUviTenths && luxShown == lastRenderedLux &&
      category == lastRenderedCategory && currentMinute == lastRenderedMinute) {
    return;
  }
  lastRenderedUviTenths = uviTenths;
  lastRenderedLux = luxShown;
  lastRenderedCategory = category;
  lastRenderedMinute = currentMinute;

  display.clearDisplay();
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);
  display.setCursor(0, 0);
  display.println("UV ALERT WRISTBAND");
  display.setCursor(0, 14);
  display.print("UVI~ ");
  if (haveUv) display.print(uvIndexEstimate, 1);
  else display.print("--");
  display.setCursor(68, 14);
  display.print("Lux~ ");
  if (haveAls) display.print(luxShown);
  else display.print("--");
  display.setCursor(0, 30);
  if (!sensorOk) {
    display.print("UV sensor not found");
  } else if (category == 0) {
    display.print("UV: LOW");
  } else if (category == 1) {
    display.print("UV: MODERATE - cover up");
  } else {
    display.print("UV: HIGH - protect skin");
  }
  display.setCursor(0, 48);
  if (rtcOk) {
    display.print("Time ");
    if (clockNow.hour() < 10) display.print('0');
    display.print(clockNow.hour());
    display.print(':');
    if (clockNow.minute() < 10) display.print('0');
    display.print(clockNow.minute());
  } else {
    display.print("RTC unavailable");
  }
  display.setCursor(0, 58);
  display.print(sdOk ? "Log: SD ready" : "Log: SD unavailable");
  display.display();
}

void stopBuzzer() {
  if (buzzerOn) noTone(BUZZER_PIN);
  digitalWrite(MOTOR_PIN, LOW);
  buzzerOn = false;
  buzzerPatternActive = false;
}

void serviceBuzzer(uint32_t now) {
  if (!sensorOk || !haveUv || uvIndexEstimate < UV_ALERT_UVI) {
    stopBuzzer();
    return;
  }

  const int category = uvCategory(uvIndexEstimate);
  const uint32_t repeatMs = (category >= 2) ? 15000UL : 30000UL;
  if (!buzzerPatternActive && static_cast<uint32_t>(now - lastAlertAt) >= repeatMs) {
    lastAlertAt = now;
    buzzerPatternActive = true;
    buzzerOn = true;
    beepCount = 1;
    beepTarget = (category >= 2) ? 3 : 2;
    tone(BUZZER_PIN, 4000);
    digitalWrite(MOTOR_PIN, HIGH);
    buzzerTransitionAt = now + ((category >= 2) ? 240UL : 150UL);
  }

  if (!buzzerPatternActive || static_cast<int32_t>(now - buzzerTransitionAt) < 0) return;

  if (buzzerOn) {
    noTone(BUZZER_PIN);
    digitalWrite(MOTOR_PIN, LOW);
    buzzerOn = false;
    if (beepCount >= beepTarget) {
      buzzerPatternActive = false;
    } else {
      buzzerTransitionAt = now + 120UL;
    }
  } else {
    tone(BUZZER_PIN, 4000);
    digitalWrite(MOTOR_PIN, HIGH);
    buzzerOn = true;
    ++beepCount;
    buzzerTransitionAt = now + ((category >= 2) ? 240UL : 150UL);
  }
}

void serviceSensor(uint32_t now) {
  if (!sensorOk || static_cast<uint32_t>(now - sensorModeStarted) < 420UL) return;
  if (!ltr.newDataAvailable()) return;

  if (sensorMode == MODE_UV) {
    uvRaw = ltr.readUVS();
    // At 18x gain and 20-bit resolution, the LTR390 reference is about 2300 counts/UVI.
    // This is an estimate, not a calibrated medical or regulatory UV instrument.
    uvIndexEstimate = static_cast<float>(uvRaw) / 2300.0f;
    haveUv = true;
    ltr.setMode(LTR390_MODE_ALS);
    sensorMode = MODE_ALS;
  } else {
    alsRaw = ltr.readALS();
    // Approximate lux at 18x gain and 400 ms integration (20-bit resolution).
    luxEstimate = static_cast<float>(alsRaw) / 120.0f;
    haveAls = true;
    ltr.setMode(LTR390_MODE_UVS);
    sensorMode = MODE_UV;
  }
  sensorModeStarted = now;
  updatePixel();
}

void initializeCard() {
  SPI.begin(SD_SCK_PIN, SD_MISO_PIN, SD_MOSI_PIN, SD_CS_PIN);
  sdOk = SD.begin(SD_CS_PIN, SPI, 4000000);
  if (!sdOk) {
    Serial.println("microSD not ready; readings will not be logged.");
    return;
  }

  if (!SD.exists("/uv_alert.csv")) {
    File file = SD.open("/uv_alert.csv", FILE_WRITE);
    if (file) {
      file.println("timestamp,uv_raw,uvi_estimate,lux_estimate,alert_level");
      file.close();
    }
  }
}

void appendLog() {
  if (!sdOk || !haveUv || !haveAls) return;

  char timestamp[24];
  if (rtcOk) {
    const DateTime now = rtc.now();
    snprintf(timestamp, sizeof(timestamp), "%04d-%02d-%02d %02d:%02d:%02d",
             now.year(), now.month(), now.day(), now.hour(), now.minute(), now.second());
  } else {
    snprintf(timestamp, sizeof(timestamp), "NO_RTC");
  }

  const char *level = (uvCategory(uvIndexEstimate) == 0) ? "LOW" :
                      (uvCategory(uvIndexEstimate) == 1) ? "MODERATE" : "HIGH";
  char row[112];
  snprintf(row, sizeof(row), "%s,%lu,%.2f,%.1f,%s",
           timestamp, static_cast<unsigned long>(uvRaw), uvIndexEstimate, luxEstimate, level);
  File file = SD.open("/uv_alert.csv", FILE_APPEND);
  if (file) {
    file.println(row);
    file.close();
  } else {
    sdOk = false;
  }
}

void setup() {
  Serial.begin(115200);
  delay(150);

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

  Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN);

  pixel.begin();
  pixel.setBrightness(24);
  pixel.clear();
  pixel.show();

  displayOk = display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS);

  sensorOk = ltr.begin();
  if (sensorOk) {
    ltr.setGain(LTR390_GAIN_18);
    ltr.setResolution(LTR390_RESOLUTION_20BIT);
    ltr.setMode(LTR390_MODE_UVS);
    sensorMode = MODE_UV;
    sensorModeStarted = millis();
  } else {
    Serial.println("LTR390 not found.");
  }

  rtcOk = rtc.begin();
  if (rtcOk && rtc.lostPower()) {
    // Sets a reasonable starting time; insert the CR1220 so the clock keeps running when off.
    rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
  }
  if (!rtcOk) Serial.println("DS3231 not found.");

  initializeCard();
  lastLogAt = millis() - LOG_INTERVAL_MS;
  lastAlertAt = millis() - 30000UL;
  renderDashboard();
}

void loop() {
  const uint32_t now = millis();
  serviceSensor(now);
  serviceBuzzer(now);

  if (static_cast<uint32_t>(now - lastDisplayCheck) >= 500UL) {
    lastDisplayCheck = now;
    renderDashboard();
  }

  if (static_cast<uint32_t>(now - lastLogAt) >= LOG_INTERVAL_MS) {
    lastLogAt = now;
    appendLog();
  }

  delay(20);
}

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