This design uses a MPXV7002DP for the pressure transducer and includes Web Graphing, LCD Display. I will add alarms and email but this is a starting point.
I place the device between the output of the laser and the fan (at our maker space) to take the exhaust air outside. We were having a problem with people not turning on the fan when the laser was operational or leaving the fan on (and sucking the maker space air through the laser) when the laser was turned off. Hopefully, this post will help you understand the project.
Project Overview
This project creates a network-connected differential pressure monitor with:
- ±2 kPa measurement range
- 5-minute sampling
- 5-hour historical graph (60 points)
- High & Low alarm thresholds
- Physical zero-calibration button
- Web-based calibration initiation button
- 16×2 LCD display
- WiFi auto configuration portal
- Email alert via Gmail SMTP – planned
- EEPROM storage of configuration
- Connected via USB cable for power
System Features
Measurement
- Reads analog output from MPXV7002DP
- Voltage scaled safely for ESP32
- 64-sample averaging (with the highest and lowest thown out for noise reduction)
- Calibration offset stored in EEPROM
Web Interface
- Chart.- line graph
- Threshold configuration
- Email address configuration – planned
- Zero calibration button
- Alarm status display – planned
LCD Display Interface
- Line 1: IP Address
- Line 2: Pressure or ALARM message
Physical Requirements
Main Components
| Item | Notes |
| ESP32 Dev Module | 3.3V logic |
| MPXV7002DP Sensor | 5V supply |
| 16×2 I2C LCD | PCF8574 backpack |
| Push Button | Zero calibration |
| Logic leveler | Convert from 5V to 3.3V |
Electrical Design

