Overview
This project started as a hardware lab assignment — wire up a temperature sensor and display the reading. I kept adding features until it became a reasonably complete example of how to structure embedded firmware properly.
The monitor reads temperature, humidity, and ambient light; displays them on an I²C OLED; triggers buzzer alarms when thresholds are exceeded; and streams telemetry over UART at 115200 baud. The Wokwi simulation is available here if you want to run it without hardware.
Hardware
| Component | Purpose | |---|---| | ESP32 dev board | Main MCU (240 MHz, dual-core, Wi-Fi capable) | | DHT22 | Temperature + humidity sensor | | Analog light sensor | Ambient light measurement (ADC) | | SSD1306 OLED (128×64, I²C) | Display | | Buzzer | Audio alarm | | Tactile button | Mode toggle / alarm dismiss |
Non-Blocking Architecture
The biggest design constraint was: zero blocking delays in the main loop. This is standard embedded practice but often ignored in Arduino tutorials that use delay(2000) between sensor reads.
I implemented a millis()-based cooperative scheduler. Each task has a last-run timestamp and an interval; the main loop checks all tasks on every iteration and runs the ones that are due:
void loop() {
unsigned long now = millis();
if (now - lastSensorRead >= SENSOR_INTERVAL_MS) {
sensor.update();
lastSensorRead = now;
}
if (now - lastDisplayRefresh >= DISPLAY_INTERVAL_MS) {
display.render(sensor.getReadings());
lastDisplayRefresh = now;
}
if (now - lastAlarmCheck >= ALARM_INTERVAL_MS) {
checkThresholds();
lastAlarmCheck = now;
}
handleButton();
}
Intervals:
- Sensor sampling: every 2000 ms (DHT22 requires at least 500 ms between reads)
- Display refresh: every 500 ms
- Alarm evaluation: every 500 ms
- Button debounce: 50 ms window
Class Design
The firmware is organized into two primary classes:
EnvSensor owns the DHT22 and light sensor. It exposes update() (called by the scheduler) and getReadings() (returns a SensorReadings struct with temperature, humidity, and lux). It handles DHT22 error codes internally — if a read fails, the previous valid reading is retained rather than displaying NaN.
DisplayManager owns the SSD1306 over I²C. It accepts a SensorReadings struct and renders a fixed layout: current readings, threshold status indicators, and a scrolling status bar at the bottom.
This separation means display logic and sensor logic never bleed into each other, and either class can be tested or replaced independently.
Alarm System
Alarm thresholds are compile-time constants:
constexpr float TEMP_HIGH_C = 30.0f;
constexpr float TEMP_LOW_C = 5.0f;
constexpr float HUM_HIGH_PCT = 80.0f;
constexpr int LUX_LOW = 50;
When any reading crosses a threshold, the buzzer sounds a 500 ms tone and an alert icon appears on the display. The button dismisses the current alarm but re-triggers if the reading stays out of range.
Button debouncing uses a 50 ms software window — the state is only registered as changed if it has been stable for the full window duration.
UART Telemetry
Every sensor cycle, the firmware serializes a CSV line to UART at 115200 baud:
timestamp_ms,temp_c,humidity_pct,lux,alarm_active
12500,23.4,55.2,312,0
14502,23.5,55.1,311,0
This is useful for logging long-term data without modifying the firmware — a host machine can capture it with any serial terminal.
Lessons Learned
The biggest lesson was how much complexity arises from the DHT22's quirks. The sensor has a minimum sampling interval, frequently returns error codes on the first read after power-on, and is sensitive to electrical noise. Handling these gracefully — holding the last good reading, logging errors to UART, not crashing — took more code than the happy path.
The millis() scheduler also surfaced a subtle issue: if the main loop blocks for any reason (even a brief I²C transaction that hangs), all tasks drift. For production firmware you'd want a hardware timer interrupt instead.