Arnov Sharma
Published © MIT

Pal 8000 Room Air Quality Monitor

Inspired by HAL9000, this device talks and updates us about the AQ of the room.

BeginnerFull instructions provided6 hours47
Pal 8000 Room Air Quality Monitor

Things used in this project

Hardware components

NextPCB  Custom PCB Board
NextPCB Custom PCB Board
×1
Raspberry Pi Pico W
Raspberry Pi Pico W
×1
Raspberry Pi Pico
Raspberry Pi Pico
×1

Software apps and online services

Arduino IDE
Arduino IDE
Fusion
Autodesk Fusion

Hand tools and fabrication machines

3D Printer (generic)
3D Printer (generic)

Story

Read more

Custom parts and enclosures

STEP FILE

3D Files

Schematics

SCH

Code

Main Code

C/C++
#include <Arduino.h>
#include <Wire.h>
#include <SoftwareSerial.h>
#include <DFRobotDFPlayerMini.h>
#include <Adafruit_SGP40.h>
#include <WiFi.h>
#include <WebServer.h>
const char* WIFI_SSID     = "SSID";
const char* WIFI_PASSWORD = "PASS";
#define LED_PIN 0
#define DF_RX 7
#define DF_TX 8
#define SGP40_SDA 4
#define SGP40_SCL 5
#define LED_IDLE 20
#define LED_PEAK 80
const uint16_t TRACK_MS[] = {
0,      // [0]  unused
4000,   // [1]  01
5000,   // [2]  02
4000,   // [3]  03
4000,   // [4]  04
2000,   // [5]  05
2000,   // [6]  06
3000,   // [7]  07
1000,   // [8]  08
2000,   // [9]  09
2000,   // [10] 10
10000,  // [11] 11
10000,  // [12] 12
9000,   // [13] 13
15000,  // [14] 14
0,      // [15] unused
0,      // [16] unused
1000,   // [17] 17
1000,   // [18] 18
};
#define INTERVAL_07 30000UL
#define INTERVAL_VOC 60000UL
#define INTERVAL_10 300000UL
#define INTERVAL_11 600000UL
#define SENSOR_RETRY 30000UL
#define VOC_GOOD_MAX 100
#define VOC_MODERATE_MAX 200
SoftwareSerial      mySerial(7, 8); // RX, TX
DFRobotDFPlayerMini player;
Adafruit_SGP40      sgp;
WebServer           server(80);
bool     sensorOK      = false;
bool     sensorWasLost = false;
bool     goodAlt       = false;
bool     modAlt        = false;
bool     elevAlt       = false;
uint16_t vocRaw        = 0;
uint16_t vocSmooth     = 0;
unsigned long bootTime = 0;
unsigned long lastTime07      = 0;
unsigned long lastTimeVOC     = 0;
unsigned long lastTime10      = 0;
unsigned long lastTime11      = 0;
unsigned long lastSensorRetry = 0;
//WEB APP HTML
const char HTML_PAGE[] PROGMEM = R"rawhtml(
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PAL 8000</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{background:#111;font-family:'Courier New',monospace;color:#fff;min-height:100vh}
.outer{max-width:860px;margin:0 auto;padding:2rem 1rem}
.hal-face{background:#1a1a1a;border:1px solid #333;border-radius:16px;padding:2rem;position:relative}
.title-bar{display:flex;justify-content:space-between;align-items:center;margin-bottom:2rem}
.pal-title{font-size:28px;font-weight:700;letter-spacing:6px;color:#fff}
.pal-title span{color:#4a9eff}
.status-dot{width:10px;height:10px;border-radius:50%;background:#4aff88;display:inline-block;margin-right:8px;animation:pulse 2s infinite}
.status-text{font-size:12px;color:#888;letter-spacing:2px}
.eye-section{display:flex;justify-content:center;margin:1.5rem 0}
.eye-outer{width:280px;height:280px;border-radius:50%;background:#0a0a0a;border:3px solid #333;display:flex;align-items:center;justify-content:center}
.eye-ring{width:240px;height:240px;border-radius:50%;background:#0d0d0d;border:2px solid #222;display:flex;align-items:center;justify-content:center}
.eye-lens{width:190px;height:190px;border-radius:50%;background:radial-gradient(circle at 40% 35%,#cc2200,#8b0000 50%,#3d0000 80%,#1a0000);display:flex;flex-direction:column;align-items:center;justify-content:center;position:relative;animation:breathe 3s ease-in-out infinite}
.eye-reflection{position:absolute;top:28px;left:38px;width:45px;height:25px;background:rgba(255,255,255,0.08);border-radius:50%;transform:rotate(-25deg)}
.scan-line{position:absolute;width:100%;height:2px;background:linear-gradient(90deg,transparent,rgba(255,80,80,0.3),transparent);animation:scan 3s linear infinite;border-radius:50%}
.voc-label{font-size:11px;letter-spacing:3px;color:rgba(255,200,200,0.7);margin-bottom:4px}
.voc-value{font-size:48px;font-weight:700;color:#fff;line-height:1;text-shadow:0 0 20px rgba(255,100,100,0.8)}
.voc-unit{font-size:11px;letter-spacing:2px;color:rgba(255,200,200,0.6);margin-top:4px}
.voc-status{font-size:10px;letter-spacing:2px;color:#4aff88;margin-top:8px}
.metrics-row{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:2rem}
.metric-card{background:#222;border:1px solid #333;border-radius:10px;padding:1rem;text-align:center}
.metric-label{font-size:10px;letter-spacing:2px;color:#666;margin-bottom:6px}
.metric-value{font-size:22px;font-weight:700;color:#fff}
.metric-sub{font-size:10px;color:#555;margin-top:4px}
.bar-section{margin-top:2rem}
.bar-label{font-size:10px;letter-spacing:2px;color:#555;margin-bottom:8px}
.bar-track{height:6px;background:#222;border-radius:3px;overflow:hidden;margin-bottom:12px}
.bar-fill{height:100%;border-radius:3px;transition:width 1s ease,background 1s ease}
.footer-row{display:flex;justify-content:space-between;align-items:center;margin-top:2rem;padding-top:1rem;border-top:1px solid #222}
.footer-text{font-size:10px;color:#444;letter-spacing:2px}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.3}}
@keyframes scan{0%{top:10%;opacity:0}10%{opacity:1}90%{opacity:1}100%{top:90%;opacity:0}}
@keyframes breathe{0%,100%{box-shadow:0 0 40px rgba(200,30,0,0.4),inset 0 0 30px rgba(0,0,0,0.6)}50%{box-shadow:0 0 70px rgba(200,30,0,0.7),inset 0 0 20px rgba(0,0,0,0.4)}}
</style>
</head>
<body>
<div class="outer">
<div class="hal-face">
<div class="title-bar">
<div class="pal-title">PAL<span>8000</span></div>
<div><span class="status-dot"></span><span class="status-text">MONITORING ACTIVE</span></div>
</div>
<div class="eye-section">
<div class="eye-outer">
<div class="eye-ring">
<div class="eye-lens">
<div class="scan-line"></div>
<div class="eye-reflection"></div>
<div class="voc-label">VOC INDEX</div>
<div class="voc-value" id="vocVal">--</div>
<div class="voc-unit">/ 500</div>
<div class="voc-status" id="vocStatus">CONNECTING...</div>
</div>
</div>
</div>
</div>
<div class="metrics-row">
<div class="metric-card">
<div class="metric-label">RAW</div>
<div class="metric-value" id="rawVal">--</div>
<div class="metric-sub">current index</div>
</div>
<div class="metric-card">
<div class="metric-label">SMOOTHED</div>
<div class="metric-value" id="smoothVal">--</div>
<div class="metric-sub">avg index</div>
</div>
<div class="metric-card">
<div class="metric-label">UPTIME</div>
<div class="metric-value" id="uptime">--</div>
<div class="metric-sub">hh:mm:ss</div>
</div>
</div>
<div class="bar-section">
<div class="bar-label">VOC LEVEL</div>
<div class="bar-track">
<div class="bar-fill" id="vocBar" style="width:0%;background:#4aff88"></div>
</div>
<div class="bar-label">0 ——— GOOD ——— 100 ——— MODERATE ——— 200 ——— POOR ——— 400 ——— HAZARDOUS ——— 500</div>
</div>
<div class="footer-row">
<div class="footer-text">SGP40 · I2C · PICO W</div>
<div class="footer-text" id="lastUpdate">LAST UPDATE: --:--:--</div>
</div>
</div>
</div>
<script>
function getStatus(v){
if(v<=100)return{text:'CLEAN AIR',color:'#4aff88'};
if(v<=200)return{text:'ACCEPTABLE',color:'#ffcc44'};
if(v<=400)return{text:'POOR AIR',color:'#ff8833'};
return{text:'HAZARDOUS',color:'#ff3333'};
}
function getBarColor(v){
if(v<=100)return'#4aff88';
if(v<=200)return'#ffcc44';
if(v<=400)return'#ff8833';
return'#ff3333';
}
function padZ(n){return String(n).padStart(2,'0')}
function fmtUptime(s){
var h=Math.floor(s/3600),m=Math.floor((s%3600)/60),sec=s%60;
return padZ(h)+':'+padZ(m)+':'+padZ(sec);
}
async function fetchVOC(){
try{
const r=await fetch('/voc');
const d=await r.json();
document.getElementById('vocVal').textContent=d.voc;
document.getElementById('rawVal').textContent=d.voc;
document.getElementById('smoothVal').textContent=d.smooth;
document.getElementById('uptime').textContent=fmtUptime(d.uptime);
var s=getStatus(d.voc);
var st=document.getElementById('vocStatus');
st.textContent=s.text;st.style.color=s.color;
var pct=Math.min((d.voc/500)*100,100).toFixed(1);
var bar=document.getElementById('vocBar');
bar.style.width=pct+'%';bar.style.background=getBarColor(d.voc);
var now=new Date();
document.getElementById('lastUpdate').textContent=
'LAST UPDATE: '+padZ(now.getHours())+':'+padZ(now.getMinutes())+':'+padZ(now.getSeconds());
}catch(e){
document.getElementById('vocStatus').textContent='NO SIGNAL';
}
}
setInterval(fetchVOC,3000);
fetchVOC();
</script>
</body>
</html>
)rawhtml";
void handleRoot() {
server.send(200, "text/html", HTML_PAGE);
}
void handleVOC() {
unsigned long uptime = (millis() - bootTime) / 1000;
String json = "{\"voc\":";
json += vocRaw;
json += ",\"smooth\":";
json += vocSmooth;
json += ",\"uptime\":";
json += uptime;
json += "}";
server.send(200, "application/json", json);
}
void handleNotFound() {
server.send(404, "text/plain", "Not found");
}
void ledIdle() {
analogWrite(LED_PIN, LED_IDLE);
}
void ledFade(uint8_t from, uint8_t to, uint32_t ms) {
const int steps = 200;
int32_t  delta     = (int32_t)to - (int32_t)from;
uint32_t stepDelay = ms / steps;
for (int i = 0; i <= steps; i++) {
analogWrite(LED_PIN, (uint8_t)(from + (delta * i) / steps));
delay(stepDelay);
server.handleClient();  // keep web server alive during fades
}
}
void ledBreatheForMs(uint32_t totalMs) {
const uint32_t BREATH_CYCLE = 2000;
uint32_t start = millis();
while (millis() - start < totalMs) {
uint32_t elapsed = millis() - start;
float t      = (float)(elapsed % BREATH_CYCLE) / (float)BREATH_CYCLE;
float norm   = sin(t * PI);
float bright = LED_IDLE + norm * (LED_PEAK - LED_IDLE);
analogWrite(LED_PIN, (uint8_t)bright);
delay(10);
server.handleClient();
}
ledIdle();
}
void playBlocking(uint8_t track) {
Serial.print(F("[PLAY] ")); Serial.println(track);
player.play(track);
delay(800);
uint32_t remaining = (TRACK_MS[track] > 800) ? TRACK_MS[track] - 800 : 0;
if (remaining > 0) ledBreatheForMs(remaining);
delay(200);
ledIdle();
}
bool initSensor() {
if (sgp.begin()) {
sensorOK = true;
Serial.println(F("[SGP40] ready"));
return true;
}
sensorOK = false;
Serial.println(F("[SGP40] not found"));
return false;
}
void pollVOC() {
uint16_t raw = sgp.measureVocIndex();
if (raw > 0) {
vocRaw    = raw;
vocSmooth = (vocSmooth == 0) ? raw
: (uint16_t)((vocSmooth * 7 + raw) / 8);
}
}
void reportVOC() {
Serial.print(F("[REPORT] VOC=")); Serial.println(vocSmooth);
if (vocSmooth <= VOC_GOOD_MAX) {
playBlocking(goodAlt ? 2 : 1);
goodAlt = !goodAlt;
} else if (vocSmooth <= VOC_MODERATE_MAX) {
playBlocking(modAlt ? 4 : 3);
modAlt = !modAlt;
} else {
playBlocking(elevAlt ? 6 : 5);
elevAlt = !elevAlt;
}
}
void setup() {
Serial.begin(9600);
pinMode(LED_PIN, OUTPUT);
analogWrite(LED_PIN, 0);
mySerial.begin(9600);
if (!player.begin(mySerial)) {
Serial.println(F("DFPlayer Mini not found"));
while (true) {
ledFade(0, LED_PEAK, 500);
ledFade(LED_PEAK, 0, 500);
}
}
player.volume(25);
delay(3000);
Wire.setSDA(SGP40_SDA);
Wire.setSCL(SGP40_SCL);
Wire.begin();
initSensor();
Serial.print(F("[WiFi] connecting to "));
Serial.println(WIFI_SSID);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 30) {
delay(500);
Serial.print(".");
attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.println();
Serial.print(F("[WiFi] connected! IP: "));
Serial.println(WiFi.localIP());
} else {
Serial.println(F("[WiFi] failed — running without web server"));
}
server.on("/",    handleRoot);
server.on("/voc", handleVOC);
server.onNotFound(handleNotFound);
server.begin();
Serial.println(F("[HTTP] server started"));
bootTime = millis();
ledFade(0, LED_PEAK, 10000);
ledIdle();
playBlocking(14);
delay(2000);
playBlocking(7);
uint32_t warmStart = millis();
while (millis() - warmStart < 27000UL) {
pollVOC();
server.handleClient();
ledIdle();
delay(500);
}
reportVOC();
unsigned long now = millis();
lastTime07 = lastTimeVOC = lastTime10 = lastTime11 = lastSensorRetry = now;
Serial.println(F("[BOOT] done"));
}
void loop() {
server.handleClient();
unsigned long now = millis();
if (!sensorOK) {
if (!sensorWasLost) {
sensorWasLost = true;
playBlocking(12);
lastSensorRetry = millis();
}
if (millis() - lastSensorRetry >= SENSOR_RETRY) {
lastSensorRetry = millis();
if (initSensor()) {
sensorWasLost = false;
playBlocking(13);
unsigned long t = millis();
lastTime07 = t; lastTimeVOC = t;
lastTime10 = t; lastTime11  = t;
}
}
ledIdle();
delay(200);
return;
}
pollVOC();
bool vocDue = (now - lastTimeVOC >= INTERVAL_VOC);
bool t10Due = (now - lastTime10  >= INTERVAL_10);
bool t11Due = (now - lastTime11  >= INTERVAL_11);
bool t07Due = (now - lastTime07  >= INTERVAL_07);
if (vocDue) {
reportVOC();
lastTimeVOC = millis();
} else if (t10Due) {
playBlocking(10);
lastTime10 = millis();
} else if (t11Due) {
playBlocking(11);
lastTime11 = millis();
} else if (t07Due) {
playBlocking(7);
lastTime07 = millis();
} else {
ledIdle();
delay(200);
}
}

Credits

Arnov Sharma
375 projects • 392 followers
I'm Arnov. I build, design, and experiment with tech—3D printing, PCB design, and retro consoles are my jam.

Comments