Greetings everyone, and welcome back.
Meet PAL 8000, a personal room air quality bot that monitors your indoor air quality with a twist. The twist is that it is inspired by HAL 9000, the iconic AI from Stanley Kubrick's 2001: A Space Odyssey.
The whole idea came about when I rewatched 2001: A Space Odyssey recently and thought, I need a HAL 9000 in my workspace. At the time, I was already working on indoor air quality meters, and that's when it clicked: why not combine both into an assistant-like device that tells you what the air quality in your room is?
One approach was to build an actual AI model, train it on TVOC readings, and have it classify good or bad air quality intelligently. That could work, but it was tedious, and honestly, I'm a lazy guy.
So I went with the simpler route: a Raspberry Pi Pico 2 paired with a DFRobot DFPlayer Mini module, which plays audio files stored on a microSD card.
These are pre-recorded clips that I made myself using Elevenlabs. One of these clips is an introduction clip that responds like, "I'm PAL 8000, your environmental monitoring system." I have also added a few audio clips that respond to increasing VOC Levels that say "Warning, air quality has degraded" and a few eerie lines like "I'm watching" to give it that sinister HAL 9000 personality.
The logic is straightforward: on startup, PAL 8000 plays an introduction clip. It then continuously reads the VOC index from the sensor. If the reading falls between 0 and 100, it plays the "clean air" clip; as readings rise, it switches to progressively more urgent clips. So it behaves like an AI, but it is completely artificial and definitely not intelligent.
For air quality sensing, I used the Sensirion SGP40, a dedicated indoor VOC (Volatile Organic Compound) sensor. It outputs a VOC index, a value from 0 to 500, where lower is cleaner air. The sensor communicates over I2C and is mounted on the back of the PAL 8000 enclosure.
I also designed an enclosure that closely resembles HAL 9000's iconic circular eye design. Mine differs in one way: HAL's enclosure was metallic silver, while mine is white.
Because we are using a Raspberry Pi Pico W, real-time VOC data can be accessed through a locally hosted web app, letting you monitor your room's air quality directly from a browser on any device on the same network.
This Article covers the entire build process from start to finish, so let's get started.
MATERIALS REQUIREDThese were the materials used in this project—
- Raspberry Pi PICO W
- DF Mini Player
- Custom PCBs (PICO DRIVER and LED BOARD)
- SMD-3030-LEDs RED
- 8205S Mosfet IC
- 10K Resistor
- IP5306 Power management IC
- 10 μF 1206 Capacitors
- SMD 0805 LED
- Inductor SMD 1 μH
- Push Button - Rocker Switch Type
- Type C Port
- Female header Pin connectors
- 3D Printed Parts
- M2 screws
- Speaker 4 Ohms 2W
- SD CARD with Audio Clips
As stated earlier, the whole idea behind this project came after rewatching 2001: A Space Odyssey, combined with the fact that I was already working on an air quality project. I thought, why not build a HAL 9000-like device that looks and sounds like him while also providing real feedback on air quality?
For those who don't know, HAL 9000 is an artificial intelligence and the onboard computer of the spaceship Discovery One in 2001: A Space Odyssey. He is the hidden main antagonist of the film. HAL is capable of many functions—speech, speech recognition, facial recognition, lip-reading, interpreting and expressing emotions, and even playing chess, all while maintaining every system aboard Discovery. HAL speaks in a soothing male voice, always calm, always measured, which somehow makes him sound even more unsettling.
My goal with PAL 8000 is to recreate that eerie, eerily calm feeling, not a direct copy but something that carries the same atmosphere.
Using a Raspberry Pi Pico 2 paired with a DFPlayer Mini module, along with audio clips I generated using ElevenLabs, I built PAL 8000 around a Sensirion SGP40 indoor air quality sensor. The SGP40 continuously measures VOC (Volatile Organic Compound) levels in the room and outputs a VOC Index value ranging from 0 to 500. Based on where that reading falls, PAL 8000 plays a corresponding audio clip; if the reading is between 0 and 100, it plays a clip indicating clean air; as readings climb higher, the responses become progressively more urgent, shifting from gentle observations to quiet warnings, all delivered in that same unsettling calm tone.
DESIGNTo get started with the design, I began by preparing the enclosure in Fusion 360. I imported 3D models of my existing LED board and Pico driver board from previous projects and searched for a high-quality front-facing reference image of HAL 9000. This image was used to trace the basic outline of the enclosure, which was then extruded and refined to house the Pico driver board and LED board inside. The LED board is positioned specifically to serve as the eye of PAL 8000.
The whole device is split into two parts: The Front body and The Back body.
The front body acts as a lid and is the primary aesthetic face of the device. It is printed in a single color, which is white. To match the exact look of HAL 9000, I modeled a cover piece that pressure-fits on top of the front body, printed in black PLA. On this cover, I also modeled two additional nameplate parts carrying the text "PAL" and "8000, " both printed in blue and white, matching the color scheme seen on HAL 9000 in the film.
The red eye is a diffuser component printed in transparent PLA. To keep the LED board securely in place, I modeled a dedicated LED board holder that mounts to the front body from the inside, with the diffuser positioned over it.
For the speaker, I designed a speaker grille and a speaker holder, both secured together and mounted onto the front body.
The back body houses the lithium cells, which are held in place using retaining ribs molded around them. The cells sit in position and are fully locked once both bodies are fastened together. The back body also features an opening used to mount and secure the SGP40 sensor, as well as a slot for the rocker switch.
For the main circuit, four screw bosses are added to the enclosure, allowing the circuit board to be secured using four M2 screws. Both the front and back bodies are joined together and fastened using M2 screws, with mounting holes added at the top and bottom of the enclosure.
3D PRINTED PARTSAfter modeling all the parts, we exported all the mesh files and 3D printed them on our Anycubic Kobra S1 printer. Using a 0.4 mm nozzle, we set the printer to a 0.2 mm layer height with 25% infill. We printed the front and back body first using white Hyper PLA. Since supports were required, I used regular supports with a snug setting and a top layer gap of 0.3 mm, which allowed for easy removal.
Using similar settings, we printed the speaker holder and LED board holder in white Hyper PLA, along with the “8000” nameplate. The speaker grill was printed in grey Hyper PLA using the same settings, and the "PAL" nameplate was printed with blue Hyper PLA. For the front, we added a cover part printed in black Hyper PLA.
Finally, for the LED diffuser, we slightly modified the settings. The layer height and infill remained the same, but we changed the sparse infill direction from the default to 90 degrees. This makes the printer lay lines in a single direction, which helps transparent PLA appear clearer, as the usual grid pattern tends to increase opacity.
SGP40 INDOOR AIR QUALITY METERThe SGP40 Sensor for monitoring air quality from a prior project was repurposed for this one.
The SGP40 sensor was created especially to measure Volatile Organic Compounds (VOC) and evaluate pollution levels in enclosed areas in order to monitor indoor air quality. It works best in regulated interior spaces with consistent ventilation, humidity, and temperature.
Because external environmental factors, including extreme temperature swings, humidity swings, and direct contact with pollutants, can significantly affect the sensor's accuracy and reliability, it is not appropriate for outdoor use.
It's best to use the SGP40 indoors, where air quality testing is crucial, such as in homes, offices, and industrial settings, for optimal results.
Below is the SGP40 Datasheet for more thorough details about the project.
https://evelta.com/content/datasheets/011-SGP40-Datasheet.pdf
Our probe is a PG7 Cable Gland, a specialized connector used to seal and fasten electrical cables when they enter panels, housings, or enclosures. A circuit with the SGP40 is part of the Probe and is located inside our PG7 Cable Gland.
PCB DESIGN—LED BOARDFor the eye of the PAL8000, we are reusing one of our previously designed LED board PCBs that we used in the Mega Man Buster project. Here, we have a round PCB that contains 10 × 3030 package red LEDs, all connected through an 8205S N-channel MOSFET. This MOSFET is configured as a switch, with two 10 kΩ resistors connecting the gate to the source, and another resistor placed between the DIN pin and the gate.
The idea is to connect the VCC and GND of this board to 5V and use a GPIO pin of the Pico to control the MOSFET. Each LED is roughly 0.2W, and with 10 LEDs in parallel, the total power can reach around 2W, which can generate significant heat. To manage this, we have added current-limiting resistors (four 1206 package resistors). The goal is to drive the LEDs under 1W so that we avoid creating excessive heat inside the PLA body.
NextPCB PCB SERVICEAfter completing the PCB design, Gerber data for both PCBs was sent to HQ NextPCB, and an order was placed for RED Solder mask boards with a white silkscreen and one board with a green solder mask and white silkscreen.
After placing the order, the PCBs were received within a week, and the PCB quality was pretty great.
In addition, I have to bring in HQDFM to you, which helped me a lot through many projects. Huaqiu’s in-house engineers developed the free Design for Manufacturing software, HQDFM, revolutionizing how PCB designers visualize and verify their designs.
Take advantage of NextPCB's Accelerator campaign and get 2 free assembled RP2040-based PCBs for your innovative projects.
https://www.nextpcb.com/blog/rp2040-free-pcba-prototypes-nextpcb-accelerator
This offer covers all costs, including logistics, making it easier and more affordable to bring your ideas to life. SMT services can be expensive, but NextPCB is here to help you overcome that hurdle. Simply share your relevant project, and they'll take care of the rest. Don't miss out on this amazing opportunity to advance your tech creations!
HQDFM: Free Online Gerber Viewer and DFM Analysis ToolAlso, NextPCB has its own Gerber Viewer and DFM analysis software.
Your designs are improved by their HQDFM software (DFM) services. Since I find it annoying to have to wait around for DFM reports from manufacturers, HQDFM is the most efficient method for performing a pre-event self-check.
This is what I see in the online Gerber Viewer. It's decent for a quick look, but not entirely clear. For full functionality—like detailed DFM analysis for PCBA—you’ll need to download the desktop software. The web version only offers a basic DFM report.
With comprehensive Design for Manufacture (DFM) analysis features, HQDFM is a free, sophisticated online PCB Gerber file viewer.
With over 15 years of industry experience, it offers valuable insights into advanced manufacturing processes. If you’re looking for reliable PCB services at a budget-friendly price, HQ NextPCB is definitely worth checking out.
PCB ASSEMBLY PROCESS—LED BOARD- The Assembly process starts by applying solder paste to each component pad on the PCB.
- Once the solder paste is applied, the SMD red 3030 LEDs are placed onto their respective footprints. This is followed by placing the MOSFET IC and the SMD resistors, using tweezers to accurately position each component.
- After all components are placed, the PCB is carefully transferred to a reflow hot plate. The hot plate heats the PCB from below until it reaches the solder paste melting temperature. As the board reaches 200°C, the solder paste reflows and securely bonds all components to the PCB.
- To verify that all LEDs are soldered correctly, a multimeter set to diode test mode is used. The positive probe is placed on the VCC rail of the LED board, while the negative probe is connected to the drain terminal of the MOSFET.
If all LEDs illuminate, this confirms that the soldering and connections are correct.
PCB DESIGN —PICO DRIVERThe second PCB used in this project is the Pico Driver Board, which we also reused from a previously made project, the Beetle board. This Pico driver is a simple design. Here, the Raspberry Pi Pico is connected to the DFPlayer Mini through GPIO7 and GPIO8, which serve as its TX and RX pins.
This board also contains 24 RGB LEDs, which we did not use in this build, as our goal was to use only the Pico and DFPlayer setup. There is also an IP5306 IC setup, which is a power management IC we’ve used in many previous projects. It provides a stable 5V output from a 3.7V lithium cell and includes features like charging indication, low battery warning, and high/low voltage cutoffs, all essential for safe lithium cell operation.
The board also contains a few 10 µF capacitors, an SMD 1 µH inductor, an indicator LED that shows battery and charging status, and a Type-C port for charging.
PCB ASSEMBLY PROCESS —PICO DRIVER- We begin assembling the main board by applying solder paste to each SMD pad using a dispensing needle. For this build, we’re using 63/37 Sn/Pb solder paste, which has a melting point of around 200°C.
- Next comes the pick-and-place stage, where each surface-mount component, which includes the IP5306 power management IC, capacitors, inductors, and resistors, all are carefully positioned using ESD-safe tweezers.
- Once all components are in location, the PCB is transferred to a reflow hotplate. The board is heated from below until it reaches the solder paste’s melting temperature. At that point, the solder melts, locking all SMD components securely in place.
- With the surface-mount process complete, we move on to the through-hole components. Female header pins for the Raspberry Pi Pico and DFPlayer Mini, a USB Type-C port, male header pins, and an inductor are added from the top side of the board.
- The PCB is then flipped, and all through-hole pads are soldered manually using a soldering iron to ensure strong mechanical and electrical connections.
- Finally, the Raspberry Pi Pico and DFPlayer Mini are mounted onto their respective headers, marking the completion of the assembly process.
For the power source, we used two 2200 mAh, 3.7V lithium-ion cells connected in parallel, creating a 3.7V battery with a total capacity of 4400 mAh. This is sufficient to run the device for a couple of days continuously.
Please note that we did not solder wires directly to the cells. Instead, we used a lithium cell spot welding machine to attach a nickel strip to both ends of the cells and then soldered the connector to the middle of the strip.
It is well known that excessive heat applied to lithium cells can potentially cause fire or explosion in some cases, so it is strongly recommended to never directly touch a soldering iron to the cell terminals.
ELECTRONICS SETUPWe next prepared the wiring for our Pico driver board with the LED board, speaker, and the SGP40 sensor.
- We began with the SGP40 sensor. The VCC of the SGP40 is soldered to the 3V3 pin of the Pico. GND is connected to GND. SDA is soldered to GPIO4, and SCL is soldered to GPIO5.
- Next comes the speaker wiring, where the positive terminal of the speaker is connected to the DFPlayer’s SPK1 pin, and the negative terminal is connected to the SPK2 pin of the DFPlayer.
- The LED board’s VCC is connected to the 5V output pin of the IP5306, GND is connected to GND, and the DIN pin is connected to GPIO0 of the Pico.
One of the most important parts of PAL 8000 is the voice. Without the right voice, it would just be another sensor talking. The goal was to recreate that signature HAL 9000 sound, calm, measured, slightly detached, and just unsettling enough to make you pay attention.
For generating the voice clips, I used ElevenLabs, an AI voice generation platform that lets you design custom voices or pick from an existing library. I experimented with a few different voices before landing on one that felt close to what I had in mind: a smooth, low male voice with no warmth in it.
I wrote 18 individual scripts, each one short and deliberate. The writing style was intentional. Everything HAL says in the film feels considered, like every word was chosen carefully, and I tried to carry that same quality into PAL 8000's lines. Some of the clips are purely functional, like the air quality reports, while others are there just to build atmosphere, lines like "Everything is very quiet" or "I will continue watching" that serve no practical purpose but make the device feel alive.
Once the clips were generated and reviewed, I exported all 18 as MP3 files and loaded them onto the microSD card that sits inside the DFPlayer Mini module. The DFPlayer reads files by their filename order, so the files are named 01 through 018. The Arduino code then calls player.play(1) through player.play(18) to trigger the corresponding clip.
DEMO CODEBelow is the code we used for phase 1 of this project.
/*
* =====================================================
* PAL 8000 — Air Quality Monitor Created by Arnov Sharma
* Board : Raspberry Pi Pico 2
* Sensor : Adafruit SGP40
* Audio : DFRobot DFPlayer Mini
* LED : PAL 8000 Eye (PWM, GPIO 0)
* =====================================================
*
* Pin Map
* -------
* GP0 → PAL 8000 Eye LED (PWM)
* GP4 → SGP40 SDA (I2C0)
* GP5 → SGP40 SCL (I2C0)
* GP7 → DFPlayer RX (SoftwareSerial)
* GP8 → DFPlayer TX (SoftwareSerial)
*
*/
#include <Arduino.h>
#include <Wire.h>
#include <SoftwareSerial.h>
#include <DFRobotDFPlayerMini.h>
#include <Adafruit_SGP40.h>
#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;
bool sensorOK = false;
bool sensorWasLost = false;
bool goodAlt = false;
bool modAlt = false;
bool elevAlt = false;
uint16_t vocSmooth = 0;
unsigned long lastTime07 = 0;
unsigned long lastTimeVOC = 0;
unsigned long lastTime10 = 0;
unsigned long lastTime11 = 0;
unsigned long lastSensorRetry = 0;
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);
}
}
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);
}
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) {
vocSmooth = (vocSmooth == 0) ? raw
: (uint16_t)((vocSmooth * 7 + raw) / 8);
Serial.print(F("[VOC] raw=")); Serial.print(raw);
Serial.print(F(" smooth=")); Serial.println(vocSmooth);
}
}
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.println(F("[BOOT] LED fade up"));
ledFade(0, LED_PEAK, 10000);
ledIdle();
Serial.println(F("[BOOT] track 14"));
playBlocking(14);
delay(2000);
Serial.println(F("[BOOT] track 07"));
playBlocking(7);
Serial.println(F("[BOOT] SGP40 warm-up..."));
uint32_t warmStart = millis();
while (millis() - warmStart < 27000UL) {
pollVOC();
ledIdle();
delay(500);
}
Serial.println(F("[BOOT] first VOC report"));
reportVOC();
unsigned long now = millis();
lastTime07 = now;
lastTimeVOC = now;
lastTime10 = now;
lastTime11 = now;
lastSensorRetry = now;
Serial.println(F("[BOOT] done — entering loop"));
}
void loop() {
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(500);
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);
}
}Here's a little breakdown of this version of the code.
We use the following libraries in our sketch that you first need to install or update to the latest version in order to compile this code without any issues.
#include <Arduino.h>
#include <Wire.h>
#include <SoftwareSerial.h>
#include <DFRobotDFPlayerMini.h>
#include <Adafruit_SGP40.h>This is the Pin Definitions & Constants that define which GPIO Pins are being used.
#define LED_PIN 0
#define DF_RX 7
#define DF_TX 8We also added a section for controlling the LED brightness level.
#define LED_IDLE 20
#define LED_PEAK 80Here is the audio track timing table that stores the duration of each audio track in ms. This is used so the LED animation matches the audio length.
const uint16_t TRACK_MS[] = { ... };This section controls how often things happen, like the VOC report every 60s, Track 07 every 30s, and other things that happen at longer intervals.
#define INTERVAL_07 30000UL
#define INTERVAL_VOC 60000ULThis defines Air Quality Levels. Less than 100 is good, more than 200 is poor, and between 100 and 200 is moderate.
#define VOC_GOOD_MAX 100
#define VOC_MODERATE_MAX 200Using the below section, we creates object for our MP3 player and SGP40 sensor.
SoftwareSerial mySerial(7, 8);
DFRobotDFPlayerMini player;
Adafruit_SGP40 sgp;
This stores the system state.
bool sensorOK = false;
uint16_t vocSmooth = 0;This is used for timing events.
unsigned long lastTime07 = 0;We use the below function to keeps LED at low brightness.
void ledIdle()We have a function for LED fade, which makes led smooth brightness transition.
void ledFade()We also have a breathing effect function, in which LED pulses using singe wave, this make device look alive.
void ledBreatheForMs()We have a playback function that plays a track, wait unitils it finishes, and runs the LED Breathing animation during playback of the track.
void playBlocking(uint8_t track)There is an Init Sensor function that starts SGP40 and sets sensorOK.
bool initSensor()Using the below function, set up reads raw VOC Values and also applies smoothing.
void pollVOC()This does Smoothing.
(vocSmooth * 7 + raw) / 8Using the below section, set up checks for VOC Level, Plays corresponding audio, if value is GOOD, track 1 or 2 will play, if value is moderate, track 3 or 4 will play, if value is poor, track 5 or 6 will play.
We added alternate tracks for creating variations.
void reportVOC()Next is the setup function.
void setup()In this, the serial starts, the LED is set up, the DF player is initialised, the volume is set, the I2C is initialized with the sensor, and the LED startup animation runs.
In Setup, we play the intro track, which is 14 then 7. During this playback, the sensor gets warmed up for 27 seconds, and after that first VOC report is provided with corrosponding track.
SIMPLE LOGIC
Our Device continuously monitors air quality using the SGP40 sensor. Based on the VOC readings, it classifies air quality into good, moderate, or poor and plays corresponding audio messages that we named 01 to 18 stored in the SD card through the DFPlayer.
The LED provides visual feedback by staying dim when idle and performing breathing animations during audio playback. We have used timers (millis) to schedule different actions like periodic announcements and system messages without blocking execution.
If the sensor disconnects, the system detects it, plays an error sound, and keeps retrying until the sensor reconnects, ensuring reliability.
At this stage, we had only developed the core logic for the PAL8000 to run offline, with no web app functionality included yet. The web features will be integrated later, toward the end of the build. This version of the code was focused purely on establishing and stabilizing the system. We went through extensive debugging during this phase, and in the end, everything came together really well.
BACK BODY & SGP40 SENSOR ASSEMBLYAfter the demo run, we desoldered all the components from the Pico driver board. We did this so we could reinstall them individually, along with the enclosure.
- We started by removing the retaining nut from the PG7 gland of the SGP40 probe, then passed the wire through the mounting hole on the back body.
- We positioned the SGP40 sensor in place and tightened the nut back, securing the PG7 firmly.
Next, we used a push button in the form factor of a rocker switch and positioned it in the slot on the back body. It is pressure-fitted into place, and the rocker switch includes two locking tabs that ensure it is held securely in position.
SPEAKER GRILL ASSEMBLY- We next start the speaker assembly process, which begins by positioning the speaker holder over the speaker’s mounting holes, then using two M2 screws to secure them together.
- The speaker grill is then positioned over the speaker holder, and both parts are pressure-fitted together.
- We passed the wires of the LED board through the hole in the LED board holder, then aligned the mounting holes of the LED board with the holder and used two M2 screws to secure them together.
- The LED diffuser is placed on top of the LED board holder, and both parts are pressure-fitted together.
- The LED board is now positioned in its place on the front body. It is inserted from the inside of the front body and pressure-fitted into place.
- Similarly, the speaker assembly is added from the front side and is also pressure-fitted into position.
The Pico driver board is first reconnected to the LED board and speaker. We solder the LED board’s VCC and GND to the 5V, and GND of the Pico driver, and the speaker wires are connected to the DFPlayer’s SPK1 and SPK2 pins.
Similarly, we connect the SGP40 sensor’s SDA and SCL pins to GPIO4 and GPIO5 of the Pico. The VCC of the SGP40 is connected to the 3V3 pin of the Pico, and GND is connected to GND. Additionally, the rocker switch wires are connected to GND and the SW pin of the IP5306 IC. Pressing the rocker switch turns ON the IP5306 setup, and double-pressing the button turns the device OFF.
PUTTING THINGS TOGETHERThe Pico driver board is positioned in its designated place on the front body and then secured using four M2 screws.
- We extended the length of the lithium battery connector so it could be placed on one end of the model and still connect to the Pico driver, whose connector is located on the opposite end.
- We then connected the lithium cell’s JST connector to the connector on the Pico driver and placed the battery into its mounting position.
At this stage, with most of the assembly complete and only the final enclosure left to put together, we replaced the existing Pico 2 with a Pico W and uploaded the updated code with web app support. This upgrade enhances the project further by adding Wi-Fi connectivity.
MAIN CODEWe uploaded the sketch below to our PICO W.
#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);
}
}Make sure to fill in your Router's SSID and password before uploading. We get the IP address for our Webapp in the serial monitor once the device gets connected to Wifi.
const char* WIFI_SSID = "SSID";
const char* WIFI_PASSWORD = "PASSWORD";The Logic of this updated code stays the same as before; we have only added Web app functionality. The web app itself is built entirely in a single block of HTML that lives inside our sketch.
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";It is written as a raw string using const char HTML_PAGE[] PROGMEM. The PROGMEM keyword tells the compiler to store this string in flash memory rather than RAM, which is important because the Pico W has limited RAM and a full HTML page would eat into it quickly.
The Pico W runs a lightweight HTTP server using the WebServer library.
#include <WebServer.h>This is the HTML Structure for the eye part.
<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>And this is the CSS structure that makes it look like HAL's lens.
.eye-lens {
width: 190px;
height: 190px;
border-radius: 50%;
background: radial-gradient(circle at 40% 35%, #cc2200, #8b0000 50%, #3d0000 80%, #1a0000);
animation: breathe 3s ease-in-out infinite;
}
@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); }
}As for how the page was built, we have used HTML and CSS. The HAL 9000 face is constructed entirely out of CSS. Three concentric div elements styled as circles using border-radius: 50% create the outer housing, the inner ring, and the red lens. The red glow effect on the lens is done with radial-gradient going from bright red at the center to deep dark red at the edges, combined with a box-shadow that pulses using a CSS @keyframes animation called "breathe." The scanning line across the eye is a thin, absolutely positioned div animated to travel from top to bottom on a 3-second loop. The small lens reflection is just another small div rotated and made semi-transparent.
For the Live Data, JavaScript running in the browser calls fetch('/voc') every 3 seconds using setInterval. The Pico W responds with fresh JSON, and JavaScript updates the VOC number, status text, progress bar color, and uptime counter in place by writing directly to the DOM using document.getElementById. This technique is called polling, and it is the simplest possible way to get live data onto a web page without needing websockets or any complex infrastructure.
We also added a progress bar at the bottom of the page, which is a plain div inside a track div. Its width is calculated as (voc / 500) * 100 percent, and its background color switches between green, yellow, orange, and red depending on which VOC threshold the reading falls into. Both the width and color transition smoothly using CSS transitions, so the bar slides and recolors rather than jumping.
FINAL ASSEMBLYAfter finalizing the code, we assembled the back body with the front body. Both parts are then secured together using two M2 screws on the top side and two M2 screws on the bottom side.
FINISHING TOUCHES- At last, we added a few greeble parts for the aesthetic of the device to make it resemble HAL 9000. We applied a small amount of super glue to the front body and placed the black cover part, securing it in place.
- Next, we applied super glue to the top section of the black cover and attached the “8000” nameplate.
- Right beside it, we added more super glue and positioned the final “PAL” nameplate. This completes the project.
Here is the result of this interesting and eerie build, PAL 8000.
A device that is mounted on a wall, watches the air around you, and speaks to you in a calm, unsettling voice that feels just a little too composed for comfort.
The moment you power it on, the red eye slowly lights up, and then it speaks, introducing itself in that calm, deliberate voice before settling into its watch.
Every 30 seconds, it reminds you it is still there; every minute, it reports what it finds in the air, and the eye breathes the whole time, never fully dim, never fully bright. It is part air quality monitor, part conversation piece, and part tribute to one of the most iconic fictional AIs ever put on screen.
To actually test it and push the VOC readings up, I lit an incense stick and held it close to the SGP40 sensor. My room normally sits comfortably under a VOC index of 100, well within the clean air range, but the moment the incense smoke reached the sensor, the reading spiked sharply, and the PAL 8000 responded exactly as intended; the voice shifted, the tone changed, and suddenly the device felt a lot more like a warning system than a wall ornament. It was the moment the whole build clicked into place.
This project is working quite well, but I already have ideas for version 2. The first improvement will be to significantly expand the audio clip library and build a proper character tree, a branching if-else dialogue system inspired by the kind of dialogue logic used in video games, where the device's response depends not just on one reading but on a combination of conditions.
More sensors will be added, too, and PAL 8000 will react differently based on each sensor value, creating something that feels far more alive and reactive than a simple threshold trigger.
The idea can go much further than this. Making an AI chatbot version is honestly the easy route: swap the Pico for a single board computer, add a speaker and a microphone, and you are mostly there. Someone already built BMO from Adventure Time that way, so building HAL 9000 with full conversational AI is not an impossible ask.
But that was never my goal. My goal was to take a different route, to see how much personality and character you could build into a device using nothing but logic, pre-recorded audio, and a well-thought-out dialogue tree. No language model, no cloud dependency, no internet required to hold a conversation. Just code, clips, and careful writing. And honestly, for a first version, PAL 8000 makes a pretty convincing case that the old-fashioned way still works.
For now, PAL 8000 version 1 is complete, and version 2 is already being planned. If you have questions about the build, want to replicate it, or just want to share your thoughts, drop a comment or feel free to reach out directly. I am always happy to talk about this stuff.
Thanks for reaching this far. Peace out.









_t9PF3orMPd.png?auto=compress%2Cformat&w=40&h=40&fit=fillmax&bg=fff&dpr=2)










Comments