Project: Environmental Monitoring Station
This project builds a complete outdoor/indoor environmental monitoring station — multiple sensors, local display, cloud logging, alerting, and a live Grafana dashboard.
System Overview
┌─────────────────────────────────────┐
│ ESP32 Monitoring Node │
│ │
│ Sensors: │
│ ├── DHT22 → Temperature/Humidity│
│ ├── BME280 → Pressure/Altitude │
│ ├── MQ-135 → Air Quality (CO2eq)│
│ ├── BH1750 → Light Level (lux) │
│ └── Rain Sensor → Precipitation │
│ │
│ Display: │
│ └── SSD1306 OLED (128×64) │
│ │
│ Power: Solar + LiPo + TP4056 │
└────────────────┬────────────────────┘
│ MQTT over WiFi
▼
┌────────────────────────────────────┐
│ Cloud Pipeline │
│ ├── ThingSpeak (quick charts) │
│ ├── InfluxDB (time-series) │
│ ├── Grafana (dashboard) │
│ └── Twilio (SMS alerts) │
└────────────────────────────────────┘Complete Device Firmware (MicroPython)
python
# environmental_station.py
import dht, utime, network, ujson, machine, gc
from machine import Pin, I2C, ADC, PWM
from umqtt.simple import MQTTClient
# ── Config ─────────────────────────────────────────
from config import (
WIFI_SSID, WIFI_PASSWORD,
MQTT_BROKER, DEVICE_ID, LOCATION,
THINGSPEAK_KEY
)
MQTT_TOPIC = f"environment/{LOCATION}/{DEVICE_ID}"
SLEEP_MINUTES = 5
ALERT_TEMP_HIGH = 38.0
ALERT_AQI_BAD = 200 # ppm threshold
# ── Hardware ────────────────────────────────────────
dht_pin = Pin(4)
dht22 = dht.DHT22(dht_pin)
i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=400_000)
# BME280
try:
import bme280
bme = bme280.BME280(i2c=i2c)
HAS_BME = True
except Exception as e:
print(f"BME280 not found: {e}")
HAS_BME = False
# BH1750 light sensor
BH1750_ADDR = 0x23
i2c.writeto(BH1750_ADDR, b'\x10') # Continuous high-res mode
# MQ-135 air quality (ADC)
mq_adc = ADC(Pin(34))
mq_adc.atten(ADC.ATTN_11DB)
# OLED display
try:
import ssd1306
oled = ssd1306.SSD1306_I2C(128, 64, i2c, addr=0x3C)
HAS_OLED = True
except:
HAS_OLED = False
# ── Reading functions ───────────────────────────────
def read_dht22():
for _ in range(3):
try:
dht22.measure()
return dht22.temperature(), dht22.humidity()
except OSError:
utime.sleep_ms(500)
return None, None
def read_bme280():
if not HAS_BME:
return None, None, None
try:
t, p, h = bme.read_compensated_data()
return t / 100, p / 25600, h / 1024
except:
return None, None, None
def read_light():
utime.sleep_ms(200)
data = i2c.readfrom(BH1750_ADDR, 2)
return (data[0] << 8 | data[1]) / 1.2 # lux
def read_air_quality():
# Oversample 16×, trim outliers, convert to rough ppm
samples = sorted([mq_adc.read() for _ in range(16)])
raw = sum(samples[2:-2]) / 12 # Trimmed mean
voltage = raw / 4095 * 3.3
if voltage < 0.1: return None
Rs = (3.3 - voltage) / voltage * 10_000
ppm = 116.6 * (Rs / 76.63) ** -2.77
return round(ppm, 0)
def read_battery():
# Voltage divider: 100kΩ / 100kΩ → ×2
adc_batt = ADC(Pin(35))
adc_batt.atten(ADC.ATTN_11DB)
raw = sum(adc_batt.read() for _ in range(8)) / 8
voltage = raw / 4095 * 3.3 * 2 # Divider correction
return round(voltage, 2)
# ── Display update ──────────────────────────────────
def update_display(temp, hum, pres, lux, aqi):
if not HAS_OLED:
return
oled.fill(0)
oled.fill_rect(0, 0, 128, 11, 1)
oled.text(f" {LOCATION}", 0, 2, 0)
if temp: oled.text(f"T: {temp:.1f}C H:{hum:.0f}%", 0, 14, 1)
if pres: oled.text(f"P: {pres:.1f}hPa", 0, 26, 1)
if lux: oled.text(f"L: {lux:.0f} lux", 0, 38, 1)
if aqi:
quality = "GOOD" if aqi < 100 else ("FAIR" if aqi < 200 else "POOR")
oled.text(f"AQ: {quality}", 0, 50, 1)
oled.show()
# ── WiFi ────────────────────────────────────────────
def connect_wifi():
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.connect(WIFI_SSID, WIFI_PASSWORD)
for _ in range(20):
if wlan.isconnected(): return wlan
utime.sleep_ms(500)
return None
# ── ThingSpeak upload ───────────────────────────────
def upload_thingspeak(temp, hum, pres, lux, aqi, battery):
try:
import urequests
url = (f"https://api.thingspeak.com/update?"
f"api_key={THINGSPEAK_KEY}"
f"&field1={temp or ''}&field2={hum or ''}"
f"&field3={pres or ''}&field4={lux or ''}"
f"&field5={aqi or ''}&field6={battery or ''}")
r = urequests.get(url, timeout=10)
r.close()
return True
except:
return False
# ── MQTT publish ────────────────────────────────────
def publish_mqtt(wlan, data):
try:
client = MQTTClient(DEVICE_ID, MQTT_BROKER, keepalive=30)
client.connect()
client.publish(MQTT_TOPIC, ujson.dumps(data), retain=True)
client.disconnect()
return True
except Exception as e:
print(f"MQTT error: {e}")
return False
# ── Main ────────────────────────────────────────────
def main():
gc.collect()
# Read all sensors
temp, hum = read_dht22()
_, pres, _ = read_bme280()
lux = read_light()
aqi = read_air_quality()
battery_v = read_battery()
battery_pct = max(0, min(100, int((battery_v - 3.3) / 0.9 * 100)))
print(f"T={temp}°C H={hum}% P={pres}hPa L={lux}lx AQ={aqi}ppm Bat={battery_v}V")
# Update local display
update_display(temp, hum, pres, lux, aqi)
# Build payload
payload = {
"device_id": DEVICE_ID,
"location": LOCATION,
"temperature": temp,
"humidity": hum,
"pressure": pres,
"light_lux": round(lux, 1) if lux else None,
"air_quality": aqi,
"battery_v": battery_v,
"battery_pct": battery_pct,
"timestamp": utime.time()
}
# Upload when WiFi available
wlan = connect_wifi()
if wlan:
publish_mqtt(wlan, payload)
upload_thingspeak(temp, hum, pres, lux, aqi, battery_pct)
wlan.active(False) # Disable WiFi to save power
else:
print("WiFi unavailable — skipping upload")
# Deep sleep until next reading
print(f"Sleeping {SLEEP_MINUTES} minutes...")
machine.deepsleep(SLEEP_MINUTES * 60 * 1000)
main()Alert System (Python — Server Side)
python
# alerts.py — monitors MQTT and sends SMS alerts via Twilio
import paho.mqtt.client as mqtt
import json
from twilio.rest import Client as TwilioClient
import os
import time
TWILIO_SID = os.environ["TWILIO_ACCOUNT_SID"]
TWILIO_TOKEN = os.environ["TWILIO_AUTH_TOKEN"]
TWILIO_FROM = os.environ["TWILIO_PHONE_FROM"]
ALERT_PHONE = os.environ["ALERT_PHONE_TO"]
twilio = TwilioClient(TWILIO_SID, TWILIO_TOKEN)
THRESHOLDS = {
"temperature": {"max": 38.0, "min": 0.0},
"humidity": {"max": 90.0, "min": 20.0},
"air_quality": {"max": 200.0},
"battery_pct": {"min": 15},
}
last_alert = {}
ALERT_COOLDOWN = 3600 # 1 hour between same-type alerts
def send_sms(message):
try:
twilio.messages.create(
body=message, from_=TWILIO_FROM, to=ALERT_PHONE
)
print(f"SMS sent: {message[:50]}")
except Exception as e:
print(f"SMS failed: {e}")
def check_alerts(data):
device = data.get("device_id", "unknown")
location = data.get("location", "?")
now = time.time()
alerts_sent = []
for metric, limits in THRESHOLDS.items():
value = data.get(metric)
if value is None:
continue
alert_key = f"{device}_{metric}"
if now - last_alert.get(alert_key, 0) < ALERT_COOLDOWN:
continue # Cooldown active
if "max" in limits and value > limits["max"]:
msg = f"⚠️ IoT Alert [{location}]\n{metric}: {value} exceeds max {limits['max']}"
send_sms(msg)
last_alert[alert_key] = now
alerts_sent.append(metric)
elif "min" in limits and value < limits["min"]:
msg = f"⚠️ IoT Alert [{location}]\n{metric}: {value} below min {limits['min']}"
send_sms(msg)
last_alert[alert_key] = now
alerts_sent.append(metric)
return alerts_sent
def on_message(client, userdata, msg):
try:
data = json.loads(msg.payload)
alerts = check_alerts(data)
if alerts:
print(f"Alerts triggered: {alerts}")
except:
pass
client = mqtt.Client("alert-system")
client.on_connect = lambda c, u, f, r: c.subscribe("environment/#", qos=1)
client.on_message = on_message
client.connect("localhost", 1883, 60)
client.loop_forever()Grafana Dashboard Setup
InfluxDB datasource panels:
Panel 1: Temperature Gauge
Type: Gauge
Min: -10°C Max: 50°C
Thresholds: Green < 28, Yellow < 35, Red ≥ 35
Panel 2: Humidity + Temperature (time series, dual Y-axis)
Query A: temperature field (left axis)
Query B: humidity field (right axis)
Panel 3: Air Quality Status
Type: Stat
Mappings: 0-100=GOOD, 101-200=FAIR, 201+=POOR
Panel 4: Battery Level
Type: Bar gauge
Min: 0% Max: 100%
Alert: < 20%
Panel 5: Pressure (last 24h)
Type: Time series
Show: Pressure trend (drop > 5 hPa in 3h = rain likely)Summary
- The station reads 5+ environmental parameters every 5 minutes using deep sleep for battery efficiency
- OLED display provides local readout without any network dependency
- ThingSpeak gives instant cloud charts; InfluxDB + Grafana for production dashboards
- Twilio SMS alerts are rate-limited with cooldowns to prevent alert fatigue
- Deep sleep + solar power enables indefinite battery-free outdoor operation
- The pattern is reusable — swap sensors, adjust thresholds, change cloud platform without restructuring
Next, we'll build an asset tracking system with GPS.