Sensor Output Scaling
MPXV7002DP sensor output:
- 0.5V to 4.5V
- Center = 2.5V (0 kPa)
Wiring Diagram (Simplified)
| Component | ESP32 Pin | Notes |
| Pressure Sensor | ADC Pin 34 | Connected via voltage divider |
| LCD (I2C) | SDA (21), SCL (22) | Needs 5V VCC |
| Zero Button | GPIO 25 | Wired between pin and GND (using internal pull-up). |
| Logic Leveler | Converts 5V signals to and from the LCD I2C | |
| Resistors | Voltage divider to get the sensor signal down to 3.3V range |
Resistors are actually 20KΩ and 11KΩ, because that is what I had lying around. The graphic has an illustration for a pressure sensor that was already in Fritzing.
Firmware
Required Libraries
Install:
- WiFiManager
- WebServer
- LiquidCrystal_I2C
ESP32 code
Not all the code is included here but it should give you a good idea of what I am doing.
main
#include <Arduino.h>
#include <LiquidCrystal_I2C.h>
#include "secrets.h"
#include <softpause.h>
#include <WiFi.h>
#include "PressureTransducer.h"
#include <WebServer.h>
#include <Preferences.h>
// #include <ESP_Mail_Client.h>
// ===================== PINS & CONSTANTS =====================
#define ADC_PIN 34
#define ZERO_BUTTON 25
#define ALARM_PIN 26
const int HISTORYLENGTH = 60; // Store 60 samples of history at 1 sample per 5 minutes
// ===================== GLOBALS =====================
LiquidCrystal_I2C lcd(0x27, 16, 2);
PressureTransducer transducer(ADC_PIN);
WebServer server(80);
// AsyncWebServer server(80);
Preferences prefs;
// SMTPSession smtp;
float pressureHistory[HISTORYLENGTH];
float calibrationOffset = 0;
float calibrationFanOn = 0;
float fanThreshold = .05;
float highThreshold = 1.0;
float lowThreshold = -1.0;
bool alarmActive = false;
bool emailPending = false;
float lastPressure = 0;
unsigned long lastSampleTime = 0;
unsigned long lastHistoryTime = 0;
float maxPressure = -999.0; // Track highest pressure since last calibration
float minPressure = 999.0; // Track lowest pressure since last calibration
// Button hold detection variables
unsigned long buttonPressTime = 0;
bool buttonHeldNotified = false;
const unsigned long BUTTON_HOLD_TIME = 5000; // 5 seconds in milliseconds
// ===================== CORE LOGIC =====================
void saveSettings() {
prefs.begin("monitor", false);
prefs.putFloat("calOffset", calibrationOffset);
prefs.putFloat("fanOn", calibrationFanOn);
prefs.putFloat("maxP", maxPressure);
prefs.putFloat("minP", minPressure);
prefs.end();
}
void loadSettings() {
prefs.begin("monitor", true);
calibrationOffset = prefs.getFloat("calOffset", 0.0);
calibrationFanOn = prefs.getFloat("fanOn", 0.0);
maxPressure = prefs.getFloat("maxP", -999.0);
minPressure = prefs.getFloat("minP", 999.0);
prefs.end();
}
void calibrateSensor()
{
// Reset min/max on calibration
maxPressure = -999.0;
minPressure = 999.0;
// setup up calibdration offset
lcd.clear();
lcd.setCursor(0, 0);
lcd.println("Turn off the fan.");
lcd.setCursor(0, 1);
lcd.println("Press the button.");
softPause(2000); // give user time to read instructions
while (digitalRead(ZERO_BUTTON) == HIGH)
{
// wait for button press
softPause(100);
}
// setup the base pressure
calibrationOffset = transducer.readPressure();
lcd.clear();
lcd.setCursor(0, 0);
lcd.println("Baseline set: ");
lcd.setCursor(0, 1);
lcd.println(calibrationOffset, 2);
lcd.print(" kPa ");
Serial.println("Baseline: " + String(calibrationOffset) + " kPa");
softPause(1000);
calibrationFanOn = 0;
lcd.clear();
lcd.setCursor(0, 0);
lcd.println("Turn on the fan.");
while (calibrationFanOn == 0)
{
lcd.setCursor(0, 1);
lcd.println("Press the button.");
while (digitalRead(ZERO_BUTTON) == HIGH)
{
// wait for button press
softPause(100);
}
float tempPressure = transducer.readPressure();
Serial.println("Delta Pressure: " + String(tempPressure+fanThreshold) + " kPa");
Serial.println("calibrationOffset Pressure: " + String(calibrationOffset) + " kPa");
if (tempPressure+fanThreshold >= calibrationOffset)
{
lcd.setCursor(0, 0);
lcd.println("Check fan! ");
}
else
{
calibrationFanOn = tempPressure;
}
}
saveSettings();
lcd.clear();
lcd.setCursor(0, 0);
lcd.print("IP: " + WiFi.localIP().toString());
lcd.println(calibrationFanOn, 2);
lcd.print(" kPa ");
}
// Web server handlers
void handleRoot() {
String html = "<html><head><title>Pressure Monitor</title><style>";
html += "body { font-family: Arial; margin: 20px; }";
html += "h1 { color: #333; }";
html += "p { font-size: 18px; }";
html += "button { padding: 10px 20px; font-size: 16px; background-color: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; }";
html += "button:hover { background-color: #45a049; }";
html += ".stats { background-color: #f0f0f0; padding: 15px; border-radius: 4px; margin: 20px 0; }";
html += ".chart-container { width: 100%; max-width: 800px; margin: 20px auto; }";
html += "</style></head><body>";
html += "<h1>Pressure Monitor</h1>";
html += "<div class='stats'>";
html += "<p>Current Pressure: <strong id='currentPressure'>" + String(lastPressure, 2) + "</strong> kPa</p>";
html += "<p>Max Pressure: <strong id='maxPressure'>" + String(maxPressure, 2) + "</strong> kPa</p>";
html += "<p>Min Pressure: <strong id='minPressure'>" + String(minPressure, 2) + "</strong> kPa</p>";
html += "<p>Time since power-up: <strong id='uptime'>Calculating...</strong></p>";
html += "</div>";
html += "<p>Calibration Offset: <strong id='calOffset'>" + String(calibrationOffset, 2) + "</strong> kPa</p>";
html += "<p>Fan Threshold: <strong id='fanThreshold'>" + String(calibrationFanOn, 2) + "</strong> kPa</p>";
html += "</div>"; // close stats
html += "<div class='chart-container'><canvas id='pressureChart' width='800' height='300'></canvas></div>";
html += "<form action='/calibrate' method='POST'>";
html += "<button type='submit'>Calibrate</button>";
html += "</form>";
html += "https://cdn.jsdelivr.net/npm/chart.js";
html += "<script>";
html += "const ctx = document.getElementById('pressureChart').getContext('2d');";
html += "const pressureChart = new Chart(ctx, {";
html += " type: 'line',";
html += " data: {";
html += " labels: [],";
html += " datasets: [{";
html += " label: 'Pressure (kPa)',";
html += " data: [],";
html += " fill: false,";
html += " borderColor: 'rgba(33,150,243,1)',";
html += " backgroundColor: 'rgba(33,150,243,0.5)',";
html += " tension: 0.2";
html += " }]";
html += " },";
html += " options: {";
html += " responsive: true,";
html += " scales: {";
html += " x: { title: { display: true, text: 'Sample Index (updated every 5 minutes)' } },";
html += " y: { title: { display: true, text: 'Pressure (kPa)' } }";
html += " }";
html += " }";
html += "});";
html += "function updateData() {";
html += " fetch('/data')";
html += " .then(response => response.json())";
html += " .then(data => {";
html += " document.getElementById('currentPressure').innerText = data.current.toFixed(2);";
html += " document.getElementById('maxPressure').innerText = data.max.toFixed(2);";
html += " document.getElementById('minPressure').innerText = data.min.toFixed(2);";
html += " document.getElementById('uptime').innerText = data.uptime;";
html += " document.getElementById('calOffset').innerText = data.calOffset.toFixed(2);";
html += " document.getElementById('fanThreshold').innerText = data.fanThreshold.toFixed(2);";
html += " const history = data.history || [];";
html += " const now = new Date();";
html += " pressureChart.data.labels = history.map((_, idx) => {";
html += " const minutesAgo = (history.length - 1 - idx) * 5;";
html += " const labelTime = new Date(now.getTime() - minutesAgo * 60000);";
html += " const hh = String(labelTime.getHours()).padStart(2, '0');";
html += " const mm = String(labelTime.getMinutes()).padStart(2, '0');";
html += " return `${hh}:${mm}`;";
html += " });";
html += " pressureChart.data.datasets[0].data = history;";
html += " pressureChart.update();";
html += " });";
html += "}";
html += "updateData();";
html += "setInterval(updateData, 5000);";
html += "</script>";
html += "</body></html>";
server.send(200, "text/html", html);
}
void handleData() {
unsigned long uptimeMillis = millis();
int hours = uptimeMillis / 3600000UL;
int minutes = (uptimeMillis % 3600000UL) / 60000UL;
int seconds = (uptimeMillis % 60000UL) / 1000UL;
String uptimeStr = String(hours) + ":" + (minutes < 10 ? "0" : "") + String(minutes) + ":" + (seconds < 10 ? "0" : "") + String(seconds);
String json = "{";
json += "\"current\":" + String(lastPressure, 3) + ",";
json += "\"max\":" + String(maxPressure, 3) + ",";
json += "\"min\":" + String(minPressure, 3) + ",";
json += "\"uptime\":\"" + uptimeStr + "\",";
json += "\"calOffset\":" + String(calibrationOffset, 3) + ",";
json += "\"fanThreshold\":" + String(calibrationFanOn, 3) + ",";
json += "\"history\": [";
for (int i = 0; i < HISTORYLENGTH; i++) {
json += String(pressureHistory[i], 3);
if (i < HISTORYLENGTH - 1) json += ",";
}
json += "]";
json += "}";
server.send(200, "application/json", json);
}
void handleCalibrate() {
calibrateSensor();
String response = "<html><head><title>Calibration Complete</title><style>";
response += "body { font-family: Arial; margin: 20px; }";
response += "h1 { color: #4CAF50; }";
response += "a { display: inline-block; margin-top: 20px; padding: 10px 20px; background-color: #008CBA; color: white; text-decoration: none; border-radius: 4px; }";
response += "a:hover { background-color: #007399; }";
response += "</style></head><body>";
response += "<h1>Calibration Completed</h1>";
response += "<p>Calibration offset: " + String(calibrationOffset, 2) + " kPa</p>";
response += "<p>Fan threshold: " + String(calibrationFanOn, 2) + " kPa</p>";
response += "<p>Min/Max pressures have been reset.</p>";
response += "<a href='/'>Back to Home</a>";
response += "</body></html>";
server.send(200, "text/html", response);
}
void setup() {
Serial.begin(115200);
// define devices
pinMode(ZERO_BUTTON, INPUT_PULLUP);
//pinMode(ALARM_PIN, OUTPUT);
lcd.init();
lcd.backlight();
lcd.setCursor(0,0);
lcd.print("Startup...");
//wifi setup
WiFi.mode(WIFI_STA);
WiFi.begin(SSID, PASSWORD);
int loop=0;
while (WiFi.status() != WL_CONNECTED) {
lcd.clear();
lcd.setCursor(0,loop);
lcd.println("Connecting...");
softPause(200);
loop +=1;
if (loop>1) loop=0;
}
// Connected!
lcd.clear();
lcd.setCursor(0,0);
lcd.print("IP: " + WiFi.localIP().toString());
lcd.setCursor(0,1);
lcd.print("Connected");
// setup ADC
analogReadResolution(12); // 0–4095
analogSetPinAttenuation(ADC_PIN , ADC_11db);
loadSettings();
if (calibrationOffset == 0.0 && calibrationFanOn == 0.0) {
// No calibration data, run calibration - this should only happen on first run or if settings are erased, so we can block here until it's done without impacting the user experience
calibrateSensor();
} else {
lcd.clear();
lcd.setCursor(0,0);
lcd.print("IP: " + WiFi.localIP().toString());
lcd.setCursor(0,1);
lcd.print("Loaded: ");
lcd.print(calibrationFanOn, 2);
lcd.print(" kPa");
softPause(2000);
}
// Set up web server routes
server.on("/", handleRoot);
server.on("/data", handleData);
server.on("/calibrate", HTTP_POST, handleCalibrate);
server.begin();
}
const unsigned long SAMPLE_INTERVAL = 1000; // 1 second
const unsigned long HISTORY_INTERVAL = 300000; // 5 minutes
void loop() {
server.handleClient(); // Handle web server requests
// Monitor button for 5-second hold to trigger calibration
bool buttonPressed = (digitalRead(ZERO_BUTTON) == LOW); // Button is pressed when LOW (INPUT_PULLUP)
if (buttonPressed) {
// Button is currently being held
if (buttonPressTime == 0) {
// Button just pressed, record the time
buttonPressTime = millis();
buttonHeldNotified = false;
} else if (!buttonHeldNotified && (millis() - buttonPressTime >= BUTTON_HOLD_TIME)) {
// Button has been held for 5 seconds
buttonHeldNotified = true;
lcd.clear();
lcd.setCursor(0, 0);
lcd.println("Calibrating...");
Serial.println("ZERO_BUTTON held for 5 seconds - starting calibration");
calibrateSensor();
}
} else {
// Button is not pressed
buttonPressTime = 0;
buttonHeldNotified = false;
}
// take a sample every second
if (millis() - lastSampleTime > SAMPLE_INTERVAL) {
lastSampleTime = millis();
lastPressure = transducer.readPressure();
lcd.setCursor(0, 1);
lcd.println("Last: " + String(lastPressure, 2) + " kPa ");
// Track min and max pressure
if (lastPressure > maxPressure) {
maxPressure = lastPressure;
saveSettings();
}
if (lastPressure < minPressure) {
minPressure = lastPressure;
saveSettings();
}
}
// update history every 5 minutes
if (millis() - lastHistoryTime > HISTORY_INTERVAL) {
lastHistoryTime = millis();
for (int i = 0; i < HISTORYLENGTH - 1; i++) {
pressureHistory[i] = pressureHistory[i + 1];
}
pressureHistory[HISTORYLENGTH - 1] = lastPressure;
}
}
Pressure transducer class
#include "PressureTransducer.h"PressureTransducer::PressureTransducer(int pin) : adcPin(pin) { pinMode(adcPin, INPUT);}#define NUMBER_OF_SAMPLES 64float PressureTransducer::readPressure() { float samples[NUMBER_OF_SAMPLES]; float minSample = 4095.0; float maxSample = 0.0; float sum = 0.0; for (int i = 0; i < NUMBER_OF_SAMPLES; i++) { float temp = analogRead(adcPin); samples[i] = temp; if (temp < minSample) minSample = temp; if (temp > maxSample) maxSample = temp; } // Sum all except min and max for (int i = 0; i < NUMBER_OF_SAMPLES; i++) { if (samples[i] != minSample && samples[i] != maxSample) { sum += samples[i]; } } float avg = sum / (NUMBER_OF_SAMPLES - 2); float adcVoltage = (avg / 4095.0) * 3.3; float sensorVoltage = adcVoltage * 1.55; float pressure_kPa = (sensorVoltage - 2.5); return pressure_kPa;}
Web page functions
- Page loads with initial values
- Chart renders 60 stored samples (gathered every 5 minutes)
- Calibration initiation button displayed
- Every 5 seconds the web page:
- Loads data
- Updates graph
No page refresh required.
Calibration Procedure
There are three ways where a calibration is initiated:
- System sees that there is no preference data defined
- Web page calibration button pressed
- User holds the device Zero button for 5 seconds
When the calibration is initiated, all previous data is lost and the user is asked to:
- Turn off fan
- Press Zero button
- Turn on fan
- Press Zero button
- System auto-adjusts offset and starts tracking
- IP address is displayed in line 0 of the LCD
Housing design
Attaches between the laser smoke exhaust and the exhaust hose to the fan and outside.
Sensor air intake hose is attached to the air flow via Bowden tube – that I had lying around.

When it is all assembled with the LCD housing, it looks like:

Here is a picture of the current software/hardware in production:

Showing the current IP address (so you can look at the web page) and the last pressure reading. Normally, when the fan is on, it is reading between -0.10 and -0.30. If the fan is off, the pressure value is dependent on the difference between the outside barometric pressure and the air pressure inside the maker space.























