สวัสดีครับชาว Maker ทุกคน! วันนี้เราจะพามารู้จักกับ PAL 8000 หุ่นยนต์ตรวจวัดคุณภาพอากาศภายในห้อง (Room Air Quality Monitor) ที่ไม่ได้แค่แสดงตัวเลขน่าเบื่อๆ แต่มันสามารถ "พูด" และคอยเตือนเราด้วยน้ำเสียงที่เย็นยะเยือกสุดๆ ครับ
โปรเจกต์นี้ได้แรงบันดาลใจมาจาก HAL 9000 AI สุดคลาสสิกจากภาพยนตร์เรื่อง 2001: A Space Odyssey แทนที่เราจะเทรนโมเดล AI หนักๆ เพื่อวิเคราะห์อากาศ เราเลือกใช้ทางที่ง่ายและฉลาดกว่าด้วยบอร์ด Raspberry Pi Pico W ทำงานร่วมกับ DFPlayer Mini และเซนเซอร์วัด VOC เพื่อให้มันประเมินคุณภาพอากาศและสุ่มเปิดไฟล์เสียงตอบโต้เราแบบเนียนๆ เหมือนมี AI เฝ้าห้องอยู่จริงๆ ครับ!
VIDEO
อุปกรณ์ที่ต้องใช้ (Materials Required)
ของที่ใช้ในโปรเจกต์นี้เป็นการผสมผสานทั้งงาน 3D Print อิเล็กทรอนิกส์ และแผ่นวงจร PCB ครับ (กระซิบ: ใครกำลังมองหาบอร์ด Raspberry Pi Pico W, เซนเซอร์วัดคุณภาพอากาศ, บอร์ดเสียง DFPlayer Mini หรือ เส้นพลาสติก 3D Print (PLA) สีขาว ดำ และใส เพื่อเอาไปทำเคสเนียนๆ แบบนี้ แวะไปช้อปอุปกรณ์คุณภาพเยี่ยมได้ที่ Globalbyte เลยครับ ครบจบในที่เดียว!)
Raspberry Pi PICO W (อัปเกรดจาก Pico ธรรมดา เพื่อให้มี Web App)
DF Mini Player พร้อม SD Card ที่ลงไฟล์เสียงเรียบร้อยแล้ว
Custom PCBs (PICO DRIVER และ LED BOARD)
เซนเซอร์วัดอากาศ Sensirion SGP40 (วัดค่า VOC)
SMD-3030-LEDs สีแดง (สำหรับทำตาของ PAL 8000)
8205S Mosfet IC, ตัวต้านทาน 10K, IP5306 Power management IC
ตัวเก็บประจุ 10 μF, Inductor SMD 1 μH, พอร์ต Type C, ปุ่มกดแบบ Rocker Switch
ลำโพง 4 Ohms 2W
ชิ้นส่วน 3D Printed Parts และน็อต M2
แนวคิดและการออกแบบ (Concept & 3D Design)
ในหนัง HAL 9000 เป็น AI ที่มีน้ำเสียงนุ่มนวล ใจเย็น แต่แฝงไปด้วยความน่ากลัวและไม่น่าไว้วางใจ ผมอยากให้ PAL 8000 ให้ความรู้สึกแบบนั้นเลยครับ! โดยใช้ Sensirion SGP40 เป็นตัวดมกลิ่นและวัดระดับ VOC (Volatile Organic Compound) ในห้อง ซึ่งจะให้ค่าดัชนี (VOC Index) ตั้งแต่ 0 ถึง 500 ถ้ายิ่งตัวเลขสูงแปลว่าอากาศยิ่งแย่
การออกแบบเคสด้วย Fusion 360 เราพยายามจำลองตาของ HAL 9000 มาครับ ตัวเคสแบ่งเป็นส่วนหน้าและส่วนหลัง ปริ้นท์ด้วย PLA สีขาว ส่วนฝาครอบด้านหน้าใช้ PLA สีดำ และทำแผ่นป้ายชื่อ "PAL" และ "8000" สีน้ำเงิน-ขาวตามแบบต้นฉบับเป๊ะ ส่วนที่สำคัญที่สุดคือ "ดวงตาสีแดง" เราใช้พลาสติก PLA แบบใส (Transparent) ปริ้นท์เพื่อให้แสงจาก LED Board กระจายตัวออกมาได้อย่างสวยงามครับ
ดูภาพการออกแบบ 3D และชิ้นส่วนเพิ่มเติม (View more) ซ่อนภาพ (View less)
เอกสาร SGP40: SGP40 Datasheet
การทำแผ่นวงจร PCB และการประกอบ (PCB Assembly)
โปรเจกต์นี้เราใช้แผ่น PCB 2 ส่วนหลักๆ คือ LED Board สำหรับทำดวงตาสีแดง (ใช้ LED 10 ดวงต่อขนานกัน ควบคุมด้วย MOSFET 8205S) และ Pico Driver Board ที่รับหน้าที่เชื่อมต่อ Pico เข้ากับ DFPlayer และระบบจัดการพลังงานแบตเตอรี่ (IP5306) ครับ โดยบอร์ดเหล่านี้ผมรีไซเคิลดีไซน์มาจากโปรเจกต์เก่าอย่าง The Beetles PCB ART และ Mega Man Buster
ในการสั่งผลิต ผมใช้บริการของ HQ NextPCB ซึ่งมีเครื่องมือ HQDFM ให้เราตรวจเช็ค Gerber file ได้ฟรีก่อนสั่งผลิต ช่วยลดความผิดพลาดได้เยอะมาก แถมช่วงนี้เขามีแคมเปญ NextPCB Accelerator ที่ประกอบบอร์ดตระกูล RP2040 ให้ฟรีๆ ด้วยนะครับ!
ดูภาพแผงวงจรและการบัดกรีเพิ่มเติม (View more) ซ่อนภาพ (View less)
ดูวิดีโอขั้นตอนการประกอบบอร์ด PCB ได้ที่นี่ครับ: PAL 8000 PCB ASSEMBLY PROCESS
การตั้งค่าเซนเซอร์และการสร้างเสียงพูด (Sensors & Audio)
สิ่งที่จะทำให้ PAL 8000 ดูมีชีวิตคือ "เสียง" ครับ ผมใช้แพลตฟอร์ม ElevenLabs ในการสร้างเสียง AI สไตล์ผู้ชายโทนต่ำๆ นิ่งๆ ดูไร้อารมณ์ความรู้สึก ผมเขียนสคริปต์สั้นๆ 18 ประโยค มีทั้งรายงานสภาพอากาศ และประโยคหลอนๆ อย่าง "I will continue watching" (ฉันจะเฝ้ามองต่อไป) เซฟเป็น MP3 ลง SD Card ตั้งชื่อ 01 ถึง 018 เพื่อให้โค้ด Arduino เรียกใช้งานได้ง่ายๆ ครับ
ประกอบร่าง! (Final Assembly & Wiring)
ขั้นตอนการประกอบลงกล่อง 3D ผมใช้การประกอบประกบชิ้นส่วนแบบ Snug fit ทั้งหมด ยึดหน้าจอ ลำโพง และไฟ LED ด้านหน้า ส่วนด้านหลังจะมีแบตเตอรี่ Lithium-ion 2200 mAh 2 ก้อนต่อขนานกัน (ความจุรวม 4400 mAh) และใช้เครื่อง Spot welding ยึดแผ่นนิกเกิล (ห้ามเอาหัวแร้งจี้แบตโดยตรงเด็ดขาดนะครับ อันตรายมาก!) เสร็จแล้วก็เดินสายไฟ VCC, GND, I2C เข้าบอร์ด Pico W ครับ
ดูภาพขั้นตอนการประกอบเพิ่มเติม (View more) ซ่อนภาพ (View less)
โค้ดควบคุมและ Web Dashboard (Code & Web App)
ในเวอร์ชันอัปเกรดล่าสุด เราเปลี่ยนมาใช้บอร์ด Raspberry Pi Pico W แทน Pico ธรรมดา เพื่อให้เครื่องสามารถรัน Web Server ภายในตัว ได้! นั่นแปลว่าคุณสามารถใช้มือถือหรือคอมพิวเตอร์ที่อยู่ในวง WiFi เดียวกัน พิมพ์ IP ของเครื่อง เพื่อดูหน้า Dashboard ที่โชว์ค่า VOC สวยๆ (ออกแบบด้วย HTML/CSS สไตล์ตาของ HAL 9000 ด้วยนะ) ได้เลยครับ!
อย่าลืมเข้าไปเปลี่ยน WIFI_SSID และ WIFI_PASSWORD ในโค้ดก่อนทำการอัปโหลดลงบอร์ดนะครับ
#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, 4000, 5000, 4000, 4000, 2000, 2000, 3000, 1000, 2000, 2000, 10000, 10000, 9000, 15000, 0, 0, 1000, 1000
};
#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 (Stored in PROGMEM to save RAM)
const char HTML_PAGE[] PROGMEM = R"rawhtml(
PAL8000
MONITORING ACTIVE
VOC INDEX
--
/ 500
CONNECTING...
VOC LEVEL
0 ——— GOOD ——— 100 ——— MODERATE ——— 200 ——— POOR ——— 400 ——— HAZARDOUS ——— 500
)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);
}
}
ดูโค้ดฉบับเต็ม (View full code) ซ่อนโค้ด (View less)
สรุปผลลัพธ์ (Result)
ในที่สุด PAL 8000 ของเราก็พร้อมใช้งานครับ! ทันทีที่เปิดเครื่อง ดวงตาสีแดงจะค่อยๆ สว่างขึ้นมา พร้อมกับเสียงทักทายที่เย็นยะเยือก ระหว่างการทำงานมันจะคอยเตือนเราทุกๆ 30 วินาทีว่ามันยังอยู่ และจะรายงานคุณภาพอากาศให้เราทราบทุกนาที ดวงตา LED ก็จะเต้นเป็นจังหวะการหายใจ (Breathing effect) ไปพร้อมๆ กัน
เพื่อความชัวร์ว่าเซนเซอร์ทำงานได้จริง ผมลองจุดธูปแล้วเอาไปจ่อใกล้ๆ เซนเซอร์ดูครับ ปรากฏว่าค่า VOC พุ่งพรวด! และ PAL 8000 ก็เปลี่ยนน้ำเสียงการรายงานเป็นโหมดแจ้งเตือนฉุกเฉินทันที ระบบนี้ทำงานได้เป๊ะตามที่ตั้งใจไว้เลยล่ะครับ
ไอเดียพัฒนาต่อยอด: สำหรับเวอร์ชัน 2 ผมตั้งใจว่าจะขยายคลังเสียงให้เยอะขึ้น และอาจจะทำ Dialogue Tree (ระบบโต้ตอบแบบมีเงื่อนไขซับซ้อนขึ้นเหมือนในเกม) และจะเพิ่มเซนเซอร์ตัวอื่นๆ เข้ามาเพื่อให้มันตอบสนองได้หลากหลายขึ้นครับ (หลายคนอาจจะบอกว่าทำไมไม่ยัด AI Chatbot ลงไปเลยล่ะ? ก็เพราะผมอยากพิสูจน์ไงครับ ว่าเราสามารถสร้าง "คาแรคเตอร์" ให้กับหุ่นยนต์ได้ด้วยลอจิกโค้ดแบบคลาสสิกล้วนๆ!)
ดูภาพการทำงานตอนเสร็จสมบูรณ์ (View more) ซ่อนภาพ (View less)
อ้างอิงข้อมูลจาก: Globalbyteshop Blog
ต้นฉบับโปรเจกต์โดย: Arnov_Sharma_makes | Original Link
ไฟล์และเอกสารเพิ่มเติม:
*คำเตือน: เนื้อหานี้เป็นการสรุปและเรียบเรียงจากบทความโปรเจกต์ต้นฉบับภาษาอังกฤษ ข้อมูลฉบับภาษาไทยและขั้นตอนการทำงานบางส่วนอาจถูกปรับเพื่อความเข้าใจที่ง่ายขึ้น สามารถตรวจสอบรายละเอียดเชิงเทคนิค กระบวนการออกแบบ PCB และโค้ดเชิงลึกได้ที่
ต้นฉบับภาษาอังกฤษ