A multi-sensor environmental monitor built on the ESP32. It reads up to three I²C sensors once a minute, logs every reading to a microSD card as CSV, and serves a live, mobile-friendly web dashboard with zoomable charts — no cloud, no app, just a web page on your local network.
The sensor suite (temperature, humidity, barometric pressure, VOC/gas, O₂, and CO₂) is chosen so the same hardware works as a general room monitor today and as the foundation for gas-analysis experiments later (see Where this is going).
- Up to three I²C sensors, all optional and independent. BME680
(temperature, humidity, pressure, gas/VOC), Sensirion SCD41 (CO₂, plus its own
temperature and humidity), and DFRobot Gravity SEN0322 (O₂). Run one, two, or
all three — if a sensor is absent or fails to initialize, the rig keeps
running, logs
NaNfor that sensor's columns, and the dashboard simply hides the charts that have no data. No code edits required to change the sensor set. - 60-second logging to microSD as a 14-column CSV (one timestamped row per reading). Append-only; at this cadence a small card holds decades of data.
- Live web dashboard with up to six charts — temperature, humidity, pressure, gas/VOC, oxygen, and CO₂ — served directly from the ESP32.
- Real time-axis plotting. Points are placed by their actual timestamp, so tooltips and positions always agree, and gaps (for example, across a reboot) render as clean breaks rather than misleading straight lines.
- History survives reboots. On startup the device re-seeds its in-memory plot buffer from the tail of the CSV, so the charts come up already populated instead of blank after a power cycle or reflash.
- Three-way units selector — °C/hPa, °F/mmHg, or °F/PSI — switchable live in the browser, with a configurable default.
- Optional translucent "shadow" fill under each plot line, with blended overlaps on the dual-sensor temperature and humidity charts. Toggleable.
- Pinch / wheel zoom and drag-pan on the time axis (works on desktop and mobile), with a one-click reset.
- Download or clear the log from the dashboard. Downloads are named with a
sortable timestamp (
sensor_log_YYYY-MM-DD_HHMMSS.csv); clearing is password-protected. - Bounded, fast display regardless of file size. The dashboard reads a capped in-RAM ring buffer, so plot performance stays constant even as the on-disk log grows for months.
- All settings in one place. Tunables live in
config.h; WiFi credentials are isolated in a git-ignoredsecrets.h, so the project is safe to publish as-is.
The three sensors were chosen to measure the gases and conditions that matter for biology and culture-metabolism work, while still being a useful general environmental monitor:
- CO₂ (SCD41) is a near-universal proxy for metabolic activity — respiring organisms produce it, so its rise tracks biological activity in an enclosed volume.
- O₂ (SEN0322) is the complementary signal: aerobic metabolism consumes it, so O₂ depletion and CO₂ accumulation together tell a coherent story.
- VOC / gas resistance (BME680) adds a supporting channel for volatile organic compounds, useful for detecting metabolic byproducts and general air quality.
- Temperature, humidity, and pressure provide the environmental context that every other reading must be interpreted against — and the BME680 and SCD41 each measure temperature and humidity independently, giving a built-in cross-check.
For a sealed-chamber experiment, the SCD41's automatic self-calibration should
be off (it assumes periodic exposure to fresh ~400 ppm air and would
otherwise corrupt an elevated, closed-system CO₂ baseline). This is a one-line
setting in config.h — see Configuration.
Specific parts used in the reference build are listed below with their product descriptions, so the item is identifiable even if a purchase link goes stale. Commodity parts (jumper wires, headers, breadboard) can be substituted freely.
| Part | Notes |
|---|---|
| ESP32 dev board | Lonely Binary ESP32 IPEX Gold Edition (external antenna + PinPulse shield with GPIO LEDs). Any ESP32 dev board works; the external antenna helps WiFi range. |
| BME680 sensor | Temperature / humidity / pressure / gas (VOC), I²C + SPI breakout. I²C address 0x77. |
| SCD41 sensor | Sensirion CO₂ + temperature + humidity, I²C. Address 0x62. |
| Gravity SEN0322 | DFRobot electrochemical O₂ sensor, 0–25 % vol, I²C. Address set by an on-board dial (default 0x73). DFRobot product page is the durable reference. |
| microSD module | SPI microSD breakout with level shifting. |
| microSD card | 16 GB (C10/U1) is plenty; the log grows ~140 KB/day. |
| Breadboard, jumper wires, pin headers | Standard prototyping parts. |
| Decoupling capacitors | 100 nF + 47 µF across the 3V3 rail near the sensors, for supply stability. |
The physical enclosure, mounting hardware, and assembly are documented separately in HARDWARE.md.
All three sensors share one I²C bus. The reference build uses a consistent 4-pin color convention on the sensor cables: blue = SCL, yellow = SDA, black = GND, red = 3V3.
| Signal | ESP32 pin | Goes to |
|---|---|---|
| SDA (I²C data) | GPIO 21 | every sensor's SDA |
| SCL (I²C clock) | GPIO 22 | every sensor's SCL |
| 3V3 | 3V3 | every sensor's VCC |
| GND | GND | every sensor's GND |
| microSD CS | GPIO 5 | SD module CS |
| microSD SCK | GPIO 18 | SD module SCK |
| microSD MISO | GPIO 19 | SD module MISO |
| microSD MOSI | GPIO 23 | SD module MOSI |
I²C addresses
| Sensor | Address | Measures |
|---|---|---|
| BME680 | 0x77 |
temperature, humidity, pressure, gas/VOC |
| SCD41 | 0x62 |
CO₂, temperature, humidity |
| Gravity SEN0322 | 0x73 (dial; alternates 0x70–0x73) |
O₂ % |
A Fritzing breadboard diagram (showing the color convention and the decoupling
caps) and close-up wiring photos are in HARDWARE.md. The
editable Fritzing project and its custom sensor parts are in
hardware/fritzing/.
This project builds with PlatformIO (VS Code or CLI). The same libraries also install through the Arduino IDE Library Manager if you prefer that toolchain.
-
Clone the repository and open the folder in PlatformIO.
-
Create your
secrets.h. Copy the template and fill in your WiFi credentials:cp src/secrets.h.example src/secrets.h
Then edit
src/secrets.h:#define WIFI_SSID "Your_Network" #define WIFI_PASSWORD "Your_Password"
secrets.his git-ignored, so your credentials never get committed. -
Review
config.hfor anything you want to change (logging interval, default units, sensor addresses, etc.) — see Configuration. -
Build and upload to the ESP32. PlatformIO pulls the libraries listed in
platformio.iniautomatically. -
Open the serial monitor at 115200 baud. The board prints each sensor's init result, the assigned IP address, and how many history rows it seeded from the SD card. Browse to that IP on any device on the same network.
A healthy boot with all three sensors looks like this:
ESP32 Environmental Wifi Webserver
=====================================
Initializing sensors...
✓ BME680 initialized
SCD41 auto-self-calibration: OFF
✓ SCD41 initialized
✓ Gravity O2 initialized
Initializing SD card...
✓ Data file exists
Connecting to WiFi: homeRF
...
IP address: 192.168.5.157
Waiting for NTP time sync...
Local time: Mon Jun 29 21:20:34 2026
Seeded ring buffer with 1440 row(s) from /sensor_log.csv
Web server started
Read @ 21:21:27 | T_BME: 27.09C | T_SCD: 25.61C | CO2: 725.00 ppm | O2: 20.72 %
Data logged to SD card
Each sensor is independent — if one is absent the board prints that and keeps going with the rest.
Libraries (declared in platformio.ini as lib_deps; also available via
the Arduino IDE Library Manager):
esp32async/ESPAsyncWebServeresp32async/AsyncTCPadafruit/Adafruit BME680 Libraryadafruit/Adafruit Unified Sensorsensirion/Sensirion I2C SCD4xdfrobot/DFRobot_OxygenSensor
Everything tunable lives in config.h, documented inline. Highlights:
| Setting | Purpose |
|---|---|
MAIN_TITLE, SUBTITLE, BYLINE |
Dashboard header text (and link). |
TZ_INFO, NTP_SERVER |
Timezone (POSIX string, auto-DST) and time source. |
LOG_INTERVAL_MS |
Time between readings (default 60000 = 60 s). |
HISTORY_MAX |
Readings kept in RAM for the live plot (default 1440 = 24 h). |
DEFAULT_UNIT |
Units on load: 0 = °C/hPa, 1 = °F/mmHg, 2 = °F/PSI. |
PLOT_SHADOW |
true for the filled "shadow" look, false for clean lines. |
BME680_ADDR, SCD41_ADDR, O2_ADDR |
I²C addresses. |
GAS_HEAT_TEMP, GAS_HEAT_TIME |
BME680 gas-heater profile (VOC sensing). |
SCD41_AUTO_CALIBRATION |
Off by default — leave off for sealed-chamber work; turn on for open-room monitoring. |
DO_O2_CALIBRATION, O2_CALIBRATION_VOL |
One-point O₂ calibration at boot. |
O2_AVERAGE_COUNT |
Samples averaged per O₂ reading (default 10). |
WARMUP_SKIP_ROWS |
Discard the first N readings (sensor warm-up). |
CSV_FILENAME |
Log file name on the card. |
DELETE_LOGFILE_PW |
Password to clear the log from the dashboard. Ships as a placeholder (admin) — change it to your own before deploying. |
WiFi credentials are deliberately not here — they live in secrets.h.
The page auto-refreshes every 30 seconds. Controls:
- Units buttons — switch temperature/pressure units live.
- Refresh Charts — redraw immediately from the current in-RAM history (carried over from the original tutorial; the page also auto-refreshes).
- Download CSV — download the complete on-disk log under a timestamped filename.
- Delete All Data — clear the log file and the live buffer (password-gated).
- Reset Zoom — return all charts to the full view after zooming/panning.
Charts appear only for sensors that are present, so the layout adapts to your hardware automatically.
The dashboard reshapes itself to whatever hardware is connected — there are no broken charts or zero-valued plots for missing sensors. With no sensors it still serves the header, controls, and latest-reading line; each sensor you add brings its charts online. The full-page captures below show the progression from zero to all three sensors (PDF, opens in your browser):
- Dashboard — no sensors (header, controls, and latest-reading only)
- Dashboard — one sensor (SCD41)
- Dashboard — two sensors (SCD41 + BME680)
- Dashboard — all three sensors (+ SEN0322)
One timestamped row per reading, 14 columns. Column names follow a
measurement_sensor_unit convention so each is self-describing. Absent or
failed sensors record NaN (read as "missing" by MATLAB, pandas, and Excel —
never a misleading 0):
temp_bme680_C, temp_bme680_F, temp_scd41_C, temp_scd41_F,
humidity_bme680_percent, humidity_scd41_percent,
pressure_bme680_hPa, pressure_bme680_mmHg, pressure_bme680_psi,
gas_bme680_kOhms, o2_sen0322_percent, co2_scd41_ppm, time, day
Each measurement is stored in every unit it could be wanted in (temperature in both °C and °F; pressure in hPa, mmHg, and PSI) so the file drops straight into a spreadsheet or analysis script with no conversion.
Worked examples for loading and plotting the log — in Python, MATLAB, and
Excel, including how each handles the NaN values and reboot gaps — are in
the examples/ folder, along with a real sample log to run them
against.
A common worry with multiple devices on a shared I²C bus is whether the parallel breakout pull-up resistors load the bus too heavily. On this build they don't. Bench measurements (Tektronix MSO64B) confirm it: the clock runs at the ESP32's default 99.2 kHz, the SDA rise times are 315–328 ns — about a third of the 1000 ns standard-mode limit — and all three sensors decode without protocol errors.
The full write-up — clock spectrum, per-sensor rise times, protocol decodes, and
how to reproduce the captures — is in SIGNAL_INTEGRITY.md,
with the decoded transactions saved as CSV in docs/scope-data/.
This is intentionally a general-purpose environmental monitor, but the
architecture was built with a specialization in mind: a slow-motion
gas-analysis instrument for biology and bacterial-culture metabolism, where
CO₂ accumulation and O₂ depletion in a sealed chamber track the activity of a
living culture over time. The seams for that fork are already in place — the
config.h/secrets.h split, the per-sensor presence handling, and the
SCD41_AUTO_CALIBRATION toggle (which flips the instrument between open-room
monitoring and sealed-chamber experiments with a single setting). A future fork
will add a syringe-pump module for timed gas sampling. If you want to build your
own specialized instrument, this codebase is meant to be reconfigured rather
than rewritten.
The charting and SD-logging foundation began with Rui Santos's BME280 web-server tutorial at Random Nerd Tutorials, which this project extends from a single sensor to a configurable multi-sensor instrument with adaptive display, time-axis plotting, reboot-safe history, and a publishable configuration structure.
This project was developed through a collaboration between myself, an experienced engineer, and modern AI assistants — primarily Anthropic's Claude and to a lesser extent OpenAI's ChatGPT. It seems worthwhile to be transparent about how that collaboration actually worked.
The engineering direction and technical decisions remained human: the selection of the sensor suite and its scientific rationale, the system architecture, the data-storage model, the choice of append-only logging, timezone-aware timestamp handling, startup initialization, calibration strategy, and the overall design philosophy. Proposed implementations were reviewed, tested, and frequently revised — including finding and correcting bugs while dancing between Claude for heavy lifting and ChatGPT for creative changes in direction.
The AI assistants contributed in different but complementary ways. They accelerated implementation, recalled APIs and library details, generated code examples, reviewed designs, explained unfamiliar technologies, suggested alternative approaches, and acted as knowledgeable brainstorming partners during debugging and refinement. Rather than replacing engineering judgment, they significantly reduced development time by allowing ideas to be explored and evaluated much more rapidly.
The result reflects the strengths of both collaborators: decades of human engineering experience guiding the design, reviewing code, and validating the instrument — combined with the speed, breadth of historical and modern knowledge, and interactive problem-solving of today's AI tools.
MIT © 2026 Ron Fredericks / BiophysicsLab.com



