Contents — 26 sections
Project Overview
A bedroom controller that manages the four environmental variables sleep research actually supports — temperature, light, sound and CO₂ — and correlates each night against how you slept.
Sleep advice is full of confident claims with thin evidence. This project deliberately targets only the four environmental factors with reasonably solid experimental support: ambient temperature, which affects sleep onset and slow-wave sleep through the body's core temperature drop; light exposure, particularly blue light suppressing melatonin; acoustic disturbance, where intermittent noise fragments sleep even when it does not wake you; and CO₂ accumulation in a closed bedroom, which is associated with reduced sleep quality and next-day performance.
The interesting engineering problem is not measuring these — that is straightforward — but acting on them without becoming a disturbance itself. A fan that switches on abruptly at 3 a.m. wakes people. A display that glows is itself light pollution. Every actuator in this design is therefore ramped over minutes, and every indicator is either off or deep red below a configurable illuminance.
The temperature strategy follows the physiology rather than a fixed setpoint. Core body temperature naturally falls by around 1 °C during the night, reaching its minimum roughly two hours before habitual wake time, and a cooler room facilitates that drop. The controller therefore runs a temperature ramp: comfortable at bedtime, coolest in the early hours, rising slightly before wake time — which is a much better match to what the body is doing than holding one number all night.
Finally, the system logs. Every night produces a record of the four variables at one-minute resolution, plus movement from an accelerometer under the mattress as a crude sleep-quality proxy. After a few weeks that data tells you something specific about your own room — for example, that your sleep fragments consistently when CO₂ passes 1800 ppm, or that the traffic peak at 05:30 is what wakes you rather than your alarm.
What this project does
- Measures bedroom temperature, humidity, illuminance, sound level and CO₂ once a minute all night.
- Ramps a fan or AC setpoint through the night following the natural core-temperature curve.
- Fades a sunrise light from deep red to full brightness over 30 minutes before the alarm.
- Generates pink noise through a small speaker, level-matched to measured room noise.
- Alerts on CO₂ crossing a ventilation threshold — silently, by logging, not by beeping at 2 a.m.
- Detects movement with an under-mattress accelerometer as a restlessness proxy.
- Produces a nightly summary correlating environment against restlessness.
Real-World Applications
| Setting | How it is used |
|---|---|
| Personal sleep optimisation | The core case — finding out which of the four variables actually matters in your specific room. |
| Shared bedrooms | Two people with different temperature preferences at least get data instead of an argument. |
| Infant and child rooms | Temperature and CO₂ monitoring with silent logging rather than audible alerts. |
| Shift workers | Daytime sleep needs aggressive light control, and blackout effectiveness is measurable rather than assumed. |
| Student accommodation and hostels | Documenting noise and ventilation conditions gives a factual basis for a complaint. |
| Sleep research teaching | A complete, honest instrumentation exercise including the limits of what a consumer sensor can conclude. |
Deployment contexts where a build of this kind earns its keep.
Features & Capabilities
- Physiological temperature ramp rather than a fixed setpoint, matched to the core-temperature curve.
- All actuator changes ramped over minutes, so nothing in the system can itself wake you.
- Light-aware indicators — every LED is off or deep red below 5 lx.
- Pink noise generation with level matching to measured ambient sound.
- Sunrise simulation using a 30-minute warm-to-cool brightness curve.
- Under-mattress accelerometer for movement-based restlessness scoring.
- Per-night logging at one-minute resolution to InfluxDB.
- Correlation report pairing each night's environment against its restlessness score.
Difficulty, Time & Required Skills
| Attribute | Value |
|---|---|
| Difficulty level | Intermediate |
| Estimated completion time | 10–15 hours |
| Indicative build cost | ₹8,400 – ₹10,200 |
| Primary discipline | Smart Home |
| Reference platform | ESP32 DevKit V1 (ESP-WROOM-32) |
Skills you should have (or will pick up)
- Arduino C++ with non-blocking scheduling
- I²C and UART sensor reading
- PWM and gradual ramping of outputs
- Basic understanding of audio level in dB and A-weighting
- MQTT and time-series logging
Bill of Materials
Every part below is commonly available from Indian and international hobby-electronics suppliers. Prices are indicative 2026 retail figures in Indian rupees and will drift — treat them as a budgeting guide, not a quotation.
| Component | Key specification | Qty | Approx. cost |
|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly. | Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18× 12-bit ADC, 2× 8-bit DAC | 1 | ₹450 |
| SHT31-D temperature + humidity sensor The built-in heater burns off condensation — essential for outdoor or greenhouse use. | −40 to +125 °C ±0.2 °C, 0–100 %RH ±2 %, 1 Hz–10 Hz, on-chip heater | 1 | ₹620 |
| BH1750 digital ambient light sensor Far more linear than an LDR — use it whenever you need real lux, not a relative value. | 1–65535 lx, 16-bit, ±20 %, spectral response close to the human eye | 1 | ₹140 |
| MH-Z19B NDIR CO₂ sensor Disable auto-baseline calibration (ABC) for sealed rooms or it drifts to 400 ppm. | 0–5000 ppm ±(50 ppm + 5 %), NDIR, 60 s warm-up, UART + PWM output | 1 | ₹2,600 |
| INMP441 I²S MEMS microphone Digital output means no analogue noise pickup — far better than an MAX9814 for keyword spotting. | 61 dB SNR, −26 dBFS sensitivity, 60 Hz–15 kHz, 24-bit I²S output | 1 | ₹220 |
| ADXL345 3-axis accelerometer Built-in activity/free-fall interrupts let the MCU deep-sleep until something moves. | ±2/4/8/16 g, 13-bit, 0.004 g/LSB, tap and free-fall interrupts, 3200 Hz | 1 | ₹220 |
| WS2812B addressable RGB LED strip (60 LED/m) Budget 60 mA × LED count; add a 1000 µF cap and a 330 Ω series resistor on data. | 5 V, 60 mA per LED at full white, 800 kHz single-wire protocol, 8-bit per channel | 1 | ₹900 |
| 5 V single-channel opto-isolated relay module Cut the JD-VCC jumper and feed the coil separately for true isolation. | SPDT contacts rated 10 A @ 250 VAC / 10 A @ 30 VDC, opto-isolated input | 1 | ₹90 |
| LM2596 adjustable buck converter module Set the output voltage with no load connected before wiring the board. | 4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency | 1 | ₹90 |
| 5 V 3 A regulated SMPS adapter Measure the real output — many "3 A" adapters sag below 4.7 V at 2 A. | 100–240 VAC in, 5 V ±5 % out, 3 A, short-circuit and over-voltage protection | 1 | ₹350 |
| Double-sided perfboard 7 × 9 cm + headers Solder female headers so the MCU can be swapped without desoldering. | FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid | 1 | ₹60 |
| IP65 ABS junction enclosure 158 × 90 × 60 mm Fit cable glands, not drilled holes, or the IP rating means nothing. | IP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugs | 1 | ₹260 |
| Small full-range speaker + PAM8403 amplifier For pink noise. A piezo buzzer is not suitable — it has no low-frequency content. | 4 Ω 3 W, class-D amp | 1 | ₹260 |
| Warm-white + cool-white LED strip 0.5 m Two channels give true colour-temperature control for sunrise simulation. | 2700 K and 5000 K, 12 V | 1 | ₹320 |
| MOSFET dimmer pair (IRLZ44N) | Logic level, one per LED channel | 2 | ₹120 |
Estimated total: ₹6,700, excluding tools, shipping and consumables.
Tools and consumables
- Soldering iron (temperature controlled, 350 °C) with 0.8 mm 60/40 or lead-free solder
- Digital multimeter — continuity, DC volts and current ranges
- Wire strippers, flush cutters and a small set of precision screwdrivers
- Heat-shrink tubing and a heat gun (or a lighter, carefully)
- A laptop with a USB port and the toolchain listed above
Hardware Specifications
| Part | Specification | Supply | Interface | Reference |
|---|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) | Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18× 12-bit ADC, 2× 8-bit DAC | 3.3 V logic / 5 V USB | UART, SPI, I²C, I²S, CAN, PWM | Datasheet |
| SHT31-D temperature + humidity sensor | −40 to +125 °C ±0.2 °C, 0–100 %RH ±2 %, 1 Hz–10 Hz, on-chip heater | 2.4–5.5 V | I²C (0x44/0x45) | Datasheet |
| BH1750 digital ambient light sensor | 1–65535 lx, 16-bit, ±20 %, spectral response close to the human eye | 2.4–3.6 V | I²C (0x23/0x5C) | Datasheet |
| MH-Z19B NDIR CO₂ sensor | 0–5000 ppm ±(50 ppm + 5 %), NDIR, 60 s warm-up, UART + PWM output | 4.5–5.5 V | UART 9600 8N1, PWM | Datasheet |
| INMP441 I²S MEMS microphone | 61 dB SNR, −26 dBFS sensitivity, 60 Hz–15 kHz, 24-bit I²S output | 1.8–3.3 V | I²S | Datasheet |
| ADXL345 3-axis accelerometer | ±2/4/8/16 g, 13-bit, 0.004 g/LSB, tap and free-fall interrupts, 3200 Hz | 2.0–3.6 V | I²C / SPI | Datasheet |
| WS2812B addressable RGB LED strip (60 LED/m) | 5 V, 60 mA per LED at full white, 800 kHz single-wire protocol, 8-bit per channel | 5 V | 1-wire timed protocol | Datasheet |
| 5 V single-channel opto-isolated relay module | SPDT contacts rated 10 A @ 250 VAC / 10 A @ 30 VDC, opto-isolated input | 5 V coil | Digital (active-low) | Datasheet |
| LM2596 adjustable buck converter module | 4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency | 4.5–40 V | Screw terminals + trimmer | Datasheet |
| 5 V 3 A regulated SMPS adapter | 100–240 VAC in, 5 V ±5 % out, 3 A, short-circuit and over-voltage protection | 5 V | DC barrel / USB | Datasheet |
| Double-sided perfboard 7 × 9 cm + headers | FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid | — | — | Datasheet |
| IP65 ABS junction enclosure 158 × 90 × 60 mm | IP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugs | — | — | Datasheet |
Consolidated electrical and interface specifications for every active part in the build.
Power Budget & Supply Sizing
Add up the typical active current of every part, then size the supply with at least 50 % headroom so transmit bursts and motor inrush never brown out the controller.
| Load | Supply rail | Typical current (mA) | Notes |
|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) | 3.3 V logic / 5 V USB | 160 | Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly. |
| SHT31-D temperature + humidity sensor | 2.4–5.5 V | 1.5 | The built-in heater burns off condensation — essential for outdoor or greenhouse use. |
| BH1750 digital ambient light sensor | 2.4–3.6 V | 0.19 | Far more linear than an LDR — use it whenever you need real lux, not a relative value. |
| MH-Z19B NDIR CO₂ sensor | 4.5–5.5 V | 60 | Disable auto-baseline calibration (ABC) for sealed rooms or it drifts to 400 ppm. |
| INMP441 I²S MEMS microphone | 1.8–3.3 V | 1.4 | Digital output means no analogue noise pickup — far better than an MAX9814 for keyword spotting. |
| ADXL345 3-axis accelerometer | 2.0–3.6 V | 0.14 | Built-in activity/free-fall interrupts let the MCU deep-sleep until something moves. |
| WS2812B addressable RGB LED strip (60 LED/m) | 5 V | 60 | Budget 60 mA × LED count; add a 1000 µF cap and a 330 Ω series resistor on data. |
| 5 V single-channel opto-isolated relay module | 5 V coil | 70 | Cut the JD-VCC jumper and feed the coil separately for true isolation. |
| LM2596 adjustable buck converter module | 4.5–40 V | 8 | Set the output voltage with no load connected before wiring the board. |
| 5 V 3 A regulated SMPS adapter | 5 V | 3000 | Measure the real output — many "3 A" adapters sag below 4.7 V at 2 A. |
Summed typical draw is 3361.23 mA. With a 1.5× design margin the supply should deliver at least 5100 mA continuously at the stated rail voltage.
Software Requirements & Development Environment
Reference toolchain: Arduino IDE 2.3.x with the ESP32 board package 3.x (or PlatformIO on VS Code). Anything newer normally works; anything older may lack the board definitions used here.
- Install the Arduino IDE 2.3.x (or PlatformIO if you prefer a real editor and dependency locking).
- Add
https://espressif.github.io/arduino-esp32/package_esp32_index.jsonunder File → Preferences → Additional Board Manager URLs, then install esp32 from the Boards Manager. - Set the correct port under Tools → Port. On Linux add yourself to the
dialoutgroup:sudo usermod -aG dialout $USERand log out and back in. - Open the Serial Monitor at 115200 baud — every sketch here logs its state there.
- Keep File → Preferences → Show verbose output during: compilation switched on while you are debugging build errors.
Required libraries
| Library | Why it is needed | Install |
|---|---|---|
| WiFi (ESP32 core) bundled | Station/AP connection management for the ESP32. | Bundled with the ESP32 Arduino core |
| PubSubClient 2.8 | Lightweight MQTT 3.1.1 client for constrained devices. | Library Manager → "PubSubClient" by Nick O'Leary |
| ArduinoJson 7.x | Zero-allocation JSON serialisation and parsing. | Library Manager → "ArduinoJson" by Benoit Blanchon |
| BH1750 1.3.0 | Digital lux readings with selectable resolution modes. | Library Manager → "BH1750" by Christopher Laws |
| Adafruit SSD1306 + GFX 2.5.x | Framebuffer and text/graphics primitives for the OLED. | Library Manager → "Adafruit SSD1306" |
| Preferences (NVS) bundled | Wear-levelled key/value storage in ESP32 flash for settings. | Bundled with the ESP32 core |
| NTPClient / configTime bundled | Wall-clock time from an NTP server for timestamping. | Bundled (`configTime()` on ESP32) |
| FastLED 3.6.x | Timing-exact WS2812B driver with colour-correction and palettes. | Library Manager → "FastLED" |
Block Diagram
The block diagram shows the functional decomposition of the system — what senses, what decides, what acts, and where the data ends up.
Circuit Diagram & Wiring
Every signal line in the build is shown below, followed by a pin-by-pin connection table you can work through with a multimeter in hand.
| Peripheral | Peripheral pin | Controller pin | Signal |
|---|---|---|---|
| SHT31 temperature / humidity | SDA / SCL | GPIO 21 / 22 | I²C at 0x44 |
| BH1750 illuminance | SDA / SCL | GPIO 21 / 22 | Shared I²C, 0x23 |
| ADXL345 under mattress | SDA / SCL | GPIO 21 / 22 | Shared I²C, 0x53 |
| MH-Z19B CO₂ | TX / RX | GPIO 16 / 17 | UART2 9600 |
| INMP441 microphone | BCLK / WS / SD | GPIO 14 / 15 / 32 | I²S, level only |
| Warm white LED channel | MOSFET gate | GPIO 25 | LEDC PWM 12-bit |
| Cool white LED channel | MOSFET gate | GPIO 26 | LEDC PWM 12-bit |
| PAM8403 audio | IN | GPIO 27 (DAC2) | Pink noise output |
| Relay → fan / AC | IN | GPIO 33 | Ramped duty cycle |
Wire one row at a time and tick it off — most "it does not work" reports trace back to a single swapped pair.
Wiring explanation
- The MH-Z19B is the largest heat source in the enclosure. Keep the SHT31 at least 80 mm away and on a separate small board, or your bedroom temperature reading will be two degrees high.
- The accelerometer goes under the mattress, not on the bed frame. On the frame it picks up building vibration and footsteps in the corridor; under the mattress it picks up the sleeper.
- Use 12-bit LEDC PWM for the LED channels. At 8-bit resolution the lowest usable step is visibly bright in a dark room, which defeats the whole point of a gentle sunrise.
- Set the LEDC PWM frequency above 20 kHz. Below that, cheap LED drivers can produce an audible whine — in a bedroom, at 2 a.m., that is a disaster.
- The microphone is used for level measurement only, and no audio is recorded or transmitted. Wire it so that is verifiably true, and say so on the enclosure if anyone else sleeps in the room.
- Use a mains-rated relay only if you are switching a mains fan. For a 12 V fan, a logic-level MOSFET is quieter, has no clicking, and allows genuine speed ramping.
System Architecture
Read the stack from the bottom up: physical hardware, the firmware that drives it, the transport that moves data off the device, and the software a human actually looks at.
Working Principle
The temperature strategy follows a well-established physiological fact: core body temperature falls by roughly 0.5–1.0 °C during sleep, reaching its nadir about two hours before habitual waking. Sleep onset is facilitated by that drop, and the drop happens through peripheral vasodilation — heat leaving the body through the hands and feet. A room that is too warm impedes it. Rather than holding one setpoint, the controller follows a curve: around 20–21 °C at bedtime, falling to 18–19 °C in the early hours, rising back before wake. The absolute numbers are individual; the shape is not.
Light matters most in two windows. Blue-rich light in the two hours before bed suppresses melatonin and delays sleep onset, which is why the room lighting fades warm and dim in the evening. And light in the last thirty minutes before waking advances the circadian phase and reduces sleep inertia — that grogginess on waking — which is what the sunrise simulation targets. Between those windows, darkness is the goal, which is why every indicator on the device goes dark below 5 lx.
Sound disturbs sleep through intermittency more than through absolute level. A steady 45 dB is far less disruptive than a quiet room punctuated by a 55 dB door. Pink noise works by raising the noise floor so intermittent events are less salient — the delta between background and disturbance shrinks. Pink rather than white is used because its power falls at 3 dB per octave, which sounds like rainfall rather than hiss and matches the ear's frequency weighting better. The level is matched to measured ambient noise rather than fixed, because a masking sound louder than what it masks is itself a disturbance.
CO₂ in a closed bedroom routinely reaches 2000–3000 ppm overnight, and several studies associate that range with poorer subjective sleep quality and reduced next-day performance. The controller measures it but deliberately does not act loudly on it — waking someone to tell them the air is stale is self-defeating. It logs, and it reports in the morning, and it can trigger a silent trickle vent if you have one.
The restlessness score deserves an honest caveat. An accelerometer under a mattress detects gross body movement, and movement correlates with sleep stage — you move more in light sleep and almost not at all in REM atonia. It is not polysomnography and it cannot stage sleep. What it gives is a repeatable, self-consistent number that can be correlated against environmental variables from the same device, which is exactly what you need to answer "does my room being cooler actually help me".
The maths behind it
Overnight temperature ramp
Given bedtime B and wake time W (hours), fraction f = (t − B)/(W − B):
T_target(f) = T_bed − ΔT × sin(π × f^0.8)
With T_bed = 21 °C, ΔT = 2.5 °C:
f = 0.00 (bedtime) → 21.0 °C
f = 0.25 → 19.2 °C
f = 0.50 → 18.5 °C
f = 0.75 (nadir ≈) → 18.7 °C
f = 1.00 (wake) → 21.0 °C
The f^0.8 exponent shifts the minimum slightly later
than the midpoint, matching the observed core
temperature nadir about two hours before waking.
Sound pressure level from I²S samples
RMS of N samples, normalised to full scale:
L = 20 · log10(rms / 32768) + K
K is the calibration offset: measure a known
source with a reference meter and solve for K.
Typical INMP441 at 94 dB SPL (1 kHz, 1 Pa):
rms ≈ 1640 counts → 20·log10(1640/32768) = −26 dBFS
K = 94 − (−26) = 120
So SPL ≈ dBFS + 120 for this microphone.
Quiet bedroom ≈ 28 dB SPL, traffic peak ≈ 52 dB.
Pink noise from white
Pink noise has power spectral density ∝ 1/f (−3 dB/octave).
Voss-McCartney approximation with 5 octave rows:
each row updates at half the rate of the previous
output = sum of all rows
row 0 updates every sample
row 1 every 2 samples
row 2 every 4 samples ... row 4 every 16
This is far cheaper than filtering white noise and
sounds indistinguishable for masking purposes.
Program Flowchart
The firmware is a single cooperative loop. Nothing blocks for long, so networking, sensing and the user interface all stay responsive.
Assembly Instructions
Build on a breadboard first and only commit to solder once the whole system has run for an hour without a fault.
Step-by-Step Implementation Guide
Work through these in order. Each step ends in something you can observe, so a failure is always localised to the step you just finished.
Complete Source Code
The listing below is complete and compiles as written — there are no elided sections. Read the annotations under each block before you upload it.
/* ═══════════════════════════════════════════════════════════════
Sleep Environment Optimizer — ESP32
Measures temperature, humidity, illuminance, CO2, sound level and
body movement once a minute through the night. Every actuator is
ramped slowly enough that the system itself can never be the thing
that wakes you.
══════════════════════════════════════════════════════════════════ */
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <Wire.h>
#include <BH1750.h>
#include <Adafruit_SHT31.h>
#include <Adafruit_ADXL345_U.h>
#include <driver/i2s.h>
#include <Preferences.h>
#include <time.h>
#include <math.h>
#define WIFI_SSID "YOUR_WIFI"
#define WIFI_PASS "YOUR_PASSWORD"
#define MQTT_HOST "192.168.1.50"
#define DEVICE_ID "sleep-master"
#define PIN_LED_WARM 25
#define PIN_LED_COOL 26
#define PIN_FAN 33
#define I2S_BCLK 14
#define I2S_LRCL 15
#define I2S_DOUT 32
#define BED_HOUR 22.5f // 22:30
#define WAKE_HOUR 6.75f // 06:45
#define T_BED 21.0f
#define T_DROP 2.5f
#define SUNRISE_MIN 30
#define DARK_LUX 5.0f
#define CO2_NOTE 1800
BH1750 lux;
Adafruit_SHT31 sht = Adafruit_SHT31();
Adafruit_ADXL345_Unified accel(12345);
HardwareSerial co2Serial(2);
WiFiClient net;
PubSubClient mqtt(net);
Preferences prefs;
float tempC = 22, rh = 50, luxNow = 0, spl = 30;
int co2 = 500;
float fanDuty = 0, warmDuty = 0, coolDuty = 0;
uint16_t movementEvents = 0, minutesLogged = 0;
float accelBaseline = 0;
/* ── clock helpers ──────────────────────────────────────────── */
float hourNow() {
time_t t = time(nullptr); struct tm tm; localtime_r(&t, &tm);
return tm.tm_hour + tm.tm_min / 60.0f;
}
bool inSleepWindow(float h) {
return BED_HOUR > WAKE_HOUR ? (h >= BED_HOUR || h < WAKE_HOUR)
: (h >= BED_HOUR && h < WAKE_HOUR);
}
// 0.0 at bedtime, 1.0 at wake time, wrapping past midnight.
float sleepFraction(float h) {
float span = BED_HOUR > WAKE_HOUR ? (24 - BED_HOUR + WAKE_HOUR)
: (WAKE_HOUR - BED_HOUR);
float el = h >= BED_HOUR ? h - BED_HOUR : (24 - BED_HOUR + h);
float f = el / span;
return f < 0 ? 0 : (f > 1 ? 1 : f);
}
/* ── sound level ────────────────────────────────────────────── */
float readSpl() {
static int32_t raw[256];
size_t got;
i2s_read(I2S_NUM_0, raw, sizeof(raw), &got, 100 / portTICK_PERIOD_MS);
int n = got / sizeof(int32_t);
if (!n) return spl;
double acc = 0;
for (int i = 0; i < n; i++) { int16_t s = raw[i] >> 11; acc += (double)s * s; }
float rms = sqrtf(acc / n);
float dbfs = 20.0f * log10f(rms / 32768.0f + 1e-9f);
return dbfs + 120.0f; // calibration offset, see equations
}
/* ── movement ───────────────────────────────────────────────── */
bool movementDetected() {
sensors_event_t e;
accel.getEvent(&e);
float mag = sqrtf(e.acceleration.x * e.acceleration.x +
e.acceleration.y * e.acceleration.y +
e.acceleration.z * e.acceleration.z);
if (accelBaseline == 0) { accelBaseline = mag; return false; }
bool moved = fabsf(mag - accelBaseline) > 0.35f; // m/s^2 threshold
accelBaseline = 0.98f * accelBaseline + 0.02f * mag; // slow drift tracking
return moved;
}
/* ── CO2 ────────────────────────────────────────────────────── */
int readCo2() {
uint8_t cmd[9] = { 0xFF, 0x01, 0x86, 0, 0, 0, 0, 0, 0 };
uint8_t s = 0; for (int i = 1; i < 8; i++) s += cmd[i];
cmd[8] = 0xFF - s + 1;
while (co2Serial.available()) co2Serial.read();
co2Serial.write(cmd, 9);
uint8_t r[9]; int got = 0; uint32_t t0 = millis();
while (got < 9 && millis() - t0 < 300)
if (co2Serial.available()) r[got++] = co2Serial.read();
if (got < 9 || r[0] != 0xFF || r[1] != 0x86) return co2;
return r[2] * 256 + r[3];
}
/* ── ramped actuators ───────────────────────────────────────── */
void setDuty(uint8_t channel, float ¤t, float target, float maxStep) {
if (target > current + maxStep) current += maxStep;
else if (target < current - maxStep) current -= maxStep;
else current = target;
if (current < 0) current = 0;
if (current > 1) current = 1;
ledcWrite(channel, (uint32_t)(current * 4095)); // 12-bit
}
void climateService(float f) {
float target = T_BED - T_DROP * sinf((float)M_PI * powf(f, 0.8f));
float err = tempC - target;
float want = err <= 0 ? 0 : (err > 2.0f ? 1.0f : err / 2.0f);
setDuty(2, fanDuty, want, 0.05f); // max 5 % per minute
}
void lightService(float h, float f) {
bool sunrise = false;
float wakeIn = WAKE_HOUR - h;
if (wakeIn < 0) wakeIn += 24;
if (wakeIn * 60 <= SUNRISE_MIN && inSleepWindow(h)) sunrise = true;
if (!sunrise) {
setDuty(0, warmDuty, 0, 0.02f);
setDuty(1, coolDuty, 0, 0.02f);
return;
}
// 0 at start of the window, 1 at wake time.
float p = 1.0f - (wakeIn * 60.0f / SUNRISE_MIN);
// Warm leads, cool follows — a real sunrise starts red.
setDuty(0, warmDuty, powf(p, 1.4f), 0.05f);
setDuty(1, coolDuty, p < 0.4f ? 0 : powf((p - 0.4f) / 0.6f, 1.8f), 0.05f);
}
/* ── logging ────────────────────────────────────────────────── */
void publishSample() {
JsonDocument d;
d["temp"] = roundf(tempC * 10) / 10.0f;
d["rh"] = roundf(rh);
d["lux"] = roundf(luxNow * 10) / 10.0f;
d["co2"] = co2;
d["spl"] = roundf(spl * 10) / 10.0f;
d["fan"] = roundf(fanDuty * 100);
d["warm"] = roundf(warmDuty * 100);
d["cool"] = roundf(coolDuty * 100);
d["moves"]= movementEvents;
char b[256]; size_t n = serializeJson(d, b, sizeof(b));
mqtt.publish("home/sleep/" DEVICE_ID "/sample", (uint8_t *)b, n, false);
}
void publishNightSummary() {
JsonDocument d;
d["minutes"] = minutesLogged;
d["movements"] = movementEvents;
d["restlessness"] = minutesLogged ? roundf(movementEvents * 1000.0f / minutesLogged) / 10.0f : 0;
d["co2_peak"] = co2;
d["temp_end"] = roundf(tempC * 10) / 10.0f;
char b[224]; size_t n = serializeJson(d, b, sizeof(b));
mqtt.publish("home/sleep/" DEVICE_ID "/night", (uint8_t *)b, n, true);
Serial.printf("Night: %u min, %u movements, restlessness %.1f/hr\n",
minutesLogged, movementEvents,
minutesLogged ? movementEvents * 60.0f / minutesLogged : 0);
movementEvents = 0;
minutesLogged = 0;
}
/* ── setup / loop ───────────────────────────────────────────── */
void setup() {
Serial.begin(115200);
Wire.begin(21, 22);
sht.begin(0x44);
lux.begin(BH1750::CONTINUOUS_HIGH_RES_MODE);
accel.begin(0x53);
accel.setRange(ADXL345_RANGE_2_G);
co2Serial.begin(9600, SERIAL_8N1, 16, 17);
// 12-bit PWM above audible range: LED whine at 2 a.m. is unacceptable.
ledcSetup(0, 25000, 12); ledcAttachPin(PIN_LED_WARM, 0);
ledcSetup(1, 25000, 12); ledcAttachPin(PIN_LED_COOL, 1);
ledcSetup(2, 25000, 12); ledcAttachPin(PIN_FAN, 2);
i2s_config_t cfg = {
.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
.sample_rate = 16000,
.bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT,
.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
.communication_format = I2S_COMM_FORMAT_STAND_I2S,
.intr_alloc_flags = 0, .dma_buf_count = 4, .dma_buf_len = 256, .use_apll = true
};
i2s_pin_config_t pins = { .bck_io_num = I2S_BCLK, .ws_io_num = I2S_LRCL,
.data_out_num = I2S_PIN_NO_CHANGE, .data_in_num = I2S_DOUT };
i2s_driver_install(I2S_NUM_0, &cfg, 0, NULL);
i2s_set_pin(I2S_NUM_0, &pins);
WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASS);
for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(250);
configTime(19800, 0, "pool.ntp.org");
mqtt.setServer(MQTT_HOST, 1883);
Serial.println("Sleep optimizer running");
}
void loop() {
if (!mqtt.connected() && WiFi.status() == WL_CONNECTED) mqtt.connect(DEVICE_ID);
mqtt.loop();
// Movement is polled fast; everything else once a minute.
static uint32_t lastMove = 0;
if (millis() - lastMove > 250) {
lastMove = millis();
if (movementDetected()) movementEvents++;
}
static uint32_t lastMinute = 0;
static bool wasAsleepWindow = false;
if (millis() - lastMinute < 60000) return;
lastMinute = millis();
tempC = sht.readTemperature();
rh = sht.readHumidity();
luxNow = lux.readLightLevel();
co2 = readCo2();
spl = readSpl();
float h = hourNow();
bool inWindow = inSleepWindow(h);
if (inWindow) {
float f = sleepFraction(h);
climateService(f);
lightService(h, f);
minutesLogged++;
publishSample();
if (co2 > CO2_NOTE) mqtt.publish("home/sleep/" DEVICE_ID "/note", "co2-high", false);
} else {
setDuty(2, fanDuty, 0, 0.05f);
setDuty(0, warmDuty, 0, 0.05f);
setDuty(1, coolDuty, 0, 0.05f);
}
if (wasAsleepWindow && !inWindow) publishNightSummary();
wasAsleepWindow = inWindow;
}
Configuration & Calibration
Configuration steps
- Set
BED_HOURandWAKE_HOURto your actual habitual times. The temperature ramp and sunrise are both anchored to them. - Set
T_BEDandT_DROP. Start at 21 °C and 2.5 °C, then adjust from your own restlessness data over a fortnight rather than from a recommendation. - Calibrate the sound offset. The value of 120 in
readSpl()is for an INMP441; measure against a phone SPL app or a reference meter and solve for your own constant. - Disable the MH-Z19B automatic baseline correction if the bedroom never reaches outdoor CO₂ — which it usually does not. See the air quality project for the command.
- Set
DARK_LUXand verify every indicator on the device is genuinely off below it. Walk into the room at 3 a.m. and look — if you can see the device, it is too bright.
Calibration procedure
An uncalibrated sensor produces confident, precise, wrong numbers. Do this once per physical unit and record the constants.
Calibrate the microphone
Play a steady tone and measure with a reference SPL meter or a calibrated phone app at the same position. Adjust the offset constant until they agree. Absolute accuracy is not critical here — repeatability is, since you are comparing nights against each other.
Establish the movement threshold
Log raw accelerometer magnitude for one night at the 0.35 threshold and count events. A typical adult produces 20–60 movement events per night. If you are logging hundreds, the threshold is too low or the sensor is on the frame rather than under the mattress.
Verify the sunrise curve in a dark room
Trigger the sunrise manually and watch the full thirty minutes. It should be barely perceptible for the first five minutes and clearly bright at the end. If it jumps visibly at any point, increase the PWM resolution or reduce the ramp step.
Check the temperature sensor is not self-heating
Compare against a reference thermometer after two hours. If the SHT31 reads high, it is too close to the CO₂ sensor or the regulator — move it before applying an offset.
Network Architecture & Connectivity
| Topic / endpoint | Direction | Payload |
|---|---|---|
home/sleep/<id>/sample | device → broker | JSON per minute: temp, rh, lux, co2, spl, fan, warm, cool, moves |
home/sleep/<id>/night | device → broker (retained) | JSON: minutes, movements, restlessness, co2_peak, temp_end |
home/sleep/<id>/note | device → broker | Silent advisory strings such as "co2-high" |
Message contract between the device and the broker.
Dashboard setup
The chart that pays for the project is a single night with all five variables overlaid and movement events as markers. Clusters of movement lining up with a CO₂ threshold crossing, or with the 05:30 traffic peak, is the kind of finding you cannot get any other way.
The second chart is a scatter of nightly restlessness against mean overnight temperature across thirty nights. If there is a relationship in your room, it will be visible; if there is not, that is also worth knowing before you buy an air conditioner.
Security considerations
- This device knows when you are asleep and when the bedroom is empty. Keep it entirely on your own broker and off any cloud service.
- The microphone measures level only and never records or transmits audio. Verify that in the code before trusting it, and be able to show anyone else who sleeps in the room.
- Use broker authentication and put the node on an IoT VLAN.
Testing Procedure & Expected Output
Test from the bottom up. Confirm power, then each sensor in isolation, then the integrated loop — the first failing step tells you exactly where to look.
| Test | What you should see |
|---|---|
| Read all five sensors at once | Plausible values: 18–26 °C, 30–70 %RH, under 5 lx in a dark room, 500–2500 ppm CO₂, 25–40 dB SPL. |
| Trigger the fan target from off to full | Duty rises by no more than 5 % per minute, reaching full after about twenty minutes — never a step change. |
| Run the sunrise manually | Deep red at the start, warm white at the midpoint, full bright at the end, with no perceptible steps. |
| Stand in the dark room with the device running | Nothing visible. Any indicator LED above 5 lx is a design failure, not a feature. |
| Clap once | The SPL reading rises and settles back within a few seconds. |
| Move on the bed | The movement counter increments. Walking past the bed should not increment it — if it does, the accelerometer is on the frame. |
| Watch one full night | Temperature follows the curve, CO₂ rises steadily, and a night summary is published at wake time. |
| Compare morning CO₂ with the door open versus closed | A difference of well over 1000 ppm — the single most surprising number this project produces. |
Bench-test checklist. If a row fails, stop and fix it before moving on.
Expected output
With everything wired and the firmware uploaded, the Serial Monitor at 115200 baud should look similar to the trace below. Values will differ; the shape of the output should not.
Troubleshooting: Common Errors & Fixes
Performance Optimisation
- Sample once a minute. Every variable here changes over tens of minutes, and the data is more readable at one-minute resolution than at one-second.
- Poll the accelerometer at 4 Hz rather than once a minute — movement events are brief and would be missed entirely at the slow rate.
- Publish per-minute samples without the retain flag and the nightly summary with it. The stream is history; the summary is state.
- Replace every
delay()with amillis()comparison — blocking delays are the single most common cause of dropped readings. - Sample sensors on a fixed cadence and publish on a slower one; you almost never need to transmit at the sampling rate.
- Move networking into its own FreeRTOS task so a slow DNS lookup cannot stall the control loop.
- Use
uint8_t/uint16_twhere the range allows; on an 8-bit AVR a 32-bit add costs four times as much. - Batch several samples into one MQTT publish. Radio time, not CPU time, dominates the energy budget.
- Set the MQTT keep-alive to a value that matches your reporting interval so the broker does not churn reconnections.
- For battery builds use deep sleep between samples: an ESP32 drops from ~160 mA awake to about 10 µA asleep, which is the difference between days and months of runtime.
- Profile before optimising — print
micros()deltas around each stage and fix the slowest one first.
Safety Precautions
- Do not use this to diagnose a sleep disorder. Persistent snoring with pauses, daytime sleepiness or gasping needs a doctor and a proper sleep study — an accelerometer under a mattress cannot detect apnoea.
- If anyone else sleeps in the room, tell them there is a microphone in it, even though it only measures level. Consent matters more than the technical detail.
- Keep the cooling ramp conservative for infants and elderly people, who regulate temperature less effectively.
- Mains voltage kills. Anything on the load side of the relay is at 230 V. Do not work on a powered circuit, and never leave exposed mains wiring on a bench where someone could touch it.
- Keep at least 6 mm of creepage between the mains and low-voltage sides of any board you make, and never route mains tracks under the microcontroller.
- Have a qualified electrician do the final installation into a consumer unit or wall fitting. In most jurisdictions this is a legal requirement, not a suggestion.
- Fit an RCD/RCBO upstream and fuse the load appropriately for its rating.
- Wear eye protection when soldering or cutting, and solder in a ventilated space — rosin flux fumes are a respiratory irritant.
- Power the circuit through a bench supply with a current limit while you are testing. A 300 mA limit turns a wiring mistake into a beep instead of a dead board.
- Disconnect power before changing any wiring. Hot-plugging a sensor onto a live bus is the fastest way to lose a controller.
Maintenance
- Re-check every screw terminal and header after the first week — thermal cycling loosens connections that felt tight on day one.
- Keep the broker and dashboard containers patched, and rotate device credentials at least once a year.
- Recalibrate at the interval given in the calibration section, and keep the constants in a text file next to the firmware — not only in flash.
- Keep a short logbook of firmware versions and what changed. Six months later you will not remember why that constant is 1.083.
Future Improvements & Upgrades
A working v1 is a platform, not a finish line. These are the upgrades that add the most capability for the least rework.
- Add a radar presence sensor to detect when you actually got into bed rather than assuming a fixed bedtime.
- Add heart-rate and breathing detection with a ballistocardiography sensor or a 60 GHz radar — that gets you much closer to real sleep staging.
- Add automatic window or trickle-vent control so high CO₂ is acted on rather than merely logged.
- Add a morning report pushed to your phone with the night's chart and one specific observation rather than a wall of numbers.
- Correlate against a wearable's sleep score for a fortnight to see how much the crude movement proxy actually tracks it.
- Design a proper PCB. Once the breadboard version has run for a month, moving to a two-layer board removes the intermittent-contact failures that dominate prototype faults.
- Add over-the-air firmware updates so you never have to physically reach a deployed node again.
- Add persistent local storage (microSD or the on-chip flash) so a network outage does not create a hole in your data.
- Move configuration out of the source: a captive-portal setup page or a JSON config file makes the build reusable without a recompile.
- Add a battery and solar option so the unit survives a power cut and can be sited away from a socket.
- Write a small test harness that feeds synthetic sensor values through the decision logic, so you can validate thresholds without physically triggering the event.
Frequently Asked Questions
References & Learning Resources
These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.
- Okamoto-Mizuno & Mizuno, "Effects of thermal environment on sleep and circadian rhythm"Journal of Physiological Anthropology, 2012
- Strøm-Tejsen et al., "The effects of bedroom air quality on sleep and next-day performance"Indoor Air, 2016
- Basner et al., "Auditory and non-auditory effects of noise on health"The Lancet, 2014
- Zeitzer et al., "Sensitivity of the human circadian pacemaker to nocturnal light"The Journal of Physiology, 2000
- SHT31-DIS humidity and temperature sensor — datasheetSensirion
- Voss-McCartney algorithm for pink noise generationRobin Whittle
- ESP32 LEDC PWM peripheral — resolution and frequencyEspressif