399 lines
12 KiB
Arduino
399 lines
12 KiB
Arduino
#include <WiFi.h>
|
||||
|
|
#include <WebServer.h>
|
|||
|
|
#include <DHT.h>
|
|||
|
|
|
|||
|
|
// ----------- WiFi -----------
|
|||
|
|
static const char* WIFI_SSID = "SSID";
|
|||
|
|
static const char* WIFI_PASS = "PASS";
|
|||
|
|
|
|||
|
|
// ----------- DHT -----------
|
|||
|
|
static const int DHT_PIN = 10; // GPIO for DHT DATA
|
|||
|
|
static const int DHT_TYPE = DHT22; // DHT11 / DHT22 / DHT12?
|
|||
|
|
DHT dht(DHT_PIN, DHT_TYPE);
|
|||
|
|
|
|||
|
|
// ----------- MQ-135 -----------
|
|||
|
|
static const int MQ_PIN = A2; // Analog pin for MQ-135 (GPIO2 on ESP32-C3)
|
|||
|
|
static const int MQ_ADC_CALIBRATION = 1900; // ADC baseline in clean air - ADJUST THIS
|
|||
|
|
static const float MQ_AQ_MIN = 0; // Min AQ value
|
|||
|
|
static const float MQ_AQ_MAX = 500; // Max AQ value (standard AQI scale)
|
|||
|
|
static const float MQ_ADC_MAX = 4095.0; // ESP32-C3 12-bit ADC max
|
|||
|
|
|
|||
|
|
// ----------- Server -----------
|
|||
|
|
WebServer server(80);
|
|||
|
|
|
|||
|
|
// ----------- Reading interval -----------
|
|||
|
|
static const unsigned long READ_MS = 2000;
|
|||
|
|
unsigned long lastRead = 0;
|
|||
|
|
|
|||
|
|
// ----------- Latest values -----------
|
|||
|
|
float latestH = NAN;
|
|||
|
|
float latestT_F = NAN;
|
|||
|
|
float latestAQ = NAN;
|
|||
|
|
|
|||
|
|
// ----------- MQ-135 helper functions -----------
|
|||
|
|
|
|||
|
|
// Returns raw ADC average (multiple reads for stability)
|
|||
|
|
int readMQRaw() {
|
|||
|
|
long sum = 0;
|
|||
|
|
const int samples = 16;
|
|||
|
|
for (int i = 0; i < samples; i++) {
|
|||
|
|
sum += analogRead(MQ_PIN);
|
|||
|
|
delay(2);
|
|||
|
|
}
|
|||
|
|
return sum / samples;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Maps raw ADC to a continuous AQ value (0–500 scale)
|
|||
|
|
// Higher ADC = more gas = worse air quality
|
|||
|
|
float readAirQuality() {
|
|||
|
|
int adc = readMQRaw();
|
|||
|
|
|
|||
|
|
// Map ADC range to AQ scale linearly.
|
|||
|
|
// MQ_ADC_CALIBRATION = your baseline in clean air.
|
|||
|
|
// Anything above that degrades air quality proportionally.
|
|||
|
|
//
|
|||
|
|
// Tune MQ_ADC_CALIBRATION by noting the ADC value in fresh air,
|
|||
|
|
// then setting it slightly below that number.
|
|||
|
|
|
|||
|
|
int adcMin = MQ_ADC_CALIBRATION; // clean air baseline
|
|||
|
|
int adcMax = 4095; // sensor saturated
|
|||
|
|
|
|||
|
|
// Linear interpolation between baseline (AQ=0) and saturation (AQ=500)
|
|||
|
|
float aq;
|
|||
|
|
if (adc <= adcMin) {
|
|||
|
|
aq = 0;
|
|||
|
|
} else {
|
|||
|
|
aq = ((float)(adc - adcMin) / (float)(adcMax - adcMin)) * 500.0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
aq = constrain(aq, 0.0, 500.0);
|
|||
|
|
return aq;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
String htmlPage() {
|
|||
|
|
return R"HTML(
|
|||
|
|
<!doctype html>
|
|||
|
|
<html>
|
|||
|
|
<head>
|
|||
|
|
<meta charset="utf-8"/>
|
|||
|
|
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|||
|
|
<title>ESP32 DHT+MQ</title>
|
|||
|
|
<style>
|
|||
|
|
body { font-family: system-ui, Arial; margin: 2rem; }
|
|||
|
|
.card { padding: 1rem 1.2rem; border: 1px solid #ddd; border-radius: 12px; max-width: 800px; }
|
|||
|
|
.val { font-size: 2rem; margin: 0.2rem 0; }
|
|||
|
|
.small { color: #555; font-size: 0.9rem; }
|
|||
|
|
.row { display: flex; gap: 2rem; flex-wrap: wrap; }
|
|||
|
|
.box { flex: 1; min-width: 220px; }
|
|||
|
|
canvas { width: 100%; height: 80%; background: #d3d3d3; border: 1px solid #eee; border-radius: 10px; }
|
|||
|
|
.legend { display: flex; gap: 1rem; align-items: center; margin-top: 0.5rem; flex-wrap: wrap; }
|
|||
|
|
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-right: 0.35rem; }
|
|||
|
|
.btnbar { margin-top: 0.8rem; display: flex; gap: 0.6rem; flex-wrap: wrap; }
|
|||
|
|
button { padding: 0.5rem 0.8rem; border-radius: 10px; border: 1px solid #ddd; background: #fff; cursor: pointer; }
|
|||
|
|
.aq-badge { padding: 0.4rem 0.8rem; border-radius: 8px; font-weight: bold; font-size: 0.9rem; }
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<div class="card">
|
|||
|
|
<h2>ESP32 DHT+MQ</h2>
|
|||
|
|
<div class="small">Updates every 2 seconds • Last ~720 Samples • ~24 Hours</div>
|
|||
|
|
|
|||
|
|
<div class="row">
|
|||
|
|
<div class="box">
|
|||
|
|
<p><b>Temperature:</b></p>
|
|||
|
|
<p class="val" id="temp">--</p>
|
|||
|
|
</div>
|
|||
|
|
<div class="box">
|
|||
|
|
<p><b>Humidity:</b></p>
|
|||
|
|
<p class="val" id="hum">--</p>
|
|||
|
|
</div>
|
|||
|
|
<div class="box">
|
|||
|
|
<p><b>Air Quality:</b></p>
|
|||
|
|
<p class="val" id="aq">--</p>
|
|||
|
|
<p id="aqLabel" class="small">--</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<canvas id="chart" width="800" height="600"></canvas>
|
|||
|
|
<div class="legend">
|
|||
|
|
<div><span class="dot" style="background:#e53935"></span>Temp (°F)</div>
|
|||
|
|
<div><span class="dot" style="background:#1e88e5"></span>Humidity (%)</div>
|
|||
|
|
<div><span class="dot" style="background:#43a047"></span>Air Quality</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="btnbar">
|
|||
|
|
<button onclick="clearData()">Clear chart</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
const canvas = document.getElementById('chart');
|
|||
|
|
const ctx = canvas.getContext('2d');
|
|||
|
|
|
|||
|
|
// Keep separate series; chart shares the x-axis (time)
|
|||
|
|
const MAX_POINTS = 720;
|
|||
|
|
let tempSeries = []; // {x, v}
|
|||
|
|
let humSeries = []; // {x, v}
|
|||
|
|
let aqSeries = []; // {x, v}
|
|||
|
|
|
|||
|
|
function resizeForDPR() {
|
|||
|
|
const dpr = window.devicePixelRatio || 1;
|
|||
|
|
const rect = canvas.getBoundingClientRect();
|
|||
|
|
canvas.width = Math.round(rect.width * dpr);
|
|||
|
|
canvas.height = Math.round(rect.height * dpr);
|
|||
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // draw in CSS pixels
|
|||
|
|
}
|
|||
|
|
window.addEventListener('resize', resizeForDPR);
|
|||
|
|
resizeForDPR();
|
|||
|
|
|
|||
|
|
function getAQLabel(aq) {
|
|||
|
|
if (aq === null || isNaN(aq)) return "--";
|
|||
|
|
if (aq <= 50) return "Excellent";
|
|||
|
|
if (aq <= 100) return "Good";
|
|||
|
|
if (aq <= 150) return "Moderate";
|
|||
|
|
if (aq <= 250) return "Unhealthy for Sensitive";
|
|||
|
|
if (aq <= 350) return "Unhealthy";
|
|||
|
|
return "Hazardous";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getAQColor(aq) {
|
|||
|
|
if (aq === null || isNaN(aq)) return '#43a047';
|
|||
|
|
if (aq <= 50) return '#43a047'; // Green
|
|||
|
|
if (aq <= 100) return '#7cb342'; // Light green
|
|||
|
|
if (aq <= 150) return '#fdd835'; // Yellow
|
|||
|
|
if (aq <= 250) return '#ffb300'; // Orange
|
|||
|
|
if (aq <= 350) return '#fb8c00'; // Dark orange
|
|||
|
|
return '#e53935'; // Red
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function draw() {
|
|||
|
|
const w = canvas.getBoundingClientRect().width;
|
|||
|
|
const h = canvas.getBoundingClientRect().height;
|
|||
|
|
|
|||
|
|
ctx.clearRect(0, 0, w, h);
|
|||
|
|
|
|||
|
|
// Background grid
|
|||
|
|
ctx.fillStyle = '#fff';
|
|||
|
|
ctx.fillRect(0, 0, w, h);
|
|||
|
|
|
|||
|
|
ctx.strokeStyle = '#eee';
|
|||
|
|
ctx.lineWidth = 1;
|
|||
|
|
|
|||
|
|
const gridLines = 5;
|
|||
|
|
for (let i = 1; i < gridLines; i++) {
|
|||
|
|
const y = (h * i) / gridLines;
|
|||
|
|
ctx.beginPath();
|
|||
|
|
ctx.moveTo(0, y);
|
|||
|
|
ctx.lineTo(w, y);
|
|||
|
|
ctx.stroke();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// If no data, stop
|
|||
|
|
if (tempSeries.length === 0 && humSeries.length === 0 && aqSeries.length === 0) return;
|
|||
|
|
|
|||
|
|
// Compute ranges (ignore missing nulls)
|
|||
|
|
const allTemp = tempSeries.map(p => p.v).filter(v => Number.isFinite(v));
|
|||
|
|
const allHum = humSeries.map(p => p.v).filter(v => Number.isFinite(v));
|
|||
|
|
const allAQ = aqSeries.map(p => p.v).filter(v => Number.isFinite(v));
|
|||
|
|
|
|||
|
|
const tempMin = allTemp.length ? Math.min(...allTemp) : 60;
|
|||
|
|
const tempMax = allTemp.length ? Math.max(...allTemp) : 100;
|
|||
|
|
const humMin = allHum.length ? Math.min(...allHum) : 0;
|
|||
|
|
const humMax = allHum.length ? Math.max(...allHum) : 100;
|
|||
|
|
const aqMin = 0;
|
|||
|
|
const aqMax = 500;
|
|||
|
|
|
|||
|
|
// Avoid flat-line divisions
|
|||
|
|
const tempSpan = (tempMax - tempMin) || 1;
|
|||
|
|
const humSpan = (humMax - humMin) || 1;
|
|||
|
|
const aqSpan = (aqMax - aqMin) || 1;
|
|||
|
|
|
|||
|
|
// x scale: index across MAX_POINTS window
|
|||
|
|
const count = Math.max(tempSeries.length, humSeries.length, aqSeries.length);
|
|||
|
|
const xForIndex = (i) => {
|
|||
|
|
if (count <= 1) return 0;
|
|||
|
|
return (w * i) / (count - 1);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function plot(series, color, min, span) {
|
|||
|
|
ctx.strokeStyle = color;
|
|||
|
|
ctx.lineWidth = 2;
|
|||
|
|
ctx.beginPath();
|
|||
|
|
|
|||
|
|
for (let i = 0; i < series.length; i++) {
|
|||
|
|
const v = series[i].v;
|
|||
|
|
if (!Number.isFinite(v)) continue;
|
|||
|
|
|
|||
|
|
const x = xForIndex(i);
|
|||
|
|
const y = h - ((v - min) / span) * (h - 20) - 10; // padding
|
|||
|
|
|
|||
|
|
if (i === 0) ctx.moveTo(x, y);
|
|||
|
|
else ctx.lineTo(x, y);
|
|||
|
|
}
|
|||
|
|
ctx.stroke();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Draw both series
|
|||
|
|
plot(tempSeries, '#e53935', tempMin, tempSpan);
|
|||
|
|
plot(humSeries, '#1e88e5', humMin, humSpan);
|
|||
|
|
|
|||
|
|
// AQ uses dynamic color based on current/latest value
|
|||
|
|
const latestAQVal = aqSeries.length > 0 ? aqSeries[aqSeries.length - 1].v : 50;
|
|||
|
|
plot(aqSeries, getAQColor(latestAQVal), aqMin, aqSpan);
|
|||
|
|
|
|||
|
|
// Labels (min/max)
|
|||
|
|
ctx.fillStyle = '#333';
|
|||
|
|
ctx.font = '12px system-ui, Arial';
|
|||
|
|
ctx.fillText(`Temp range: ${tempMin.toFixed(1)}–${tempMax.toFixed(1)} °F`, 10, 16);
|
|||
|
|
ctx.fillText(`Hum range: ${humMin.toFixed(1)}–${humMax.toFixed(1)} %`, 10, 32);
|
|||
|
|
if (allAQ.length > 0) {
|
|||
|
|
ctx.fillText(`AQ range: ${Math.min(...allAQ.filter(v => isFinite(v))).toFixed(0)}–${Math.max(...allAQ.filter(v => isFinite(v))).toFixed(0)}`, 10, 48);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function pushPoint(series, value) {
|
|||
|
|
if (Number.isFinite(value)) {
|
|||
|
|
series.push({x: Date.now(), v: value});
|
|||
|
|
while (series.length > MAX_POINTS) series.shift();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function clearData() {
|
|||
|
|
tempSeries = [];
|
|||
|
|
humSeries = [];
|
|||
|
|
aqSeries = [];
|
|||
|
|
draw();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function refresh() {
|
|||
|
|
try {
|
|||
|
|
const res = await fetch('/data?nocache=' + Date.now());
|
|||
|
|
const j = await res.json();
|
|||
|
|
|
|||
|
|
// Update numbers
|
|||
|
|
if (j.t_f === null) document.getElementById('temp').textContent = '--';
|
|||
|
|
else document.getElementById('temp').textContent = j.t_f.toFixed(1) + ' °F';
|
|||
|
|
|
|||
|
|
if (j.h === null) document.getElementById('hum').textContent = '--';
|
|||
|
|
else document.getElementById('hum').textContent = j.h.toFixed(1) + ' %';
|
|||
|
|
|
|||
|
|
if (j.aq === null) {
|
|||
|
|
document.getElementById('aq').textContent = '--';
|
|||
|
|
document.getElementById('aqLabel').textContent = '--';
|
|||
|
|
} else {
|
|||
|
|
document.getElementById('aq').textContent = Math.round(j.aq);
|
|||
|
|
document.getElementById('aqLabel').textContent = getAQLabel(j.aq);
|
|||
|
|
document.getElementById('aqLabel').className = 'small aq-badge';
|
|||
|
|
document.getElementById('aqLabel').style.backgroundColor = getAQColor(j.aq) + '40';
|
|||
|
|
document.getElementById('aqLabel').style.color = j.aq > 250 ? '#fff' : '#333';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Update series + chart
|
|||
|
|
if (j.t_f !== null) tempSeries.push({x: Date.now(), v: j.t_f});
|
|||
|
|
if (j.h !== null) humSeries.push({x: Date.now(), v: j.h});
|
|||
|
|
if (j.aq !== null) aqSeries.push({x: Date.now(), v: j.aq});
|
|||
|
|
|
|||
|
|
while (tempSeries.length > MAX_POINTS) tempSeries.shift();
|
|||
|
|
while (humSeries.length > MAX_POINTS) humSeries.shift();
|
|||
|
|
while (aqSeries.length > MAX_POINTS) aqSeries.shift();
|
|||
|
|
|
|||
|
|
draw();
|
|||
|
|
} catch (e) {
|
|||
|
|
// ignore fetch errors
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
refresh();
|
|||
|
|
setInterval(refresh, 2000);
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|
|||
|
|
)HTML";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void handleRoot() {
|
|||
|
|
server.send(200, "text/html", htmlPage());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void handleData() {
|
|||
|
|
// Convert NaN to null for easy JSON handling
|
|||
|
|
float h = latestH;
|
|||
|
|
float t = latestT_F;
|
|||
|
|
float aq = latestAQ;
|
|||
|
|
|
|||
|
|
String json = "{";
|
|||
|
|
json += "\"h\": " + (isnan(h) ? String("null") : String(h, 1)) + ",";
|
|||
|
|
json += "\"t_f\": " + (isnan(t) ? String("null") : String(t, 1)) + ",";
|
|||
|
|
json += "\"aq\": " + (isnan(aq) ? String("null") : String(aq, 1));
|
|||
|
|
json += "}";
|
|||
|
|
|
|||
|
|
server.send(200, "application/json", json);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void setup() {
|
|||
|
|
Serial.begin(115200);
|
|||
|
|
delay(200);
|
|||
|
|
Serial.println("ESP32-C3 DHT11 + MQ-135 starting...");
|
|||
|
|
|
|||
|
|
dht.begin();
|
|||
|
|
|
|||
|
|
// Configure MQ-135 pin as analog input
|
|||
|
|
analogReadResolution(12); // 12-bit ADC (0-4095)
|
|||
|
|
|
|||
|
|
WiFi.mode(WIFI_STA);
|
|||
|
|
WiFi.begin(WIFI_SSID, WIFI_PASS);
|
|||
|
|
|
|||
|
|
Serial.print("Connecting to WiFi");
|
|||
|
|
while (WiFi.status() != WL_CONNECTED) {
|
|||
|
|
delay(300);
|
|||
|
|
Serial.print(".");
|
|||
|
|
}
|
|||
|
|
Serial.println();
|
|||
|
|
|
|||
|
|
Serial.print("Connected. IP address: ");
|
|||
|
|
Serial.println(WiFi.localIP());
|
|||
|
|
|
|||
|
|
server.on("/", handleRoot);
|
|||
|
|
server.on("/data", handleData);
|
|||
|
|
server.begin();
|
|||
|
|
Serial.println("HTTP server started.");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void loop() {
|
|||
|
|
// keep server responsive
|
|||
|
|
server.handleClient();
|
|||
|
|
|
|||
|
|
unsigned long now = millis();
|
|||
|
|
if (now - lastRead < READ_MS) return;
|
|||
|
|
lastRead = now;
|
|||
|
|
|
|||
|
|
// Read DHT sensor
|
|||
|
|
float h = dht.readHumidity();
|
|||
|
|
float tC = dht.readTemperature(); // Celsius
|
|||
|
|
|
|||
|
|
// Read MQ-135
|
|||
|
|
float aq = readAirQuality();
|
|||
|
|
|
|||
|
|
if (isnan(h) || isnan(tC)) {
|
|||
|
|
Serial.println("DHT read failed (check wiring/pull-up).");
|
|||
|
|
latestH = NAN;
|
|||
|
|
latestT_F = NAN;
|
|||
|
|
} else {
|
|||
|
|
latestH = h;
|
|||
|
|
latestT_F = dht.convertCtoF(tC); // Already in F
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
latestAQ = aq;
|
|||
|
|
|
|||
|
|
Serial.print("Humidity: ");
|
|||
|
|
Serial.print(latestH, 1);
|
|||
|
|
Serial.print(" %\tTemp: ");
|
|||
|
|
Serial.print(latestT_F, 1);
|
|||
|
|
Serial.print(" F\tAQ: ");
|
|||
|
|
Serial.print(latestAQ, 1);
|
|||
|
|
Serial.println();
|
|||
|
|
|
|||
|
|
Serial.print("Connected. IP address: ");
|
|||
|
|
Serial.println(WiFi.localIP());
|
|||
|
|
}
|