I've always wanted a proper outdoor monitoring setup that goes beyond a simple temperature and humidity display. I wanted GPS-accurate timestamps, real air quality data, sunlight levels, and a dashboard I could open on my phone from anywhere in the house without relying on any cloud service. Most commercial weather stations are either too limited or locked into proprietary apps. So I built my own.
This project combines five sensors onto a single I2C bus, all controlled by the DFRobot FireBeetle 2 ESP32-P4. The board runs a lightweight web server that delivers a real-time dashboard — complete with arc gauges, time-series charts, an AQI meter, a compass, and a live OpenStreetMap — to any browser on my local Wi-Fi network. The whole thing refreshes every two seconds without a single page reload.
"Five sensors, one I2C bus, no cloud — just a clean web dashboard hosted entirely on the ESP32 itself."
The Enclosure — Designed for Outdoors, Printed by JUSTWAY
Getting the electronics working is only half the project. Deploying a weather station outdoors means dealing with rain, UV, heat, and humidity. I needed a custom enclosure that fit the IO Expansion Board footprint exactly, had cutouts for the Grove cables and GPS antenna, ventilation slots for the air quality sensors, and a mounting point for the GPS antenna cable — none of which you can buy off the shelf.
That's where JUSTWAY came in. I designed the enclosure in CAD as a two-part clamshell with a gasket channel, louvred ventilation slots on the bottom for the PM2.5 and gas sensors, a cable gland cutout for the GPS antenna pigtail, and four M3 mounting bosses. Then I uploaded the STEP file to justway.com and got an instant quote in seconds.
The parts arrived well-packed and the print quality was excellent — smooth walls, clean layer lines, and the cable gland cutout dimensionally accurate. You can directly visitjustway.com for an instant quote.
Hardware SetupHow all five sensors connectOne of the things that makes this build clean is that every sensor speaks I2C. That means they all share the same two signal wires — SDA and SCL — plus power and ground from the IO Expansion Board. You just plug in five Grove cables and you're done. No soldering, no breadboard.
✅ No multiplexer needed. All five addresses are unique, so everything runs on a single I2C bus without any extra hardware.
Grove cable wiring
Every Grove cable follows the same color convention: yellow is SDA, white is SCL, red is 3.3V, black is GND. Just connect each sensor to any Grove I2C port on the expansion board. The order does not matter.
If you have not used ESP32 with Arduino IDE before, go to File > Preferences and add the Espressif ESP32 board index URL to the Additional Boards Manager URLs field. Then open Tools > Board > Boards Manager, search for esp32, and install the package by Espressif Systems. Select FireBeetle-ESP32 or ESP32 Dev Module from the board list.
Testing Each Sensor IndividuallyBefore combining everything into the full station sketch, I tested each sensor with a simple standalone sketch. This is the fastest way to confirm that wiring and library versions are correct before adding complexity. All test sketches print to Serial Monitor at 115200 baud.
SHT40 — temperature and humidityThe Sensirion library uses the class SensirionI2cSht4x (lowercase 'c' in I2c — a common capitalisation mistake). Pass SHT40_I2C_ADDR_44 to begin() and call softReset() before the first measurement. When working correctly you will see temperature and humidity values printed every second.
/*
* THIS FILE IS AUTOMATICALLY GENERATED
*
* Generator: sensirion-driver-generator 0.40.0
* Product: sht4x
* Model-Version: 2.1.1
*/
/*
* Copyright (c) 2024, Sensirion AG
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* * Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* * Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* * Neither the name of Sensirion AG nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
#include <Arduino.h>
#include <SensirionI2cSht4x.h>
#include <Wire.h>
// macro definitions
// make sure that we use the proper definition of NO_ERROR
#ifdef NO_ERROR
#undef NO_ERROR
#endif
#define NO_ERROR 0
SensirionI2cSht4x sensor;
static char errorMessage[64];
static int16_t error;
void setup() {
Serial.begin(115200);
while (!Serial) {
delay(100);
}
Wire.begin();
sensor.begin(Wire, SHT40_I2C_ADDR_44);
sensor.softReset();
delay(10);
uint32_t serialNumber = 0;
error = sensor.serialNumber(serialNumber);
if (error != NO_ERROR) {
Serial.print("Error trying to execute serialNumber(): ");
errorToString(error, errorMessage, sizeof errorMessage);
Serial.println(errorMessage);
return;
}
Serial.print("serialNumber: ");
Serial.print(serialNumber);
Serial.println();
}
void loop() {
float aTemperature = 0.0;
float aHumidity = 0.0;
delay(2000);
error = sensor.measureLowestPrecision(aTemperature, aHumidity);
if (error != NO_ERROR) {
Serial.print("Error trying to execute measureLowestPrecision(): ");
errorToString(error, errorMessage, sizeof errorMessage);
Serial.println(errorMessage);
return;
}
Serial.print("aTemperature: ");
Serial.print(aTemperature);
Serial.print("\t");
Serial.print("aHumidity: ");
Serial.print(aHumidity);
Serial.println();
}SGP30 — VOC and CO2The SGP30 uses a C-style Seeed library rather than a class-based one. The key functions are sgp_probe() to detect the sensor, sgp_iaq_init() to start the IAQ algorithm, and sgp_measure_iaq_blocking_read() to get readings. For the first 15 seconds after sgp_iaq_init(), the sensor outputs default values (CO2 = 400 ppm, TVOC = 0 ppb). This is expected and not a fault.
The most important constraint on the SGP30 is timing: it must be polled exactly every 1 second for its internal baseline compensation algorithm to work correctly. In the full station sketch I use a non-blocking millis() timer to guarantee this.
#include <Arduino.h>
#include "sensirion_common.h"
#include "sgp30.h"
void setup() {
s16 err;
u16 scaled_ethanol_signal, scaled_h2_signal;
Serial.begin(115200);
Serial.println("serial start!!");
/*For wio link!*/
#if defined(ESP8266)
pinMode(15, OUTPUT);
digitalWrite(15, 1);
Serial.println("Set wio link power!");
delay(500);
#endif
/* Init module,Reset all baseline,The initialization takes up to around 15 seconds, during which
all APIs measuring IAQ(Indoor air quality ) output will not change.Default value is 400(ppm) for co2,0(ppb) for tvoc*/
while (sgp_probe() != STATUS_OK) {
Serial.println("SGP failed");
while (1);
}
/*Read H2 and Ethanol signal in the way of blocking*/
err = sgp_measure_signals_blocking_read(&scaled_ethanol_signal,
&scaled_h2_signal);
if (err == STATUS_OK) {
Serial.println("get ram signal!");
} else {
Serial.println("error reading signals");
}
err = sgp_iaq_init();
//
}
void loop() {
s16 err = 0;
u16 tvoc_ppb, co2_eq_ppm;
err = sgp_measure_iaq_blocking_read(&tvoc_ppb, &co2_eq_ppm);
if (err == STATUS_OK) {
Serial.print("tVOC Concentration:");
Serial.print(tvoc_ppb);
Serial.println("ppb");
Serial.print("CO2eq Concentration:");
Serial.print(co2_eq_ppm);
Serial.println("ppm");
} else {
Serial.println("error reading IAQ values\n");
}
delay(1000);
}SI1151 — visible light and IRAfter installing from the correct Si1151 branch, the test is straightforward. Call Wire.begin() before Serial.begin(), initialise with si1151.Begin(), then call si1151.ReadIR() and si1151.ReadVisible() in the loop. The values are raw ADC counts, not lux — useful for relative comparisons but not for calibrated measurements.
#include "Si115X.h"
Si115X si1151;
/**
* Setup for configuration
*/
void setup()
{
Wire.begin();
Serial.begin(115200);
if (!si1151.Begin()) {
Serial.println("Si1151 is not ready!");
while (1) {
delay(1000);
Serial.print(".");
};
}
else {
Serial.println("Si1151 is ready!");
}
}
/**
* Loops and reads data from registers
*/
void loop()
{
Serial.print("IR: ");
Serial.println(si1151.ReadIR());
Serial.print("Visible: ");
Serial.println(si1151.ReadVisible());
delay(500);
}The PM2.5 sensor initialises with particle.begin() and reads with gainParticleNum_Every0_1L(). This returns particle count per 0.1 litre of air, not mass concentration in micrograms per cubic metre. The dashboard uses these counts to calculatethe AQI. If you need µg/m³ instead, the library also provides gainParticleConcentration_ugm3()
/*
* DFRobot PM2.5 Air Quality Sensor (SEN0460) – Simple test sketch
* Board : DFRobot FireBeetle 2 ESP32-P4 + IO Expansion Board
*
* Library:
* DFRobot_AirQualitySensor
* Arduino IDE → Sketch → Include Library → Manage Libraries
* Search: "DFRobot_AirQualitySensor" → Install
* Or: https://github.com/DFRobot/DFRobot_AirQualitySensor
*
* Wiring (Grove I2C → IO Expansion Board):
* SDA → SDA
* SCL → SCL
* VCC → 3.3V
* GND → GND
*/
#include <Arduino.h>
#include <Wire.h>
#include "DFRobot_AirQualitySensor.h"
#define PM_I2C_ADDRESS 0x19
DFRobot_AirQualitySensor particle(&Wire, PM_I2C_ADDRESS);
void setup() {
Serial.begin(115200);
Wire.begin();
while (!particle.begin()) {
Serial.println("[PM] Sensor not found. Retrying...");
delay(1000);
}
Serial.println("[PM] Sensor initialized.");
Serial.print("[PM] Firmware version: ");
Serial.println(particle.gainVersion());
}
void loop() {
// Particle count per 0.1L of air
uint16_t pm_0_3 = particle.gainParticleNum_Every0_1L(PARTICLENUM_0_3_UM_EVERY0_1L_AIR);
uint16_t pm_0_5 = particle.gainParticleNum_Every0_1L(PARTICLENUM_0_5_UM_EVERY0_1L_AIR);
uint16_t pm_1_0 = particle.gainParticleNum_Every0_1L(PARTICLENUM_1_0_UM_EVERY0_1L_AIR);
uint16_t pm_2_5 = particle.gainParticleNum_Every0_1L(PARTICLENUM_2_5_UM_EVERY0_1L_AIR);
Serial.println("--- PM Particle Count (per 0.1L air) ---");
Serial.print("PM0.3 : "); Serial.println(pm_0_3);
Serial.print("PM0.5 : "); Serial.println(pm_0_5);
Serial.print("PM1.0 : "); Serial.println(pm_1_0);
Serial.print("PM2.5 : "); Serial.println(pm_2_5);
Serial.println();
delay(1000);
}Set gnss.setGnss(gnss.eGPS_BeiDou_GLONASS) to use all three constellations for the fastest fix. The GNSS module delivers UTC time, so I wrote a toIST() helper function that adds 5 hours 30 minutes for Indian Standard Time and handles midnight rollover correctly including month and year boundaries. Time is displayed in 12-hour AM/PM format.
/*
* DFRobot GNSS & RTC Module v1.0 – Simple test sketch
* Board : DFRobot FireBeetle 2 ESP32-P4 + IO Expansion Board
*
* Library:
* DFRobot_GNSSAndRTC
* https://github.com/DFRobot/DFRobot_GNSSAndRTC
* Arduino IDE → Sketch → Include Library → Add .ZIP Library
*
* Wiring (I2C → IO Expansion Board):
* SDA → SDA
* SCL → SCL
* VCC → 3.3V
* GND → GND
*
* Note: Module needs a clear view of the sky for GPS fix.
* Cold start can take up to 30 seconds.
*/
#include <Wire.h>
#include "DFRobot_GNSSAndRTC.h"
#define I2C_COMMUNICATION
DFRobot_GNSSAndRTC_I2C gnss(&Wire, MODULE_I2C_ADDRESS);
void setup() {
Serial.begin(115200);
while (!gnss.begin()) {
Serial.println("GNSS module not found. Retrying...");
delay(1000);
}
gnss.enablePower();
gnss.setGnss(gnss.eGPS_BeiDou_GLONASS); // GPS + BeiDou + GLONASS
Serial.println("GNSS initialized.");
}
// IST = UTC + 5 hours 30 minutes
#define IST_OFFSET_HOURS 5
#define IST_OFFSET_MINUTES 30
// Convert UTC time to IST and handle date rollover
void toIST(uint8_t utcH, uint8_t utcM, uint8_t utcD, uint8_t utcMon, uint16_t utcY,
uint8_t &istH, uint8_t &istM, uint8_t &istD, uint8_t &istMon, uint16_t &istY) {
int totalMinutes = utcH * 60 + utcM + IST_OFFSET_HOURS * 60 + IST_OFFSET_MINUTES;
istM = totalMinutes % 60;
istH = (totalMinutes / 60) % 24;
istD = utcD;
istMon = utcMon;
istY = utcY;
// Roll over to next day if total minutes exceed 24 hours
if (totalMinutes >= 24 * 60) {
istD += 1;
// Days per month (simplified — good enough for display)
uint8_t daysInMonth[] = {31,28,31,30,31,30,31,31,30,31,30,31};
// Leap year adjustment
if (istY % 4 == 0) daysInMonth[1] = 29;
if (istD > daysInMonth[istMon - 1]) {
istD = 1;
istMon += 1;
if (istMon > 12) {
istMon = 1;
istY += 1;
}
}
}
}
void loop() {
DFRobot_GNSSAndRTC::sTim_t utc = gnss.getUTC();
DFRobot_GNSSAndRTC::sTim_t date = gnss.getDate();
DFRobot_GNSSAndRTC::sLonLat_t lat = gnss.getLat();
DFRobot_GNSSAndRTC::sLonLat_t lon = gnss.getLon();
double altitude = gnss.getAlt();
uint8_t satellites = gnss.getNumSatUsed();
double sog = gnss.getSog();
double cog = gnss.getCog();
// Convert UTC → IST
uint8_t istH, istM, istD, istMon;
uint16_t istY;
toIST(utc.hour, utc.minute, date.date, date.month, date.year,
istH, istM, istD, istMon, istY);
// Convert 24h → 12h AM/PM
const char* period = (istH < 12) ? "AM" : "PM";
uint8_t istH12 = istH % 12;
if (istH12 == 0) istH12 = 12; // 0h → 12 AM, 12h → 12 PM
Serial.println("----------------------------");
Serial.printf("Date (IST) : %04d/%02d/%02d\n", istY, istMon, istD);
Serial.printf("Time (IST) : %02d:%02d:%02d %s\n", istH12, istM, utc.second, period);
Serial.printf("Latitude : %.6f\n", lat.latitudeDegree);
Serial.printf("Longitude : %.6f\n", lon.lonitudeDegree);
Serial.printf("Altitude : %.2f m\n", altitude);
Serial.printf("Satellites : %d\n", satellites);
Serial.printf("Speed : %.2f km/h\n", sog);
Serial.printf("Course : %.2f °\n", cog);
delay(2000);
}With all five sensors confirmed working individually, the full station sketch combines them with a Wi-Fi web server. The only things to change before flashing are the two Wi-Fi credential lines near the top of the file. After uploading, open Serial Monitor at 115200 baud and watch each sensor report Ready in sequence, followed by the local IP address once Wi-Fi connects. Open that IP address in any browser on the same network.
/*
* Outdoor Weather Station – Wi-Fi Web Server Dashboard
* Board : DFRobot FireBeetle 2 ESP32-P4 + IO Expansion Board
*
* Sensors:
* - SHT40 Temp + Humidity (SensirionI2cSht4x)
* - SGP30 VOC + eCO2 (sgp30.h C-style)
* - SI1151 Sunlight visible + IR (Si115X.h)
* - SEN0460 PM2.5 air quality (DFRobot_AirQualitySensor)
* - GNSS + RTC GPS + time (DFRobot_GNSSAndRTC)
*
* Usage:
* 1. Fill in your Wi-Fi SSID and password below.
* 2. Flash, open Serial Monitor at 115200.
* 3. Note the IP address, open it in any browser on your network.
* 4. Dashboard auto-refreshes every 2 seconds.
*/
#include <Arduino.h>
#include <Wire.h>
#include <WiFi.h>
#include <WebServer.h>
#include <SensirionI2cSht4x.h>
#include "sensirion_common.h"
#include "sgp30.h"
#include "Si115X.h"
#include "DFRobot_AirQualitySensor.h"
#include "DFRobot_GNSSAndRTC.h"
// ─── Wi-Fi credentials ────────────────────────
const char* WIFI_SSID = "";
const char* WIFI_PASSWORD = "";
#define IST_OFFSET_HOURS 5
#define IST_OFFSET_MINUTES 30
#ifdef NO_ERROR
#undef NO_ERROR
#endif
#define NO_ERROR 0
SensirionI2cSht4x sht4x;
Si115X si1151;
DFRobot_AirQualitySensor particle(&Wire, 0x19);
DFRobot_GNSSAndRTC_I2C gnss(&Wire, MODULE_I2C_ADDRESS);
WebServer server(80);
float g_tempC = 0; float g_humidity = 0;
uint16_t g_tvoc = 0; uint16_t g_eco2 = 0;
uint16_t g_visible = 0; uint16_t g_ir = 0;
uint16_t g_pm03 = 0; uint16_t g_pm05 = 0;
uint16_t g_pm10 = 0; uint16_t g_pm25 = 0;
double g_lat = 0; double g_lon = 0;
double g_alt = 0; uint8_t g_sats = 0;
double g_speed = 0; double g_cog = 0;
char g_dateStr[20] = "--";
char g_timeStr[20] = "--";
s16 sgpError; u16 scaled_ethanol, scaled_h2;
void toIST(uint8_t utcH, uint8_t utcM, uint8_t sec,
uint8_t utcD, uint8_t utcMon, uint16_t utcY) {
int totalMin = utcH*60 + utcM + IST_OFFSET_HOURS*60 + IST_OFFSET_MINUTES;
uint8_t istM=totalMin%60, istH=(totalMin/60)%24, istD=utcD, istMon=utcMon;
uint16_t istY=utcY;
if (totalMin >= 24*60) {
istD++;
uint8_t dim[]={31,28,31,30,31,30,31,31,30,31,30,31};
if (istY%4==0) dim[1]=29;
if (istD>dim[istMon-1]) { istD=1; istMon++; }
if (istMon>12) { istMon=1; istY++; }
}
const char* p=(istH<12)?"AM":"PM";
uint8_t h12=istH%12; if(h12==0)h12=12;
snprintf(g_dateStr,sizeof(g_dateStr),"%04d/%02d/%02d",istY,istMon,istD);
snprintf(g_timeStr,sizeof(g_timeStr),"%02d:%02d:%02d %s",h12,istM,sec,p);
}
void readAllSensors() {
sht4x.measureHighPrecision(g_tempC, g_humidity);
float absHum=216.7f*((g_humidity/100.0f)*6.112f*exp((17.62f*g_tempC)/(243.12f+g_tempC)))/(273.15f+g_tempC);
sgp_set_absolute_humidity((u32)(absHum*1000));
u16 tvoc,eco2; sgp_measure_iaq_blocking_read(&tvoc,&eco2);
g_tvoc=tvoc; g_eco2=eco2;
g_visible=si1151.ReadVisible(); g_ir=si1151.ReadIR();
g_pm03=particle.gainParticleNum_Every0_1L(PARTICLENUM_0_3_UM_EVERY0_1L_AIR);
g_pm05=particle.gainParticleNum_Every0_1L(PARTICLENUM_0_5_UM_EVERY0_1L_AIR);
g_pm10=particle.gainParticleNum_Every0_1L(PARTICLENUM_1_0_UM_EVERY0_1L_AIR);
g_pm25=particle.gainParticleNum_Every0_1L(PARTICLENUM_2_5_UM_EVERY0_1L_AIR);
DFRobot_GNSSAndRTC::sTim_t utc=gnss.getUTC(), date=gnss.getDate();
DFRobot_GNSSAndRTC::sLonLat_t lat=gnss.getLat(), lon=gnss.getLon();
g_lat=lat.latitudeDegree; g_lon=lon.lonitudeDegree;
g_alt=gnss.getAlt(); g_sats=gnss.getNumSatUsed();
g_speed=gnss.getSog(); g_cog=gnss.getCog();
toIST(utc.hour,utc.minute,utc.second,date.date,date.month,date.year);
}
void handleData() {
String j="{";
j+="\"temp\":"+String(g_tempC,2)+",\"humidity\":"+String(g_humidity,2)+",";
j+="\"tvoc\":"+String(g_tvoc)+",\"eco2\":"+String(g_eco2)+",";
j+="\"visible\":"+String(g_visible)+",\"ir\":"+String(g_ir)+",";
j+="\"pm03\":"+String(g_pm03)+",\"pm05\":"+String(g_pm05)+",";
j+="\"pm10\":"+String(g_pm10)+",\"pm25\":"+String(g_pm25)+",";
j+="\"lat\":"+String(g_lat,6)+",\"lon\":"+String(g_lon,6)+",";
j+="\"alt\":"+String(g_alt,1)+",\"sats\":"+String(g_sats)+",";
j+="\"speed\":"+String(g_speed,1)+",\"cog\":"+String(g_cog,1)+",";
j+="\"date\":\""+String(g_dateStr)+"\",\"time\":\""+String(g_timeStr)+"\"";
j+="}";
server.sendHeader("Access-Control-Allow-Origin","*");
server.send(200,"application/json",j);
}
void handleRoot() {
String html = R"rawhtml(<!DOCTYPE html>
<html lang="en"><head>
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>WeatherStation · Live</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<style>
:root{--bg:#060910;--p1:#0d1117;--p2:#111827;--p3:#1e293b;--bdr:rgba(255,255,255,0.07);--bdr2:rgba(255,255,255,0.13);--tx:#e2e8f4;--mu:#64748b;--blue:#38bdf8;--cyan:#22d3ee;--green:#4ade80;--amber:#fbbf24;--orange:#fb923c;--red:#f87171;--purple:#a78bfa;--pink:#f472b6;--teal:#2dd4bf;}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{scroll-behavior:smooth}
body{background:var(--bg);color:var(--tx);font-family:'Inter',sans-serif;font-size:14px;line-height:1.5;min-height:100vh}
body::before{content:'';position:fixed;inset:0;z-index:0;pointer-events:none;background:radial-gradient(ellipse 70% 40% at 50% -10%,rgba(56,189,248,0.06),transparent)}
.page{position:relative;z-index:1;max-width:1280px;margin:0 auto;padding:20px 24px 48px}
/* header */
.hdr{display:flex;align-items:center;justify-content:space-between;padding-bottom:20px;border-bottom:1px solid var(--bdr2);margin-bottom:28px}
.hdr-l{display:flex;align-items:center;gap:14px}
.logo-box{width:42px;height:42px;border-radius:11px;background:linear-gradient(135deg,#0ea5e9,#6366f1);display:flex;align-items:center;justify-content:center;font-size:22px}
.logo-name{font-size:17px;font-weight:600;letter-spacing:-.3px}
.logo-name span{color:var(--blue)}
.logo-sub{font-size:10px;color:var(--mu);letter-spacing:.8px;margin-top:2px}
.hdr-r{display:flex;align-items:center;gap:20px}
.live-pill{display:flex;align-items:center;gap:7px;background:rgba(74,222,128,.07);border:1px solid rgba(74,222,128,.2);border-radius:20px;padding:4px 12px;font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--green)}
.live-dot{width:6px;height:6px;border-radius:50%;background:var(--green);animation:blink 1.5s infinite}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.2}}
.clk{text-align:right;font-family:'JetBrains Mono',monospace}
.clk-t{font-size:19px;font-weight:500;color:var(--blue);letter-spacing:1px}
.clk-d{font-size:10px;color:var(--mu);margin-top:2px}
/* section */
.sec{font-size:10px;font-weight:500;letter-spacing:2px;text-transform:uppercase;color:var(--mu);margin:30px 0 12px;display:flex;align-items:center;gap:10px}
.sec::before{content:'';width:3px;height:13px;border-radius:2px;background:var(--blue)}
.sec::after{content:'';flex:1;height:1px;background:var(--bdr)}
/* grid */
.g{display:grid;gap:12px}
.g2{grid-template-columns:repeat(2,minmax(0,1fr))}
.g3{grid-template-columns:repeat(3,minmax(0,1fr))}
.g4{grid-template-columns:repeat(4,minmax(0,1fr))}
.g5{grid-template-columns:repeat(5,minmax(0,1fr))}
.gmap{grid-template-columns:1fr 320px}
@media(max-width:900px){.g4{grid-template-columns:repeat(2,1fr)}.g5{grid-template-columns:repeat(3,1fr)}.gmap{grid-template-columns:1fr}}
@media(max-width:600px){.g2,.g3,.g4,.g5{grid-template-columns:1fr 1fr}}
/* card */
.card{background:var(--p1);border:1px solid var(--bdr);border-radius:14px;padding:16px 18px;position:relative;overflow:hidden;transition:border-color .2s,transform .15s}
.card:hover{border-color:var(--bdr2);transform:translateY(-1px)}
.card::before{content:'';position:absolute;top:0;left:0;right:0;height:2px;border-radius:14px 14px 0 0}
.cb::before{background:var(--blue)}.cg::before{background:var(--green)}.ca::before{background:var(--amber)}
.co::before{background:var(--orange)}.cr::before{background:var(--red)}.cp::before{background:var(--purple)}
.ct::before{background:var(--teal)}.cpk::before{background:var(--pink)}
.lbl{font-size:10px;font-weight:500;letter-spacing:1.5px;text-transform:uppercase;color:var(--mu);margin-bottom:8px}
.val{font-size:28px;font-weight:600;font-family:'JetBrains Mono',monospace;line-height:1;color:var(--tx)}
.val .u{font-size:12px;font-weight:400;color:var(--mu);margin-left:3px}
.vsm{font-size:16px}
.sub{font-size:11px;color:var(--mu);margin-top:6px}
/* gauge */
.gwrap{display:flex;flex-direction:column;align-items:center;padding:6px 0 2px}
.gval{font-family:'JetBrains Mono',monospace;font-size:20px;font-weight:600;margin-top:4px}
.glbl{font-size:10px;letter-spacing:1.5px;text-transform:uppercase;color:var(--mu);margin-top:2px}
/* aqi */
.aqi-track{height:8px;border-radius:4px;background:linear-gradient(to right,#4ade80 0%,#fbbf24 30%,#fb923c 50%,#f87171 70%,#a78bfa 85%,#7f1d1d 100%);position:relative;margin:10px 0 5px}
.aqi-needle{position:absolute;top:-5px;width:18px;height:18px;border-radius:50%;background:white;border:2.5px solid #1e293b;transform:translateX(-50%);transition:left .9s cubic-bezier(.34,1.56,.64,1)}
.aqi-labs{display:flex;justify-content:space-between;font-size:9px;color:var(--mu);letter-spacing:.3px}
.aqib{display:inline-flex;align-items:center;padding:3px 10px;border-radius:20px;font-size:11px;font-weight:500;margin-top:6px}
.aq0{background:rgba(74,222,128,.1);color:#4ade80;border:1px solid rgba(74,222,128,.25)}
.aq1{background:rgba(251,191,36,.1);color:#fbbf24;border:1px solid rgba(251,191,36,.25)}
.aq2{background:rgba(251,146,60,.1);color:#fb923c;border:1px solid rgba(251,146,60,.25)}
.aq3{background:rgba(248,113,113,.1);color:#f87171;border:1px solid rgba(248,113,113,.25)}
.aq4{background:rgba(167,139,250,.1);color:#a78bfa;border:1px solid rgba(167,139,250,.25)}
/* pm bars */
.pmb{display:flex;flex-direction:column;gap:9px;margin-top:10px}
.pmrow{display:flex;align-items:center;gap:10px}
.pmlbl{width:48px;font-size:11px;color:var(--mu);font-family:'JetBrains Mono',monospace}
.pmtrk{flex:1;height:7px;background:var(--p2);border-radius:4px;overflow:hidden}
.pmfil{height:100%;border-radius:4px;transition:width .8s ease}
.pmnum{width:44px;text-align:right;font-size:11px;font-family:'JetBrains Mono',monospace}
/* sun meter */
.suntrk{height:11px;border-radius:6px;background:var(--p2);overflow:hidden;margin:10px 0 5px}
.sunfil{height:100%;border-radius:6px;background:linear-gradient(to right,#1e3a5f,#fbbf24,#fffbcc);transition:width .8s ease}
.sunrow{display:flex;justify-content:space-between;font-size:10px;color:var(--mu)}
/* compass */
.cpwrap{display:flex;flex-direction:column;align-items:center;gap:8px}
.cpring{width:110px;height:110px;border-radius:50%;border:2px solid var(--bdr2);position:relative;background:var(--p2)}
.cpN,.cpS,.cpE,.cpW{position:absolute;font-size:10px;font-weight:600}
.cpN{top:5px;left:50%;transform:translateX(-50%);color:var(--red)}
.cpS{bottom:5px;left:50%;transform:translateX(-50%);color:var(--mu)}
.cpE{right:6px;top:50%;transform:translateY(-50%);color:var(--mu)}
.cpW{left:6px;top:50%;transform:translateY(-50%);color:var(--mu)}
.cpneedle{position:absolute;top:50%;left:50%;width:4px;height:48px;margin-left:-2px;margin-top:-42px;transform-origin:bottom center;border-radius:2px 2px 0 0;background:linear-gradient(to bottom,var(--red) 50%,var(--p3) 50%);transition:transform .9s cubic-bezier(.34,1.56,.64,1)}
.cpdot{position:absolute;top:50%;left:50%;width:9px;height:9px;border-radius:50%;background:var(--tx);transform:translate(-50%,-50%)}
/* chart */
.chard{padding:18px 18px 12px}
.chdr{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:12px}
.chtitle{font-size:11px;font-weight:500;letter-spacing:.5px;color:var(--mu)}
.chcur{font-family:'JetBrains Mono',monospace;font-size:19px;font-weight:600;margin-top:2px}
/* gps stats */
.gpsstats{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-bottom:10px}
.gpss{background:var(--p2);border:1px solid var(--bdr);border-radius:10px;padding:10px 12px}
.gpss .lbl{margin-bottom:3px}
.gpss .val{font-size:13px}
/* map */
#map{height:380px;border-radius:12px;border:1px solid var(--bdr2);overflow:hidden}
/* footer */
.ftr{margin-top:40px;padding-top:18px;border-top:1px solid var(--bdr);display:flex;justify-content:space-between;align-items:center;font-size:10px;color:var(--mu);font-family:'JetBrains Mono',monospace}
</style></head>
<body><div class="page">
<div class="hdr">
<div class="hdr-l">
<div class="logo-box">🌤</div>
<div>
<div class="logo-name">Weather<span>Station</span></div>
<div class="logo-sub">ESP32-P4 · 5 SENSORS · FIREBEETLE 2</div>
</div>
</div>
<div class="hdr-r">
<div class="live-pill"><div class="live-dot"></div>LIVE · 2s refresh</div>
<div class="clk">
<div class="clk-t" id="hdr-t">--:--:-- --</div>
<div class="clk-d" id="hdr-d">----/--/--</div>
</div>
</div>
</div>
<!-- TEMP & HUMIDITY -->
<div class="sec">Temperature & Humidity</div>
<div class="g g3">
<div class="card cb">
<div class="gwrap">
<canvas id="g-temp" width="170" height="100"></canvas>
<div class="gval" id="gv-temp" style="color:var(--blue)">-- °C</div>
<div class="glbl">Temperature</div>
</div>
</div>
<div class="card ct">
<div class="gwrap">
<canvas id="g-hum" width="170" height="100"></canvas>
<div class="gval" id="gv-hum" style="color:var(--teal)">-- %</div>
<div class="glbl">Humidity</div>
</div>
</div>
<div class="card cg" style="display:flex;flex-direction:column;justify-content:space-between;">
<div>
<div class="lbl">Feels Like</div>
<div class="val" id="feels" style="color:var(--green)">--<span class="u">°C</span></div>
<div class="sub" id="feels-d">Calculating...</div>
</div>
<div style="margin-top:18px">
<div class="lbl">Dew Point</div>
<div class="val vsm" id="dew" style="color:var(--cyan)">--<span class="u">°C</span></div>
<div class="sub">Condensation threshold</div>
</div>
</div>
</div>
<div class="g g2" style="margin-top:12px">
<div class="card chard cb">
<div class="chdr"><div><div class="chtitle">TEMPERATURE HISTORY (last 60 readings)</div><div class="chcur" id="cc-temp" style="color:var(--blue)">-- °C</div></div></div>
<canvas id="c-temp" height="80"></canvas>
</div>
<div class="card chard ct">
<div class="chdr"><div><div class="chtitle">HUMIDITY HISTORY</div><div class="chcur" id="cc-hum" style="color:var(--teal)">-- %RH</div></div></div>
<canvas id="c-hum" height="80"></canvas>
</div>
</div>
<!-- AIR QUALITY -->
<div class="sec">Air Quality & Gas Sensors</div>
<div class="g g4">
<div class="card ca">
<div class="lbl">TVOC</div>
<div class="val" id="tvoc" style="color:var(--amber)">--<span class="u">ppb</span></div>
<div class="sub" id="tvoc-l">--</div>
</div>
<div class="card co">
<div class="lbl">eCO₂</div>
<div class="val" id="eco2" style="color:var(--orange)">--<span class="u">ppm</span></div>
<div class="sub" id="eco2-l">--</div>
</div>
<div class="card cp">
<div class="lbl">Visible Light</div>
<div class="val" id="vis" style="color:var(--purple)">--<span class="u">raw</span></div>
<div class="suntrk"><div class="sunfil" id="sunf" style="width:0%"></div></div>
<div class="sunrow"><span>Dark</span><span id="sunpct">0%</span><span>Bright</span></div>
</div>
<div class="card cpk">
<div class="lbl">Infrared (IR)</div>
<div class="val" id="ir" style="color:var(--pink)">--<span class="u">raw</span></div>
<div class="sub">SI1151 channel 1</div>
</div>
</div>
<!-- AQI card -->
<div class="card" style="margin-top:12px">
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:14px">
<div>
<div class="lbl">AIR QUALITY INDEX (US EPA · PM2.5)</div>
<div style="display:flex;align-items:center;gap:14px;margin-top:6px">
<span style="font-family:'JetBrains Mono',monospace;font-size:36px;font-weight:600" id="aqi-n">--</span>
<div id="aqi-b" class="aqib aq0">GOOD</div>
</div>
</div>
<div style="text-align:right;max-width:260px">
<div style="font-size:11px;color:var(--mu)">Health advisory</div>
<div id="aqi-desc" style="font-size:12px;color:var(--tx);margin-top:4px">Waiting for data...</div>
</div>
</div>
<div class="aqi-track"><div class="aqi-needle" id="aqi-ndl" style="left:2%"></div></div>
<div class="aqi-labs"><span>Good</span><span>Moderate</span><span>Sensitive</span><span>Unhealthy</span><span>Very Bad</span><span>Hazardous</span></div>
</div>
<!-- PARTICULATES -->
<div class="sec">Particulate Matter</div>
<div class="g g2">
<div class="card cr" style="padding:18px">
<div class="lbl">PARTICLE COUNT / 0.1L AIR</div>
<div class="pmb">
<div class="pmrow"><span class="pmlbl">PM 0.3</span><div class="pmtrk"><div class="pmfil" id="b03" style="width:0%;background:var(--red)"></div></div><span class="pmnum" id="n03">--</span></div>
<div class="pmrow"><span class="pmlbl">PM 0.5</span><div class="pmtrk"><div class="pmfil" id="b05" style="width:0%;background:var(--orange)"></div></div><span class="pmnum" id="n05">--</span></div>
<div class="pmrow"><span class="pmlbl">PM 1.0</span><div class="pmtrk"><div class="pmfil" id="b10" style="width:0%;background:var(--amber)"></div></div><span class="pmnum" id="n10">--</span></div>
<div class="pmrow"><span class="pmlbl">PM 2.5</span><div class="pmtrk"><div class="pmfil" id="b25" style="width:0%;background:var(--green)"></div></div><span class="pmnum" id="n25">--</span></div>
</div>
</div>
<div class="card chard cr">
<div class="chdr"><div><div class="chtitle">PM 2.5 HISTORY</div><div class="chcur" id="cc-pm" style="color:var(--red)">--</div></div></div>
<canvas id="c-pm" height="110"></canvas>
</div>
</div>
<div class="g g2" style="margin-top:12px">
<div class="card chard ca">
<div class="chdr"><div><div class="chtitle">TVOC HISTORY</div><div class="chcur" id="cc-tvoc" style="color:var(--amber)">-- ppb</div></div></div>
<canvas id="c-tvoc" height="80"></canvas>
</div>
<div class="card chard co">
<div class="chdr"><div><div class="chtitle">eCO₂ HISTORY</div><div class="chcur" id="cc-eco2" style="color:var(--orange)">-- ppm</div></div></div>
<canvas id="c-eco2" height="80"></canvas>
</div>
</div>
<!-- GPS -->
<div class="sec">GPS & Navigation</div>
<div class="gpsstats">
<div class="gpss"><div class="lbl">Latitude</div><div class="val vsm" id="lat" style="color:var(--blue)">--</div></div>
<div class="gpss"><div class="lbl">Longitude</div><div class="val vsm" id="lon" style="color:var(--blue)">--</div></div>
<div class="gpss"><div class="lbl">Altitude</div><div class="val vsm" id="alt" style="color:var(--cyan)">--</div></div>
<div class="gpss"><div class="lbl">Satellites</div><div class="val vsm" id="sats" style="color:var(--green)">--</div></div>
<div class="gpss"><div class="lbl">Speed</div><div class="val vsm" id="spd" style="color:var(--amber)">--</div></div>
</div>
<div class="g gmap">
<div id="map"></div>
<div style="display:flex;flex-direction:column;gap:12px">
<div class="card cb">
<div class="lbl">HEADING (COG)</div>
<div class="cpwrap" style="margin-top:10px">
<div class="cpring">
<span class="cpN">N</span><span class="cpS">S</span>
<span class="cpE">E</span><span class="cpW">W</span>
<div class="cpneedle" id="cpn"></div>
<div class="cpdot"></div>
</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:14px;font-weight:500" id="cogv">0.0°</div>
<div class="sub" id="cogd">North</div>
</div>
</div>
<div class="card cg" style="flex:1">
<div class="lbl">SATELLITE SIGNAL</div>
<div style="display:flex;flex-direction:column;align-items:center;margin-top:8px">
<canvas id="g-sats" width="170" height="90"></canvas>
<div style="font-family:'JetBrains Mono',monospace;font-size:22px;font-weight:600;color:var(--green);margin-top:4px" id="satsbig">--</div>
<div style="font-size:10px;color:var(--mu);letter-spacing:1px">SATELLITES IN VIEW</div>
</div>
</div>
</div>
</div>
<div class="ftr">
<span>WeatherStation · ESP32-P4 · DFRobot FireBeetle 2</span>
<span id="upd">Last update: --</span>
<span>Map © OpenStreetMap contributors</span>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script>
const MAX=60;
Chart.defaults.color='#64748b';
Chart.defaults.font.family="'JetBrains Mono',monospace";
Chart.defaults.font.size=10;
function mkChart(id,color,unit,fill){
const hex=color;
return new Chart(document.getElementById(id),{
type:'line',
data:{labels:[],datasets:[{data:[],borderColor:hex,borderWidth:1.5,fill:fill!==false,
backgroundColor:hex+'18',tension:0.4,pointRadius:0,pointHoverRadius:3}]},
options:{responsive:true,maintainAspectRatio:true,animation:{duration:300},
plugins:{legend:{display:false},tooltip:{callbacks:{label:c=>c.parsed.y.toFixed(1)+' '+unit}}},
scales:{x:{display:false},y:{display:true,grid:{color:'rgba(255,255,255,0.04)'},ticks:{maxTicksLimit:4}}}}
});
}
const CT=mkChart('c-temp','#38bdf8','°C');
const CH=mkChart('c-hum', '#2dd4bf','%');
const CV=mkChart('c-tvoc','#fbbf24','ppb');
const CE=mkChart('c-eco2','#fb923c','ppm');
const CP=mkChart('c-pm', '#f87171','');
const hT=[],hH=[],hV=[],hE=[],hP=[];
function push(chart,arr,v,lbl){
arr.push(v); chart.data.labels.push(lbl); chart.data.datasets[0].data.push(v);
if(arr.length>MAX){arr.shift();chart.data.labels.shift();chart.data.datasets[0].data.shift();}
chart.update('none');
}
function drawArc(id,val,min,max,color){
const c=document.getElementById(id); if(!c)return;
const ctx=c.getContext('2d'),W=c.width,H=c.height;
ctx.clearRect(0,0,W,H);
const cx=W/2,cy=H-8,r=Math.min(W*0.44,H*0.85);
const pct=Math.max(0,Math.min(1,(val-min)/(max-min)));
ctx.lineCap='round';
ctx.beginPath();ctx.arc(cx,cy,r,Math.PI,2*Math.PI);
ctx.strokeStyle='rgba(255,255,255,0.07)';ctx.lineWidth=11;ctx.stroke();
ctx.beginPath();ctx.arc(cx,cy,r,Math.PI,Math.PI+pct*Math.PI);
ctx.strokeStyle=color;ctx.lineWidth=11;ctx.stroke();
for(let i=0;i<=10;i++){
const a=Math.PI+i*Math.PI/10;
const r1=r-15,r2=r-7;
ctx.beginPath();ctx.moveTo(cx+r1*Math.cos(a),cy+r1*Math.sin(a));ctx.lineTo(cx+r2*Math.cos(a),cy+r2*Math.sin(a));
ctx.strokeStyle=i%5===0?'rgba(255,255,255,.3)':'rgba(255,255,255,.1)';
ctx.lineWidth=i%5===0?2:1;ctx.stroke();
}
}
function drawSats(v){
const c=document.getElementById('g-sats');if(!c)return;
const ctx=c.getContext('2d'),W=c.width,H=c.height;
ctx.clearRect(0,0,W,H);
const cx=W/2,cy=H-4,r=Math.min(W*0.42,H*0.9);
const p=Math.min(1,v/16);
ctx.lineCap='round';
ctx.beginPath();ctx.arc(cx,cy,r,Math.PI,2*Math.PI);
ctx.strokeStyle='rgba(255,255,255,0.07)';ctx.lineWidth=11;ctx.stroke();
ctx.beginPath();ctx.arc(cx,cy,r,Math.PI,Math.PI+p*Math.PI);
ctx.strokeStyle='#4ade80';ctx.lineWidth=11;ctx.stroke();
}
function heatIdx(T,R){
const c=[-8.78469475556,1.61139411,2.33854883889,-.14611605,-.01230809357,-.01642482777,.00221732792,.00072546,-.00000358581];
return c[0]+c[1]*T+c[2]*R+c[3]*T*R+c[4]*T*T+c[5]*R*R+c[6]*T*T*R+c[7]*T*R*R+c[8]*T*T*R*R;
}
function dewPt(T,R){const g=(17.27*T/(237.7+T))+Math.log(R/100);return(237.7*g/(17.27-g)).toFixed(1);}
function cogDir(d){return['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSW','SW','WSW','W','WNW','NW','NNW'][Math.round(d/22.5)%16];}
function getAQI(pm){
if(pm<=12) return [Math.round(pm*4.17),'GOOD','aq0','Air quality is satisfactory and poses little risk.'];
if(pm<=35) return [Math.round(50+(pm-12.1)/23*50),'MODERATE','aq1','Acceptable. Sensitive groups may experience minor effects.'];
if(pm<=55) return [Math.round(100+(pm-35.5)/19.5*50),'SENSITIVE','aq2','Unhealthy for sensitive groups — elderly, children, asthma.'];
if(pm<=150)return [Math.round(150+(pm-55.5)/94.5*50),'UNHEALTHY','aq3','Everyone may begin to experience health effects.'];
return [Math.min(300,200+Math.round((pm-150)/100*100)),'VERY UNHEALTHY','aq4','Health alert — serious effects for the entire population.'];
}
// Map
var map=L.map('map',{zoomControl:true}).setView([13.0827,80.2707],15);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{attribution:'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',maxZoom:19}).addTo(map);
var svgIco=L.divIcon({className:'',html:'<div style="width:18px;height:18px;border-radius:50%;background:#38bdf8;border:3px solid white;box-shadow:0 0 12px #38bdf8bb;"></div>',iconSize:[18,18],iconAnchor:[9,9]});
var mkr=L.marker([13.0827,80.2707],{icon:svgIco}).addTo(map);
mkr.bindPopup('<b>Weather Station</b><br>Acquiring GPS fix...').openPopup();
var lLat=0,lLon=0;
async function fetchData(){
try{
const r=await fetch('/data'),d=await r.json();
const now=new Date();
const ts=now.getHours().toString().padStart(2,'0')+':'+now.getMinutes().toString().padStart(2,'0')+':'+now.getSeconds().toString().padStart(2,'0');
document.getElementById('hdr-t').textContent=d.time;
document.getElementById('hdr-d').textContent=d.date;
document.getElementById('upd').textContent='Last update: '+ts;
// Temp / Humidity
const T=parseFloat(d.temp),H=parseFloat(d.humidity);
drawArc('g-temp',T,-10,60,'#38bdf8');
drawArc('g-hum', H, 0,100,'#2dd4bf');
document.getElementById('gv-temp').textContent=T.toFixed(1)+' °C';
document.getElementById('gv-hum').textContent=H.toFixed(1)+' %';
const hi=heatIdx(T,H);
document.getElementById('feels').innerHTML=hi.toFixed(1)+'<span class="u">°C</span>';
document.getElementById('feels-d').textContent=hi>T+3?'Feels hotter than actual':hi<T-3?'Feels cooler than actual':'Close to actual temperature';
document.getElementById('dew').innerHTML=dewPt(T,H)+'<span class="u">°C</span>';
document.getElementById('cc-temp').textContent=T.toFixed(1)+' °C';
document.getElementById('cc-hum').textContent=H.toFixed(1)+' %RH';
push(CT,hT,T,ts); push(CH,hH,H,ts);
// Gas
document.getElementById('tvoc').innerHTML=d.tvoc+'<span class="u">ppb</span>';
document.getElementById('eco2').innerHTML=d.eco2+'<span class="u">ppm</span>';
document.getElementById('vis').innerHTML=d.visible+'<span class="u">raw</span>';
document.getElementById('ir').innerHTML=d.ir+'<span class="u">raw</span>';
document.getElementById('tvoc-l').textContent=d.tvoc<220?'Normal indoor level':d.tvoc<660?'Elevated — ventilate':d.tvoc<2200?'High — open windows now':'Very high — evacuate area';
document.getElementById('eco2-l').textContent=d.eco2<600?'Excellent air':d.eco2<1000?'Good':d.eco2<2000?'Moderate — ventilate':'Poor — open windows';
const sp=Math.min(100,Math.round(d.visible/10));
document.getElementById('sunf').style.width=sp+'%';
document.getElementById('sunpct').textContent=sp+'%';
document.getElementById('cc-tvoc').textContent=d.tvoc+' ppb';
document.getElementById('cc-eco2').textContent=d.eco2+' ppm';
push(CV,hV,d.tvoc,ts); push(CE,hE,d.eco2,ts);
// AQI
const[aqn,aql,aqc,aqd]=getAQI(d.pm25);
document.getElementById('aqi-n').textContent=aqn;
document.getElementById('aqi-desc').textContent=aqd;
const ab=document.getElementById('aqi-b');ab.textContent=aql;ab.className='aqib '+aqc;
document.getElementById('aqi-ndl').style.left=Math.min(95,Math.max(2,aqn/5))+'%';
// PM bars
const mx=Math.max(d.pm03,1);
['03','05','10','25'].forEach((k,i)=>{
const v=[d.pm03,d.pm05,d.pm10,d.pm25][i];
document.getElementById('b'+k).style.width=Math.min(100,v/mx*100)+'%';
document.getElementById('n'+k).textContent=v;
});
document.getElementById('cc-pm').textContent='PM2.5: '+d.pm25;
push(CP,hP,d.pm25,ts);
// GPS
document.getElementById('lat').textContent=parseFloat(d.lat).toFixed(6);
document.getElementById('lon').textContent=parseFloat(d.lon).toFixed(6);
document.getElementById('alt').textContent=d.alt+' m';
document.getElementById('sats').textContent=d.sats+' sats';
document.getElementById('spd').textContent=d.speed+' km/h';
document.getElementById('cogv').textContent=parseFloat(d.cog).toFixed(1)+'°';
document.getElementById('cogd').textContent=cogDir(parseFloat(d.cog));
document.getElementById('cpn').style.transform='rotate('+d.cog+'deg)';
document.getElementById('satsbig').textContent=d.sats;
drawSats(parseInt(d.sats));
if(d.lat!=0&&d.lon!=0&&(d.lat!==lLat||d.lon!==lLon)){
lLat=d.lat;lLon=d.lon;
const ll=[parseFloat(d.lat),parseFloat(d.lon)];
mkr.setLatLng(ll);
mkr.setPopupContent('<b>Weather Station</b><br>'+parseFloat(d.lat).toFixed(6)+', '+parseFloat(d.lon).toFixed(6)+'<br>Alt: '+d.alt+' m · '+d.sats+' satellites<br>Speed: '+d.speed+' km/h');
map.panTo(ll);
}
}catch(e){console.warn('Fetch error:',e);}
}
drawArc('g-temp',0,-10,60,'#38bdf8');
drawArc('g-hum', 0, 0,100,'#2dd4bf');
drawSats(0);
fetchData();
setInterval(fetchData,2000);
</script></body></html>)rawhtml";
server.send(200,"text/html",html);
}
void setup(){
Serial.begin(115200);
Wire.begin();
sht4x.begin(Wire,SHT40_I2C_ADDR_44); sht4x.softReset(); delay(10);
Serial.println("[SHT40] Ready");
while(sgp_probe()!=STATUS_OK){Serial.println("[SGP30] Probe failed...");delay(1000);}
sgpError=sgp_measure_signals_blocking_read(&scaled_ethanol,&scaled_h2);
sgp_iaq_init(); Serial.println("[SGP30] Ready");
while(!si1151.Begin()){Serial.println("[SI1151] Not found...");delay(1000);}
Serial.println("[SI1151] Ready");
while(!particle.begin()){Serial.println("[PM2.5] Not found...");delay(1000);}
Serial.println("[PM2.5] Ready");
while(!gnss.begin()){Serial.println("[GNSS] Not found...");delay(1000);}
gnss.enablePower(); gnss.setGnss(gnss.eGPS_BeiDou_GLONASS);
Serial.println("[GNSS] Ready");
Serial.print("\nConnecting to Wi-Fi");
WiFi.begin(WIFI_SSID,WIFI_PASSWORD);
while(WiFi.status()!=WL_CONNECTED){delay(500);Serial.print(".");}
Serial.println("\nConnected!");
Serial.print("Open: http://"); Serial.println(WiFi.localIP());
server.on("/",handleRoot);
server.on("/data",handleData);
server.begin();
Serial.println("Server started.");
}
unsigned long lastRead=0;
void loop(){
server.handleClient();
if(millis()-lastRead>=2000){lastRead=millis();readAllSensors();}
}• Non-blocking loop: server.handleClient() runs on every single loop iteration so the web server is always responsive. Sensor reads only happen when 2000 milliseconds have passed since the last read, checked with millis().
• SGP30 absolute humidity compensation: After each SHT40 read, temperature and humidity are converted to absolute humidity in g/m3 and passed to sgp_set_absolute_humidity(). This measurably improves the accuracy of the eCO2 reading and is easy to miss in most example code.
• COG added to JSON: I added gnss.getCog() (course over ground) to the JSON output specifically to drive the compass widget on the dashboard.
• IST with AM/PM: The toIST() helper converts UTC to IST and formats as 12-hour clock with AM/PM, since that is what I wanted to see on the dashboard.
The Live Web DashboardThis is probably the part of the project I am most pleased with. Instead of a basic Serial Monitor printout, every sensor reading is visualised in a proper dashboard that any device on the local network can open in a browser. No app install, no cloud account — just an IP address.
The ESP32 hosts two HTTP routes on port 80. The root route (/) serves the full HTML, CSS, and JavaScript dashboard as a single response. A second route (/data) returns a small JSON object with all current sensor values. The browser uses a JavaScript setInterval loop to fetch /data every two seconds and update only the changed values on screen. The page never reloads, which keeps everything smooth and reduces load on the ESP32.
"Open the IP address in any browser. No app, no account, no cloud — the dashboard is served entirely by the ESP32."The JUSTWAY ordering process
The workflow on justway.com is the smoothest I have used for a one-off maker part. Upload your STL or STEP file, the system analyses it and returns an instant quote with estimated delivery. You pick your technology (FDM, SLA, SLS, MJF), material, infill, and surface finish. For this enclosure I selected FDM with PETG at 40% infill and standard resolution. The quote came back in about 10 seconds.
For makers and engineers who want a custom enclosure without owning a 3D printer or waiting for a local print farm, JUSTWAY is a solid option. The combination of instant quoting, broad material selection, and reasonable pricing (the full enclosure set was under $15) makes it practical for prototype quantities. Get a quote for your own enclosure at justway.com.
What's NextThis project is fully functional as described, but there are several natural extensions worth exploring:
• MQTT and Home Assistant: Add the PubSubClient library to publish readings to an MQTT broker. Home Assistant can then display the data on its dashboard alongside other smart home devices.
• Solar-powered deployment: A 5V solar panel with a lithium battery and TP4056 charging module can make the station fully autonomous. Add deep sleep mode (wake every 60 seconds, read, publish, sleep) to extend battery life significantly.
• Barometric pressure: A BMP280 uses I2C at address 0x76 — adding it to the existing bus would complete the standard weather station sensor suite, and pressure trends are useful for short-term weather prediction.
• Wind and rain: A Davis anemometer (pulse output) and tipping bucket rain gauge (pulse output) connect to spare digital GPIO pins and would complete a professional-grade station.
• ThingSpeak cloud logging: Send readings to ThingSpeak every 15 seconds for free historical charts and basic data analysis.
Final ThoughtsThis was one of my most satisfying builds. Every design decision has a reason: the sensor selection for non-overlapping I2C addresses, the non-blocking loop to keep the web server responsive while honouring the SGP30's timing requirement, the absolute humidity compensation to improve gas sensor accuracy, the JUSTWAY PETG enclosure for UV and moisture resistance.
This project was made possible in part by JUSTWAY, who provided 3D printing services for the custom outdoor enclosure. JUSTWAY offers FDM, SLA, SLS, MJF, and metal 3D printing with instant online quoting at justway.com. First-time users get a discount on their first order.












Comments