สร้าง PAL 8000: หุ่นยนต์ผู้ช่วยเช็คคุณภาพอากาศ สไตล์ HAL 9000 ด้วย Raspberry Pi Pico W

Pal 8000 Room Air Quality Monitor

สวัสดีครับชาว Maker ทุกคน! วันนี้เราจะพามารู้จักกับ PAL 8000 หุ่นยนต์ตรวจวัดคุณภาพอากาศภายในห้อง (Room Air Quality Monitor) ที่ไม่ได้แค่แสดงตัวเลขน่าเบื่อๆ แต่มันสามารถ "พูด" และคอยเตือนเราด้วยน้ำเสียงที่เย็นยะเยือกสุดๆ ครับ

โปรเจกต์นี้ได้แรงบันดาลใจมาจาก HAL 9000 AI สุดคลาสสิกจากภาพยนตร์เรื่อง 2001: A Space Odyssey แทนที่เราจะเทรนโมเดล AI หนักๆ เพื่อวิเคราะห์อากาศ เราเลือกใช้ทางที่ง่ายและฉลาดกว่าด้วยบอร์ด Raspberry Pi Pico W ทำงานร่วมกับ DFPlayer Mini และเซนเซอร์วัด VOC เพื่อให้มันประเมินคุณภาพอากาศและสุ่มเปิดไฟล์เสียงตอบโต้เราแบบเนียนๆ เหมือนมี AI เฝ้าห้องอยู่จริงๆ ครับ!

อุปกรณ์ที่ต้องใช้ (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 Inspiration

ในหนัง 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 กระจายตัวออกมาได้อย่างสวยงามครับ

Fusion 360 Design 1
Fusion 360 Design 2 Fusion 360 Design 3 Fusion 360 Design 4 3D Printed Parts SGP40 Sensor Assembly

เอกสาร 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 ให้ฟรีๆ ด้วยนะครับ!

LED Board Schematic
NextPCB Boards Pico Driver Assembly Finished Board

ดูวิดีโอขั้นตอนการประกอบบอร์ด PCB ได้ที่นี่ครับ: PAL 8000 PCB ASSEMBLY PROCESS

การตั้งค่าเซนเซอร์และการสร้างเสียงพูด (Sensors & Audio)

ElevenLabs Audio Generation

สิ่งที่จะทำให้ 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 ครับ

โค้ดควบคุมและ Web Dashboard (Code & Web App)

ในเวอร์ชันอัปเกรดล่าสุด เราเปลี่ยนมาใช้บอร์ด Raspberry Pi Pico W แทน Pico ธรรมดา เพื่อให้เครื่องสามารถรัน Web Server ภายในตัว ได้! นั่นแปลว่าคุณสามารถใช้มือถือหรือคอมพิวเตอร์ที่อยู่ในวง WiFi เดียวกัน พิมพ์ IP ของเครื่อง เพื่อดูหน้า Dashboard ที่โชว์ค่า VOC สวยๆ (ออกแบบด้วย HTML/CSS สไตล์ตาของ HAL 9000 ด้วยนะ) ได้เลยครับ!

อย่าลืมเข้าไปเปลี่ยน WIFI_SSID และ WIFI_PASSWORD ในโค้ดก่อนทำการอัปโหลดลงบอร์ดนะครับ

C++ (Pico W + Web App Full Code)
#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...
RAW
--
current index
SMOOTHED
--
avg index
UPTIME
--
hh:mm:ss
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);
}
}

สรุปผลลัพธ์ (Result)

ในที่สุด PAL 8000 ของเราก็พร้อมใช้งานครับ! ทันทีที่เปิดเครื่อง ดวงตาสีแดงจะค่อยๆ สว่างขึ้นมา พร้อมกับเสียงทักทายที่เย็นยะเยือก ระหว่างการทำงานมันจะคอยเตือนเราทุกๆ 30 วินาทีว่ามันยังอยู่ และจะรายงานคุณภาพอากาศให้เราทราบทุกนาที ดวงตา LED ก็จะเต้นเป็นจังหวะการหายใจ (Breathing effect) ไปพร้อมๆ กัน

เพื่อความชัวร์ว่าเซนเซอร์ทำงานได้จริง ผมลองจุดธูปแล้วเอาไปจ่อใกล้ๆ เซนเซอร์ดูครับ ปรากฏว่าค่า VOC พุ่งพรวด! และ PAL 8000 ก็เปลี่ยนน้ำเสียงการรายงานเป็นโหมดแจ้งเตือนฉุกเฉินทันที ระบบนี้ทำงานได้เป๊ะตามที่ตั้งใจไว้เลยล่ะครับ

ไอเดียพัฒนาต่อยอด: สำหรับเวอร์ชัน 2 ผมตั้งใจว่าจะขยายคลังเสียงให้เยอะขึ้น และอาจจะทำ Dialogue Tree (ระบบโต้ตอบแบบมีเงื่อนไขซับซ้อนขึ้นเหมือนในเกม) และจะเพิ่มเซนเซอร์ตัวอื่นๆ เข้ามาเพื่อให้มันตอบสนองได้หลากหลายขึ้นครับ (หลายคนอาจจะบอกว่าทำไมไม่ยัด AI Chatbot ลงไปเลยล่ะ? ก็เพราะผมอยากพิสูจน์ไงครับ ว่าเราสามารถสร้าง "คาแรคเตอร์" ให้กับหุ่นยนต์ได้ด้วยลอจิกโค้ดแบบคลาสสิกล้วนๆ!)

Final PAL 8000

อ้างอิงข้อมูลจาก: Globalbyteshop Blog

ต้นฉบับโปรเจกต์โดย: Arnov_Sharma_makes | Original Link

ไฟล์และเอกสารเพิ่มเติม:

*คำเตือน: เนื้อหานี้เป็นการสรุปและเรียบเรียงจากบทความโปรเจกต์ต้นฉบับภาษาอังกฤษ ข้อมูลฉบับภาษาไทยและขั้นตอนการทำงานบางส่วนอาจถูกปรับเพื่อความเข้าใจที่ง่ายขึ้น สามารถตรวจสอบรายละเอียดเชิงเทคนิค กระบวนการออกแบบ PCB และโค้ดเชิงลึกได้ที่ ต้นฉบับภาษาอังกฤษ

 

แท็ก


Blog posts

เข้าสู่ระบบ

ลืมรหัสผ่านใช่ไหม?

ยังไม่มีบัญชีใช่ไหม?
สร้างบัญชี