โครงสร้างพื้นฐาน สำหรับการพัฒนาโปรแกรม IoT
ในการสร้างระบบ Smart Farm หรือ IoT ใดๆ ให้ฉลาดและทำงานได้จริง เราจำเป็นต้องเข้าใจหัวใจ 4 อย่างของการเขียนโปรแกรม บทเรียนนี้จะเปลี่ยนคำศัพท์ยากๆ ให้กลายเป็นเรื่องใกล้ตัว
📦 1. การจัดการข้อมูล (Variables & Data Types)
เวลาบอร์ด ESP32 ทำงาน มันต้องมีที่สำหรับจดจำสิ่งต่างๆ เช่น อุณหภูมิตอนนี้เท่าไหร่ ปั๊มน้ำเปิดอยู่ไหม เราเรียกพื้นที่จดจำนี้ว่า ตัวแปร (Variables) ซึ่งเปรียบเสมือน “กล่องใส่ของ” แต่ของแต่ละอย่างก็ต้องใส่กล่องให้ถูกประเภท เราจึงต้องรู้จัก ชนิดข้อมูล (Data Types)

int(Integer) กล่องใส่ “เลขจำนวนเต็ม” ไม่มีทศนิยม- ตัวอย่าง
int relayPin = 2;(จำไว้ว่ารีเลย์ต่ออยู่ขา 2)
- ตัวอย่าง
float(Floating Point) กล่องใส่ “ตัวเลขทศนิยม”- ตัวอย่าง
float temp = 35.5;(จำไว้ว่าอุณหภูมิคือ 35.5 องศา)
- ตัวอย่าง
String(Text) กล่องใส่ “ข้อความ”- ตัวอย่าง
String myName = "Krunu Farm";(จำชื่อฟาร์มไว้)
- ตัวอย่าง
bool(Boolean) กล่องใส่ “สถานะ 2 ทาง” (จริง/เท็จ , เปิด/ปิด)- ตัวอย่าง
bool isPumpOn = false;(จำไว้ว่าตอนนี้ปั๊มปิดอยู่)
- ตัวอย่าง
long(Long Integer) กล่องใส่ “เลขจำนวนเต็มขนาดใหญ่” (ไม่มีทศนิยม)- เหมาะสำหรับ เก็บค่าตัวเลขมหาศาลที่เกินความจุของ int ปกติ เช่น หลักแสน หลักล้านขึ้นไป
- ตัวอย่าง long totalWaterDrops = 1500000; (จำไว้ว่าปั๊มหยดน้ำไปแล้ว 1 ล้าน 5 แสนหยด)
unsigned long(Unsigned Long) กล่องใส่ “เลขจำนวนเต็มบวกขนาดใหญ่มาก” (ไม่มีทศนิยมและห้ามติดลบ)- เหมาะสำหรับ การจับเวลาในระบบ ใช้คู่กับคำสั่ง millis() เพราะเวลาบนโลกมีแต่เดินหน้าไม่มีวันติดลบ
- ตัวอย่าง unsigned long previousTime = 0; (จำไว้ว่าจดเวลาครั้งล่าสุดไว้เมื่อไหร่)
enum(Enumeration) กล่องจัดหมวดหมู่ “ป้ายชื่อแทนเลขจำนวนเต็ม” (เพื่อให้อ่านง่าย)- เหมาะสำหรับ จัดกลุ่มตัวเลขที่มีความหมายเกี่ยวข้องกัน หรือใช้กำหนดชื่อสถานะของระบบ (State Machine) เพื่อให้โค้ดอ่านง่ายเหมือนภาษาคน ไม่ต้องคอยเดาว่าเลขนี้แปลว่าอะไร
- ตัวอย่าง enum PumpState { OFF = 0, ON = 1, ERROR = 2 }; PumpState current = ON; // (จำไว้ว่าสถานะตอนนี้คือ ON ซึ่งเบื้องหลังก็คือเลข 1 นั่นเอง)
🚦 2. ตรรกะการตัดสินใจ (Control Structures)
เมื่อบอร์ดจำข้อมูลได้แล้ว ต่อมาเราต้องสอนให้มันคิดและตัดสินใจได้
คำสั่ง if - else
นี่คือโครงสร้างที่ใช้บ่อยที่สุด ใช้สำหรับตั้งเงื่อนไขว่า ถ้าเกิดเหตุการณ์นี้ จะให้ทำสิ่งนี้ แต่ถ้าไม่ใช่ ให้ไปทำอีกสิ่งหนึ่งแทน เช่น
float soilMoisture = 40.5; // ดินมีความชื้น 40.5%
// สอนบอร์ดให้ตัดสินใจ:
if (soilMoisture < 50.0) {
// เงื่อนไขเป็นจริง -> ให้รดน้ำ
digitalWrite(relayPin, HIGH);
Serial.println("ดินแห้งแล้ว! เปิดปั๊มน้ำด่วน");
} else {
// เงื่อนไขเป็นเท็จ -> ให้ปิดน้ำ
digitalWrite(relayPin, LOW);
Serial.println("ดินชื้นดีแล้ว ปิดปั๊มน้ำได้");
}⚙️ 3. การทำงานเชิงสถานะ (State Machine)
เมื่อโปรเจกต์เราใหญ่ขึ้น การเขียนโค้ดเรียงลงมาตรงๆ อาจจะทำให้ระบบรวนได้ State Machine คือเทคนิคการเขียนโปรแกรมโดยแบ่งการทำงานออกเป็นสถานะ (State) ที่ชัดเจน บอร์ดจะรู้ตัวเสมอว่าตอนนี้ตัวเองกำลังอยู่ในสถานะไหน และจะต้องทำอะไรต่อ
ตัวอย่าง เครื่องซักผ้า
- สถานะรอ (Idle) รอคนมากดปุ่ม
- สถานะเติมน้ำ (Filling) เปิดวาล์วน้ำ รอจนน้ำเต็ม
- สถานะซัก (Washing) มอเตอร์หมุน 20 นาที
ในการเขียนโค้ด เรามักจะใช้คำสั่ง switch - case มาช่วยจัดการสถานะ
int systemState = 0; // 0=รอคำสั่ง, 1=กำลังรดน้ำ, 2=แจ้งเตือนน้ำหมด
switch (systemState) { //นำค่าในตัวแปรมาเปรียบเทียบกับกรณี (case) ต่างๆ
case 0: // ถ้าอยู่ในสถานะ 0
Serial.println("รอรับคำสั่งจากแอปพลิเคชัน...");
break; // จบการทำงานของสถานะนี้
case 1: // ถ้าอยู่ในสถานะ 1
Serial.println("กำลังรดน้ำต้นไม้...");
// ใส่โค้ดเปิดปั๊มตรงนี้
break;
case 2: // ถ้าอยู่ในสถานะ 2
Serial.println("แจ้งเตือน: น้ำในแท็งก์หมด!");
break;
}ข้อดี โค้ดเป็นระเบียบมาก แก้ไขง่าย และป้องกันการสั่งงานผิดพลาด
⏱️ 4. การจัดการเวลา (Non-blocking Delay ด้วย millis)
ตอนเราเริ่มเรียน เรามักจะใช้คำสั่ง delay(2000); เพื่อให้บอร์ดรอ 2 วินาที
❌ ทำไม delay() ถึงไม่ควรใช้ในบางโอกาส คำสั่ง delay เปรียบเสมือนการให้บอร์ดกินยานอนหลับในช่วงเวลา 2 วินาทีนั้น บอร์ดจะหลับลึก ไม่รับรู้โลกภายนอก ถ้ามีคนกดปุ่มปิดปั๊มน้ำ หรือมีค่าความชื้นอันตรายส่งเข้ามา บอร์ดจะไม่สนเลย ซึ่งอันตรายมากในงานระบบควบคุม
✅ millis() นาฬิกาจับเวลา คือคำสั่งที่บอกว่า ตั้งแต่เปิดบอร์ดมา ผ่านไปกี่มิลลิวินาทีแล้ว การใช้ millis() เปรียบเหมือนเรากำลังต้มมาม่า แต่ระหว่างที่รอ 3 นาที เราไม่ได้ยืนจ้องหม้อเฉยๆ (delay) เราเหลือบดูนาฬิกาเป็นพักๆ และระหว่างนั้นเราก็สามารถกวาดบ้าน ล้างจาน หรือตอบแชทไปด้วยได้ (Non-blocking)
สูตรสำเร็จการใช้ millis()
เราจะใช้หลักการคณิตศาสตร์ง่ายๆ คือ เวลาปัจจุบัน - เวลาที่จดไว้ล่าสุด >= ระยะเวลาที่ต้องการรอ
unsigned long previousMillis = 0; // กล่องเก็บเวลาที่จดไว้ล่าสุด (ใช้ unsigned long เพราะเลขมันจะเยอะมาก)
const long interval = 2000; // ต้องการให้ทำงานทุกๆ 2 วินาที (2000 มิลลิวินาที)
void loop() {
unsigned long currentMillis = millis(); // เหลือบดูนาฬิกาว่าตั้งแต่เปิดเครื่องมา ตอนนี้เวลาผ่านไปกี่มิลลิวินาทีแล้ว
// เช็คว่าเวลาผ่านไปถึง 2 วินาทีหรือยัง
if (currentMillis - previousMillis >= interval) {
previousMillis = currentMillis; // จดเวลาปัจจุบันเก็บไว้ใช้รอบหน้า
// ทำงานที่ต้องการ เช่น ส่งข้อมูลขึ้นหน้าเว็บ
Serial.println("ส่งข้อมูลสำเร็จ!");
}
// ระหว่างที่รอให้ครบ 2 วินาที บอร์ดสามารถมาทำคำสั่งตรงนี้ได้ตลอดเวลา!!
// เช่น การเช็คว่ามีคนกดปุ่มหรือไม่
checkButtonPress();
}การเลือกใช้งาน Digital Input (Pull-up) และ Digital Output
เมื่อไหร่ควรใช้ OUTPUT
กฎการจำ อุปกรณ์ไหนที่ต้องรอคำสั่งจากบอร์ด เพื่อให้มีการ ขยับ สว่าง ส่งเสียง ให้ใช้โหมดนี้
บอร์ดจะทำหน้าที่จ่ายไฟไปสั่งงาน
- ตัวอย่างอุปกรณ์ใน Smart Farm
- รีเลย์ (Relay Module) สำหรับตัดต่อไฟ 220V เพื่อคุมปั๊มน้ำ พัดลมฟาร์ม
- หลอดไฟ LED สั่งให้ติดสว่างเพื่อแสดงสถานะ เช่น ไฟแดงเตือนน้ำแห้ง
- ลำโพงบัซเซอร์ (Active Buzzer) สั่งให้ส่งเสียงร้องเตือนเวลามีขโมยเข้าฟาร์ม
- โซลินอยด์วาล์ว (Solenoid Valve) สั่งเปิด-ปิดวาล์วน้ำอัตโนมัติ
เมื่อไหร่ควรใช้ INPUT แบบปกติ
กฎการจำ ใช้กับเซนเซอร์ที่เป็น แผงวงจรสำเร็จรูป (มักจะมี 3-4 ขา) ซึ่งมันฉลาดพอที่จะส่งไฟ HIGH (1) หรือ LOW (0) มาให้เราอยู่แล้ว
เนื่องจากเซนเซอร์พวกนี้มีชิปประมวลผลในตัว มันจึงไม่มีปัญหา “สถานะลอยตัว (Floating)” เราเลยไม่ต้องเปิด Pull-up ช่วย
- ตัวอย่างอุปกรณ์ใน Smart Farm
- เซนเซอร์ตรวจจับความเคลื่อนไหว (PIR Sensor) มีคนเดินผ่านส่ง 1 ไม่มีคนส่ง 0
- เซนเซอร์จับเส้นขาวดำ / กีดขวาง (IR Sensor) เจอกำแพงส่ง 0 ไม่เจอส่ง 1
- โมดูลวัดความชื้นดินแบบดิจิทัล (Soil Moisture DO Pin) ถ้าดินแห้งเกินเกณฑ์ที่กำหนด ให้ส่ง 1 มาเตือนทันที
เมื่อไหร่ควรใช้ INPUT_PULLUP (รับข้อมูลจากหน้าสัมผัส)
กฎการจำ ใช้กับอุปกรณ์ที่เป็นกลไกหน้าสัมผัสที่ไม่มีไฟเลี้ยงในตัว
อุปกรณ์พวกนี้ไม่มีสมอง ไม่มีแผงวงจร เป็นแค่เหล็ก 2 ชิ้นแตะกัน เราจึงต้องเปิดระบบ Pull-up ภายในบอร์ด ESP32 เพื่อช่วยประคองสถานะไฟไม่ให้รวน
- ตัวอย่างอุปกรณ์ใน Smart Farm
- สวิตช์ปุ่มกด (Push Button) ปุ่มกดเปิดประตู
- สวิตช์แม่เหล็ก (Magnetic Switch) เอาไว้ติดประตูฟาร์ม ถ้าประตูเปิด แม่เหล็กห่างกัน บอร์ดจะรู้ทันที
- สวิตช์ลูกลอย (Float Switch) เอาไว้หย่อนในถังน้ำ ถ้าน้ำเต็ม ลูกลอยจะลอยขึ้นมาชนสวิตช์
- ไมโครสวิตช์ (Limit Switch) ตัวเช็คว่าประตูรั้วฟาร์มเลื่อนเปิดไปจนสุดหรือยัง
สรุปตารางคู่มือช่างประจำฟาร์ม
| ลักษณะอุปกรณ์ | โหมดที่ต้องใช้ | สถานะเมื่อทำงาน (Active) |
| สั่งอุปกรณ์ให้ทำงาน (หลอดไฟ ปั๊ม เสียง) | OUTPUT | HIGH (สั่งเปิด) / LOW (สั่งปิด) |
| เซนเซอร์แผงวงจรสำเร็จรูป | INPUT | รับ HIGH หรือ LOW (ตามสเปกเซนเซอร์) |
| สวิตช์กลไก / หน้าสัมผัสเปล่าๆ | INPUT_PULLUP | LOW (เวลากดสวิตช์หรือหน้าสัมผัสชนกัน) |
💡 ข้อควรระวัง ถ้านักศึกษาเอาสวิตช์ลูกลอยเตือนน้ำเต็ม 2 เส้น มาต่อเข้า ESP32 แล้วลืมพิมพ์คำว่า
_PULLUP(พิมพ์แค่INPUTเฉยๆ) ปั๊มน้ำอาจจะทำงานสลับเปิด-ปิดรัวๆ จนมอเตอร์ไหม้ได้เลย เพราะระบบจะอ่านค่าผีหลอก (Floating) นั่นเอง
Lab 5 ระบบไฟจราจรอัจฉริยะพร้อมปุ่มกดข้ามถนน (Smart Traffic Light Control with Manual Override)
ในแล็บนี้ เราจะข้ามพื้นฐานการกะพริบไฟแบบธรรมดา แต่จะเน้นการเขียนโปรแกรมเชิงตรรกะแบบ State Machine และการใช้ Non-blocking Delay (millis()) เพื่อให้ ESP32 สามารถตรวจสอบการกดปุ่มได้ตลอดเวลาโดยไม่ค้างที่คำสั่ง delay()
วัตถุประสงค์
- เพื่อให้มีความเข้าใจในการจัดการสถานะ (State) ของโปรแกรม
- เพื่อฝึกการใช้งาน Digital Input (Pull-up) และ Digital Output
- เพื่อเรียนรู้การเขียนโค้ดแบบ Non-blocking เพื่อให้ปุ่มตอบสนองได้ทันที
อุปกรณ์ที่ใช้
- บอร์ด ESP32 x 1
- LED (แดง เหลือง เขียว) อย่างละ 1 หลอด
- ตัวต้านทาน 220-330 Ohm x 3 (สำหรับ LED)
- Tact Switch x 1
- Breadboard และสายจัมเปอร์
การต่อวงจร (Wiring)
| อุปกรณ์ | ขา ESP32 | หมายเหตุ |
| LED แดง | GPIO 12 | ต่อผ่านตัวต้านทานลง Ground |
| LED เหลือง | GPIO 14 | ต่อผ่านตัวต้านทานลง Ground |
| LED เขียว | GPIO 27 | ต่อผ่านตัวต้านทานลง Ground |
| Tact Switch | GPIO 26 | ต่อขาหนึ่งลง GND อีกขาเข้า GPIO 26 (ใช้ Internal Pull-up) |
โค้ดตัวอย่าง
const int LED_R = 12;
const int LED_Y = 14;
const int LED_G = 27;
const int BTN_PIN = 26;
// กำหนดสถานะของไฟจราจร
enum TrafficState { GREEN, YELLOW, RED, MANUAL_STOP };
TrafficState currentState = GREEN;
unsigned long previousMillis = 0;
const long intervalGreen = 5000; // เขียว 5 วินาที
const long intervalYellow = 2000; // เหลือง 2 วินาที
const long intervalRed = 5000; // แดง 5 วินาที
void setup() {
Serial.begin(115200);
pinMode(LED_R, OUTPUT);
pinMode(LED_Y, OUTPUT);
pinMode(LED_G, OUTPUT);
pinMode(BTN_PIN, INPUT_PULLUP); // ใช้ Pull-up ภายใน (กดแล้วเป็น LOW)
Serial.println("System Start Traffic Light Lab 1");
}
void loop() {
unsigned long currentMillis = millis();
// ตรวจสอบการกดปุ่ม (Manual Override)
// หากมีการกดปุ่ม ให้บังคับเปลี่ยนเป็นไฟแดงทันที
if (digitalRead(BTN_PIN) == LOW) {
delay(50); // Debounce เล็กน้อย
if (digitalRead(BTN_PIN) == LOW) {
Serial.println("Manual Override Change to RED!");
currentState = RED;
previousMillis = currentMillis; // Reset เวลา
}
}
// State Machine สำหรับควบคุมไฟจราจร
switch (currentState) {
case GREEN:
updateLEDs(LOW, LOW, HIGH); // เขียวติด
if (currentMillis - previousMillis >= intervalGreen) {
currentState = YELLOW;
previousMillis = currentMillis;
}
break;
case YELLOW:
updateLEDs(LOW, HIGH, LOW); // เหลืองติด
if (currentMillis - previousMillis >= intervalYellow) {
currentState = RED;
previousMillis = currentMillis;
}
break;
case RED:
updateLEDs(HIGH, LOW, LOW); // แดงติด
if (currentMillis - previousMillis >= intervalRed) {
currentState = GREEN;
previousMillis = currentMillis;
}
break;
}
}
// ฟังก์ชันช่วยจัดการสถานะ LED
void updateLEDs(int r, int y, int g) {
digitalWrite(LED_R, r);
digitalWrite(LED_Y, y);
digitalWrite(LED_G, g);
}อธิบายโค้ดโปรแกรม
การประกาศตัวแปรและโครงสร้างข้อมูล
- const int LED_X = … การใช้ const เพื่อบอกคอมไพเลอร์ว่าค่านี้จะไม่เปลี่ยนแปลง ช่วยประหยัด Memory และป้องกันการเผลอแก้ค่าขา GPIO ในโค้ดส่วนอื่น
- enum TrafficState { … } นี่คือหัวใจของ State Machine ครับ แทนที่เราจะจำว่า 0 = เขียว 1 = เหลือง เราสร้างชื่อเรียกขึ้นมาเลยเพื่อให้โค้ดอ่านง่าย
- unsigned long previousMillis ต้องใช้ประเภท unsigned long เท่านั้น เพราะค่า millis() จะเพิ่มขึ้นเรื่อยๆ จนมีค่ามหาศาล (ประมาณ 50 วันถึงจะวนกลับมาศูนย์) หากใช้ int จะเกิดปัญหา Variable Overflow
การจัดการเวลาด้วย millis()
- currentMillis – previousMillis >= interval เป็นการเช็คว่าเวลาปัจจุบันลบเวลาที่บันทึกไว้ล่าสุด ถึงระยะเวลาที่กำหนดหรือยัง ถ้าถึงแล้วค่อยสั่งให้ทำงาน วิธีนี้ทำให้ CPU ทำงานวน loop() ได้หลายล้านรอบต่อวินาทีเพื่อเช็คปุ่มกดไปด้วย
ส่วนของ setup()
- pinMode(BTN_PIN, INPUT_PULLUP) การใช้ INPUT_PULLUP หมายถึง เราใช้ตัวต้านทานภายใน ESP32 ดึงสถานะขา 26 ไว้ที่ HIGH (3.3V) เสมอ
ส่วนของ loop() และ switch case
เราใช้ switch เพื่อแยกการทำงานของแต่ละสีไฟออกจากกันอย่างเด็ดขาด
- Manual Override โค้ดส่วนที่เช็ค digitalRead(BTN_PIN) == LOW ถูกวางไว้นอก switch เพื่อให้มันตรวจจับได้ตลอดเวลา (Real-time) ไม่ว่าไฟตอนนั้นจะเป็นสีอะไรก็ตาม หากกดปุ่มปุ๊บ currentState จะถูกบังคับให้เป็น RED ทันที
ฟังก์ชันเสริม updateLEDs()
แทนที่จะเขียน digitalWrite 3 บรรทัดทุกครั้งที่เปลี่ยนสีไฟ เราสร้างฟังก์ชันขึ้นมาเพื่อรับค่า HIGH/LOW 3 ค่ารวดเดียว
- ช่วยให้โค้ดสะอาดขึ้น ลดความซ้ำซ้อน และลดโอกาสผิดพลาดเวลาเราแก้ขาไฟ
Lab 5.1 การหน่วงเวลาเพื่อความปลอดภัยในระบบจราจร (Safety Delay Design)
💡 แนวคิดการปรับปรุงตรรกะ (Logic Change)
- ตรวจสอบสถานะปัจจุบัน ก่อนจะเปลี่ยนไฟ เราต้องเช็คก่อนว่าตอนนี้เป็นไฟเขียวหรือไม่
- เปลี่ยนเป้าหมาย หากมีการกดปุ่มขณะเป็นไฟเขียว ให้เปลี่ยน currentState เป็น YELLOW แทน RED
- รีเซ็ตเวลา บันทึกเวลาเริ่มต้นใหม่ (previousMillis = currentMillis) เพื่อให้ไฟเหลืองติดค้างตามระยะเวลาที่เราตั้งไว้ ก่อนที่จะเข้าสู่ไฟแดงตามวงจรปกติ
⌨️ โค้ดส่วนที่ต้องแก้ไข (Snippet)
ให้ค้นหาส่วนการตรวจสอบปุ่ม (Manual Override) ใน loop() แล้วปรับปรุงเป็นดังนี้
// ตรวจสอบการกดปุ่ม (Manual Override)
if (digitalRead(BTN_PIN) == LOW) {
delay(50); // Debounce ป้องกันสัญญาณรบกวน
if (digitalRead(BTN_PIN) == LOW) {
// เงื่อนไขใหม่ ถ้าปัจจุบันคือไฟเขียว ให้เปลี่ยนเป็นไฟเหลืองก่อน
if (currentState == GREEN) {
Serial.println("Button Pressed Changing GREEN to YELLOW...");
currentState = YELLOW;
previousMillis = currentMillis; // เริ่มนับเวลาสำหรับไฟเหลืองใหม่
}
// ถ้าเป็นไฟเหลืองหรือไฟแดงอยู่แล้ว ไม่ต้องทำอะไร ให้ระบบรันตามเวลาปกติ
}
}📊 แผนภาพการทำงานใหม่ (State Machine Diagram)
จากการปรับปรุง โครงสร้างการทำงานจะเปลี่ยนไปดังนี้

- Normal Flow : Green (5s) -> Yellow (2s) -> Red (5s) -> Loop
- Manual Flow : Green -> [Button Pressed] -> Yellow (2s) -> Red (5s) -> Loop
📚 คำอธิบายสำหรับนักศึกษา
- เหตุผลทางด้านความปลอดภัย ในชีวิตจริง หากไฟเขียวแล้วเปลี่ยนเป็นแดงทันที รถที่วิ่งมาด้วยความเร็วจะเบรกไม่ทันและเกิดอุบัติเหตุ การเขียนโค้ดให้ผ่าน YELLOW ก่อนจึงเป็นการเลียนแบบระบบ Safety ในงานวิศวกรรมจริง
- Sequential Logic ให้นักศึกษามองว่าโปรแกรมไม่จำเป็นต้องข้ามขั้นตอนเสมอไป แต่สามารถเร่งขั้นตอนให้เข้าสู่ลำดับถัดไปได้
- State Interaction โค้ดนี้แสดงให้เห็นว่า Input (ปุ่มกด) สามารถมีปฏิกิริยากับสถานะ (State) ที่แตกต่างกันได้ เช่น ถ้ากดตอนไฟแดงจะไม่มีผลอะไร แต่ถ้ากดตอนไฟเขียวจะมีผลทันที เป็นการสร้างเงื่อนไขที่ซับซ้อนขึ้นอีกระดับ
Lab 5.2 เลขนับถอยหลัง (Countdown Timer) บนจอ OLED

📘 แนวคิดการคำนวณเวลานับถอยหลัง (Countdown Logic)
ในการใช้ millis() เราไม่ได้นับถอยหลังโดยตรง แต่เราใช้วิธี เอาเวลาที่กำหนด ลบด้วย เวลาที่ผ่านไปแล้ว เพื่อหาเวลาที่เหลืออยู่
สูตรการคำนวณ

📍 ตารางการต่อสาย
| อุปกรณ์ | ขาของอุปกรณ์ | ขาบน ESP32 | หมายเหตุ |
| OLED | GND | GND | สายกราวด์ร่วม |
| VCC | 3.3V | ห้ามต่อ 5V เพราะจออาจไหม้ | |
| SCL | GPIO 22 | ขาสัญญาณนาฬิกา I2C | |
| SDA | GPIO 21 | ขาสัญญาณข้อมูล I2C | |
| LED แดง | ขาบวก (Long Leg) | GPIO 12 | ต่อผ่านตัวต้านทาน |
| LED เหลือง | ขาบวก (Long Leg) | GPIO 14 | ต่อผ่านตัวต้านทาน |
| LED เขียว | ขาบวก (Long Leg) | GPIO 27 | ต่อผ่านตัวต้านทาน |
| Switch | ขาที่ 1 | GPIO 26 | สำหรับรับคำสั่ง Manual |
| ขาที่ 2 | GND | เมื่อกดปุ่มจะดึงสัญญาณลง Ground |
⌨️ การปรับปรุงโค้ด Lab 5 + OLED Countdown
เราจะใช้ Library Adafruit_SSD1306 ในการควบคุมจอ และปรับตรรกะใน switch-case เพื่อคำนวณตัวเลขก่อนส่งไปแสดงผล
โค้ดตัวอย่าง
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);
const int LED_R = 12, LED_Y = 14, LED_G = 27, BTN_PIN = 26;
enum TrafficState { GREEN, YELLOW, RED };
TrafficState currentState = GREEN;
unsigned long previousMillis = 0;
const long intervalGreen = 10000; // 10 วินาที
const long intervalYellow = 3000; // 3 วินาที
const long intervalRed = 10000; // 10 วินาที
void setup() {
Serial.begin(115200);
pinMode(LED_R, OUTPUT); pinMode(LED_Y, OUTPUT); pinMode(LED_G, OUTPUT);
pinMode(BTN_PIN, INPUT_PULLUP);
if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println("OLED failed");
for(;;);
}
display.clearDisplay();
}
void loop() {
unsigned long currentMillis = millis();
long remainingTime = 0; // ตัวแปรเก็บวินาทีที่เหลือ
// --- ส่วนปุ่มกด (Manual Override) ---
if (digitalRead(BTN_PIN) == LOW) {
if (currentState == GREEN) {
currentState = YELLOW;
previousMillis = currentMillis;
}
}
// --- ส่วน State Machine และคำนวณเวลา ---
switch (currentState) {
case GREEN:
updateLEDs(LOW, LOW, HIGH);
remainingTime = (intervalGreen - (currentMillis - previousMillis)) / 1000;
if (currentMillis - previousMillis >= intervalGreen) {
currentState = YELLOW;
previousMillis = currentMillis;
}
showDisplay("GO!", remainingTime, SSD1306_WHITE);
break;
case YELLOW:
updateLEDs(LOW, HIGH, LOW);
remainingTime = (intervalYellow - (currentMillis - previousMillis)) / 1000;
if (currentMillis - previousMillis >= intervalYellow) {
currentState = RED;
previousMillis = currentMillis;
}
showDisplay("WAIT", remainingTime, SSD1306_WHITE);
break;
case RED:
updateLEDs(HIGH, LOW, LOW);
remainingTime = (intervalRed - (currentMillis - previousMillis)) / 1000;
if (currentMillis - previousMillis >= intervalRed) {
currentState = GREEN;
previousMillis = currentMillis;
}
showDisplay("STOP", remainingTime, SSD1306_WHITE);
break;
}
}
// ฟังก์ชันแสดงผลหน้าจอ
void showDisplay(String status, int seconds, int color) {
display.clearDisplay();
// แสดงสถานะตัวอักษร
display.setTextSize(2);
display.setTextColor(SSD1306_WHITE);
display.setCursor(35, 5);
display.println(status);
// แสดงตัวเลขถอยหลังขนาดใหญ่
display.setTextSize(4);
display.setCursor(45, 30);
if(seconds < 0) seconds = 0; // ป้องกันเลขติดลบตอนเปลี่ยนสถานะ
display.println(seconds);
display.display();
}
void updateLEDs(int r, int y, int g) {
digitalWrite(LED_R, r); digitalWrite(LED_Y, y); digitalWrite(LED_G, g);
}🔍 คำอธิบายส่วนเสริม
การจัดการเลขติดลบ
สังเกตในฟังก์ชัน showDisplay จะมีการเช็ค if(seconds < 0) seconds = 0; เพราะในจังหวะที่เวลาคาบเกี่ยวกับการเปลี่ยนสถานะ ค่าคำนวณอาจติดลบได้เล็กน้อย เช่น -0.001 การป้องกันไว้จะทำให้ตัวเลขบนหน้าจอไม่กระโดดเป็นค่าแปลกๆ
การจัดวาง Layout บน OLED
- setTextSize(2) ใช้สำหรับข้อความสถานะ GO WAIT STOP เพื่อให้อ่านง่าย
- setTextSize(4) ใช้สำหรับตัวเลขวินาที เพื่อให้เด่นชัดเหมือนนาฬิกาไฟจราจรจริง
- setCursor(x, y) ให้นักศึกษาฝึกการกะระยะ (Coordinate) โดยที่ x คือแนวนอน (0-127) และ y คือแนวตั้ง (0-63)
ประสิทธิภาพของโค้ด
เนื่องจากเราใช้ millis() โค้ดจะวน loop() เร็วมาก หน้าจอ OLED จะถูกอัปเดตตลอดเวลา ทำให้ตัวเลขวินาทีเปลี่ยนไปอย่างลื่นไหล (Real-time)
🎯 กิจกรรมเช็คความเข้าใจ
- ลองเปลี่ยนเวลาแสดงไฟแต่ละสี
- ลองเปลี่ยนข้อความบนหน้าจอ OLED
Lab 5.3 เสียงแจ้งเตือนคนตาบอด โดยใช้เทคนิคการหารเอาเศษ (Modulo)

🔌 การต่อวงจรเพิ่ม (Hardware)
ให้นักศึกษาเพิ่มลำโพงแบบมีเสียงในตัว (Active Buzzer) เข้าไปในวงจร
- ขาบวก (VCC/Signal) ต่อเข้ากับ GPIO 25
- ขาลบ (GND) ต่อเข้ากับ GND ร่วมของบอร์ด
💻 โค้ดที่ต้องเพิ่มและปรับแก้
จุดที่ 1 เพิ่มตัวแปรขา Buzzer ไว้ด้านบนสุดของโค้ด
const int LED_R = 12, LED_Y = 14, LED_G = 27, BTN_PIN = 26;
const int BUZZER_PIN = 25; // เพิ่มขา Buzzerจุดที่ 2 ตั้งค่า pinMode ใน void setup()
void setup() {
// ... (โค้ดก่อนหน้า) ...
pinMode(BUZZER_PIN, OUTPUT);
digitalWrite(BUZZER_PIN, LOW); // สั่งปิดเสียงไว้ก่อนตอนเปิดเครื่อง
}จุดที่ 3 อัปเกรดสถานะไฟแดง case RED ใน void loop()
ให้นักศึกษาลบโค้ดใน case RED เดิมออก แล้วเอาชุดนี้ไปวางแทน
case RED:
updateLEDs(HIGH, LOW, LOW);
remainingTime = (intervalRed - (currentMillis - previousMillis)) / 1000;
// ---------------------------------------------------
// 🌟 โค้ดส่วนที่เพิ่มเข้ามา: ควบคุมจังหวะเสียงเตือนคนข้ามถนน
// ---------------------------------------------------
if (remainingTime > 3) {
// ช่วงเวลาปกติ (เหลือมากกว่า 3 วินาที): ดังจังหวะช้าๆ ทุกๆ ครึ่งวินาที
if ((currentMillis / 500) % 2 == 0) {
digitalWrite(BUZZER_PIN, HIGH);
} else {
digitalWrite(BUZZER_PIN, LOW);
}
} else {
// ช่วงเวลาเร่งด่วน เหลือ 3 วินาทีสุดท้าย ดังจังหวะเร็วๆ ทุกๆ 0.15 วินาที
if ((currentMillis / 150) % 2 == 0) {
digitalWrite(BUZZER_PIN, HIGH);
} else {
digitalWrite(BUZZER_PIN, LOW);
}
}
// ---------------------------------------------------
// เช็คว่าหมดเวลาไฟแดงหรือยัง?
if (currentMillis - previousMillis >= intervalRed) {
currentState = GREEN;
previousMillis = currentMillis;
digitalWrite(BUZZER_PIN, LOW); // ปิดเสียงทันทีที่ไฟเปลี่ยนเป็นสีเขียว
}
showDisplay("STOP", remainingTime, SSD1306_WHITE);
break;🧠 อธิบายจังหวะเสียงกะพริบ
if ((currentMillis / 500) % 2 == 0)
currentMillisคือ เวลาที่วิ่งไปเรื่อยๆไม่มีวันหยุด เช่น 1000 1001 1002…- พอเราเอามาหาร
/ 500ตัวเลขมันจะเปลี่ยนสเตปทุกๆครึ่งวินาที - พอเราเอามา หารเอาเศษ
% 2(Modulo 2) ผลลัพธ์ที่ได้จะออกมาแค่ 0 กับ 1 สลับกันไปเรื่อยๆ อย่างสมบูรณ์แบบ
เราเลยเอาเงื่อนไขนี้มาสั่ง HIGH (เปิดเสียง) และ LOW (ปิดเสียง) ได้เลย โดยไม่ต้องพึ่งคำสั่ง delay() แม้แต่นิดเดียว
รู้จักกับ Modulo (%) เวทมนตร์แห่งการหารเอาเศษ
Modulo (%) ในการเขียนโปรแกรม ไม่ใช่เครื่องหมายเปอร์เซ็นต์ แต่มันคือคำสั่งที่สั่งให้คอมพิวเตอร์ หารเพื่อเอาเศษ (สนใจแค่ว่าเหลือเศษเท่าไหร่)
🍕 จำง่ายๆ ด้วยทฤษฎีพิซซ่า
10 % 3 = 1พิซซ่า 10 ชิ้น แบ่งให้ 3 คน… เหลือเศษ 1 ชิ้น10 % 2 = 0พิซซ่า 10 ชิ้น แบ่ง 2 คน ลงตัวพอดีเป๊ะ… เหลือเศษ 0 ชิ้น
🪄 2 ตัวอย่างที่โปรแกรมเมอร์ใช้บ่อย
1. สร้างสวิตช์เปิด-ปิดสลับกัน (ใช้ % 2) จำกฎไว้ว่า อะไรก็ตามที่เอามา % 2 ผลลัพธ์จะมีแค่ 0 กับ 1 สลับกัน
- เลขคู่
% 2= 0 - เลขคี่
% 2= 1 - นำไปใช้ทำอะไร เมื่อเอาเวลาที่วิ่งไปเรื่อยๆมา
% 2ผลลัพธ์จะออกมาเป็น0, 1, 0, 1, 0, 1...สลับกันไปเรื่อยๆ นำไปใช้ทำไฟกะพริบหรือลำโพงดังเป็นจังหวะได้
2. สร้างอาณาเขตวนลูป (ใช้ % ตัวเลขที่ต้องการ) เป็นการจำกัดตัวเลขไม่ให้วิ่งทะลุขอบเขตที่ตั้งไว้ พอถึงยอดปุ๊บจะโดนกลับมาเริ่มต้นใหม่ทันที
- สมมติเครื่องนับคิวใช้
% 4➡️ ผลลัพธ์จะออกมาเป็น1, 2, 3, 0, 1, 2, 3, 0... - นำไปใช้ทำอะไร ใช้ทำระบบนับคิว หรือปุ่มกดเลื่อนเมนูบนหน้าจอ OLED พอกดเลื่อนไปจนสุดเมนูสุดท้าย ก็ให้เด้งวนกลับมาเมนูแรกสุดโดยอัตโนมัติ
Project 3 Smart Traffic Light Control
แนวคิดการพัฒนา (The Logic Upgrade)

- เปลี่ยนจาก Constant เป็น Dynamic เราต้องเปลี่ยนการประกาศตัวแปรเวลาจาก const long เป็น long เพื่อให้โปรแกรมสามารถแก้ไขค่าในหน่วยความจำขณะทำงานได้
- คำว่า
constย่อมาจาก Constant แปลว่า ค่าคงที่- คุณสมบัติ เมื่อเราประกาศ
const long interval = 10000;ตัวแปรนี้จะถูกจองพื้นที่ไว้ในหน่วยความจำแบบ Read-only (อ่านได้อย่างเดียว) - การทำงาน คอมไพเลอร์ (Compiler) จะรู้ว่าค่านี้จะไม่มีวันเปลี่ยนตลอดกาลระหว่างที่โปรแกรมทำงาน
- ข้อดี ปลอดภัย ป้องกันการเผลอไปเขียนทับ และประหยัด RAM เพราะบางครั้งคอมไพเลอร์จะเก็บค่านี้ไว้ใน Flash Memory แทน
- คุณสมบัติ เมื่อเราประกาศ
- เมื่อเราตัดคำว่า
constออก เหลือเพียงlong interval = 10000;- คุณสมบัติ มันจะกลายเป็น Variable ตัวแปรอย่างแท้จริง
- การทำงาน ตัวแปรนี้จะถูกเก็บไว้ใน SRAM (RAM) ของ ESP32 ซึ่งมีคุณสมบัติให้เราสามารถ เขียนทับ (Overwrite) ค่าใหม่ลงไปได้ตลอดเวลาขณะที่บอร์ดกำลังทำงานอยู่
- คำว่า
- HTML Input Form สร้างหน้าเว็บที่มีช่องกรอกตัวเลข (Input Type=”number”) และปุ่มกดส่งข้อมูล (Submit)
- Data Parsing (ดาต้า พาร์ซิง) เมื่อมีการกดปุ่มบนเว็บ Browser จะส่งค่าผ่าน URL แล้ว ESP32 จะต้องดึงค่าเหล่านั้นมาอัปเดตตัวแปรในโค้ด
โค้ดตัวอย่าง Lab 5 + OLED + Web Configuration
#include <WiFi.h>
#include <WebServer.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
// --- Configuration ---
const char* ssid = "YOUR_WIFI_NAME";
const char* password = "YOUR_WIFI_PASSWORD";
Adafruit_SSD1306 display(128, 64, &Wire, -1);
WebServer server(80);
// ขาเชื่อมต่อ
const int LED_R = 12, LED_Y = 14, LED_G = 27, BTN_PIN = 26;
// เปลี่ยนเป็น Variable (ไม่ใช่ const) เพื่อให้แก้ไขได้
long intervalGreen = 10000;
long intervalYellow = 3000;
long intervalRed = 10000;
enum TrafficState { GREEN, YELLOW, RED };
TrafficState currentState = GREEN;
unsigned long previousMillis = 0;
// --- Web Page Design ---
void handleRoot() {
String html = "<!DOCTYPE html><html><head><meta charset='UTF-8'><meta name='viewport' content='width=device-width, initial-scale=1'>";
html += "<title>Traffic Control</title>";
html += "<style>";
html += "body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; text-align: center; padding: 20px; background-color: #f4f4f9; color: #333; }";
html += ".card { background: white; padding: 30px; border-radius: 15px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); max-width: 350px; margin: 0 auto; }";
html += "h1 { color: #2c3e50; font-size: 24px; margin-bottom: 20px; }";
html += "input[type='number'] { width: 80px; padding: 8px; margin: 10px 5px; border: 1px solid #ccc; border-radius: 5px; font-size: 16px; text-align: center; }";
html += "input[type='submit'] { background: #4CAF50; color: white; border: none; padding: 12px 25px; border-radius: 8px; font-size: 16px; cursor: pointer; transition: background 0.3s; margin-top: 15px; width: 100%; font-weight: bold; }";
html += "input[type='submit']:hover { background: #45a049; }";
html += ".label { display: inline-block; width: 100px; text-align: right; font-weight: bold; }";
html += ".g-color { color: #28a745; } .y-color { color: #ffc107; } .r-color { color: #dc3545; }";
html += "</style></head><body>";
html += "<div class='card'>";
html += "<h1>🚦 Traffic Panel</h1>";
html += "<form action='/update' method='GET'>";
// จัดหน้าตา Form ให้น่าใช้งานขึ้น
html += "<div class='input-group'><span class='label g-color'>Green (sec): </span><input type='number' name='g' min='1' max='99' value='" + String(intervalGreen/1000) + "'></div>";
html += "<div class='input-group'><span class='label y-color'>Yellow (sec): </span><input type='number' name='y' min='1' max='99' value='" + String(intervalYellow/1000) + "'></div>";
html += "<div class='input-group'><span class='label r-color'>Red (sec): </span><input type='number' name='r' min='1' max='99' value='" + String(intervalRed/1000) + "'></div>";
html += "<input type='submit' value='Update Times'>";
html += "</form>";
html += "</div>";
html += "</body></html>";
server.send(200, "text/html", html);
}
// --- Logic Update Function ---
void handleUpdate() {
if (server.hasArg("g")) intervalGreen = server.arg("g").toInt() * 1000;
if (server.hasArg("y")) intervalYellow = server.arg("y").toInt() * 1000;
if (server.hasArg("r")) intervalRed = server.arg("r").toInt() * 1000;
// ส่งผู้ใช้กลับไปหน้าหลัก
server.sendHeader("Location", "/");
server.send(303);
Serial.println("Intervals Updated!");
}
void setup() {
Serial.begin(115200);
pinMode(LED_R, OUTPUT); pinMode(LED_Y, OUTPUT); pinMode(LED_G, OUTPUT);
pinMode(BTN_PIN, INPUT_PULLUP);
if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) Serial.println("OLED failed");
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
server.on("/", handleRoot);
server.on("/update", handleUpdate);
server.begin();
Serial.println("\nIP " + WiFi.localIP().toString());
}
void loop() {
server.handleClient();
unsigned long currentMillis = millis();
long remainingTime = 0;
// Manual Override
if (digitalRead(BTN_PIN) == LOW && currentState == GREEN) {
currentState = YELLOW; previousMillis = currentMillis;
}
switch (currentState) {
case GREEN:
updateLEDs(0, 0, 1);
remainingTime = (intervalGreen - (currentMillis - previousMillis)) / 1000;
if (currentMillis - previousMillis >= intervalGreen) { currentState = YELLOW; previousMillis = currentMillis; }
showDisplay("GO", remainingTime);
break;
case YELLOW:
updateLEDs(0, 1, 0);
remainingTime = (intervalYellow - (currentMillis - previousMillis)) / 1000;
if (currentMillis - previousMillis >= intervalYellow) { currentState = RED; previousMillis = currentMillis; }
showDisplay("WAIT", remainingTime);
break;
case RED:
updateLEDs(1, 0, 0);
remainingTime = (intervalRed - (currentMillis - previousMillis)) / 1000;
if (currentMillis - previousMillis >= intervalRed) { currentState = GREEN; previousMillis = currentMillis; }
showDisplay("STOP", remainingTime);
break;
}
}
void showDisplay(String status, int sec) {
display.clearDisplay();
display.setTextSize(2); display.setTextColor(1); display.setCursor(40, 0); display.println(status);
display.setTextSize(4); display.setCursor(45, 25); display.println(max(0, sec));
display.setTextSize(1); display.setCursor(0, 55); display.print(WiFi.localIP().toString());
display.display();
}
void updateLEDs(int r, int y, int g) {
digitalWrite(LED_R, r); digitalWrite(LED_Y, y); digitalWrite(LED_G, g);
}🔍 อธิบายโค้ดที่เพิ่มเข้ามา
HTTP GET Method & Argument Parsing
ในหน้าเว็บ เราใช้ฟอร์ม HTML ที่ส่งค่าแบบ GET เมื่อกดปุ่ม “Update” Browser จะสร้าง URL เช่น http//192.168.1.50/update?g=20&y=5&r=20
ในโค้ด ESP32 ส่วน handleUpdate() จะใช้ฟังก์ชัน
- server.hasArg(“g”) เช็คว่ามีข้อมูลตัวแปร g ส่งมาไหม
- server.arg(“g”).toInt() ดึงค่าข้อความออกมาแล้วแปลงเป็นตัวเลข (Integer)
- คูณ 1000 เพื่อแปลงหน่วยจากวินาที (ที่คนกรอก) ให้เป็นมิลลิวินาที (ที่คอมพิวเตอร์ใช้)
การทำงานของ Web Server Route
- server.on(“/”, handleRoot) เมื่อเข้า IP ตรงๆ จะโชว์หน้าฟอร์มกรอกเวลา (หน้าแรก)
- server.on(“/update”, handleUpdate) เมื่อกดส่งข้อมูล จะวิ่งมาที่ฟังก์ชันนี้เพื่อคำนวณและบันทึกค่า
UX Improvement on OLED
ในฟังก์ชัน showDisplay จะเพิ่มการแสดงผล IP Address ไว้ที่มุมล่างของจอ OLED ตลอดเวลา เพื่อให้นักศึกษารู้ว่าต้องพิมพ์ที่อยู่เว็บอะไรโดยไม่ต้องเปิด Serial Monitor ดู (แต่ในการใช้งานจริง ไม่ควรแสดงผลส่วนนี้)
🎯 ประเด็นที่ควรเน้นย้ำ
“ทำไมค่าที่ตั้งไว้ถึงหายเมื่อปิดเครื่อง” นักศึกษาจะสังเกตว่าถ้าถอดปลั๊กแล้วเสียบใหม่ เวลาจะกลับไปเป็น 10 3 10 วินาทีเหมือนเดิม เพราะข้อมูลถูกเก็บไว้ใน RAM จากโค้ดที่เขียนไว้ หากต้องการให้ค่าอยู่ถาวร ต้องเรียนรู้การใช้ EEPROM (อี-อี-พรอม) หรือ LittleFS (ลิตเติล-เอฟเอส) ในการบันทึกค่าลงใน Flash Memory
💻 โค้ดที่ปรับปรุงใหม่ให้เว็บสวยขึ้น (Modern UI Version)

นำไปแทนที่ ในส่วน void handleRoot()
void handleRoot() {
String html = "<!DOCTYPE html><html lang='th'>";
html += "<head><meta charset='UTF-8'><meta name='viewport' content='width=device-width, initial-scale=1'>";
html += "<title>ควบคุมไฟจราจร</title>";
// นำเข้าฟอนต์ 'คณิต' (Kanit) จาก Google Fonts
html += "<link href='https://fonts.googleapis.com/css2?family=Kanit:wght@300;500&display=swap' rel='stylesheet'>";
html += "<style>";
// ตั้งค่าพื้นหลังและฟอนต์
html += "body { font-family: 'Kanit', sans-serif; background: linear-gradient(135deg, #e0c3fc 0%, #8ec5fc 100%); display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; color: #333; }";
// ดีไซน์กล่องควบคุมหลัก (Card)
html += ".card { background: rgba(255, 255, 255, 0.95); padding: 40px 30px; border-radius: 24px; box-shadow: 0 20px 40px rgba(0,0,0,0.15); width: 90%; max-width: 400px; backdrop-filter: blur(10px); }";
html += "h1 { text-align: center; color: #2c3e50; font-size: 26px; margin-top: 0; margin-bottom: 30px; font-weight: 500; }";
// ดีไซน์กล่องใส่ตัวเลขแต่ละบรรทัด
html += ".input-group { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding: 15px 20px; background: #ffffff; border-radius: 16px; box-shadow: 0 4px 10px rgba(0,0,0,0.05); transition: 0.3s ease; border: 1px solid #f0f0f0; }";
html += ".input-group:hover { transform: translateY(-3px); box-shadow: 0 8px 15px rgba(0,0,0,0.1); }";
// ขีดสีแบ่งสถานะไฟด้านซ้าย
html += ".green-box { border-left: 6px solid #2ecc71; }";
html += ".yellow-box { border-left: 6px solid #f1c40f; }";
html += ".red-box { border-left: 6px solid #e74c3c; }";
html += "label { font-size: 18px; font-weight: 500; }";
// ดีไซน์ช่องกรอกตัวเลข
html += "input[type='number'] { width: 70px; padding: 10px; border: 2px solid #e0e0e0; border-radius: 10px; text-align: center; font-size: 18px; font-family: 'Kanit', sans-serif; outline: none; transition: 0.3s; font-weight: bold; color: #2c3e50; }";
html += "input[type='number']:focus { border-color: #3498db; box-shadow: 0 0 8px rgba(52, 152, 219, 0.3); }";
// ดีไซน์ปุ่มกด
html += "button { width: 100%; padding: 16px; background: linear-gradient(to right, #3498db, #2980b9); color: white; border: none; border-radius: 16px; font-size: 20px; font-weight: 500; font-family: 'Kanit', sans-serif; cursor: pointer; transition: 0.3s; margin-top: 15px; box-shadow: 0 8px 15px rgba(52, 152, 219, 0.3); }";
html += "button:hover { transform: translateY(-2px); box-shadow: 0 12px 20px rgba(52, 152, 219, 0.4); }";
html += "button:active { transform: translateY(2px); box-shadow: none; }";
// เครดิตด้านล่าง
html += ".footer { text-align: center; margin-top: 25px; font-size: 14px; color: #7f8c8d; }";
html += "</style></head><body>";
// เริ่มส่วนของการแสดงผล
html += "<div class='card'>";
html += "<h1>🚦 แผงควบคุมไฟจราจร</h1>";
html += "<form action='/update' method='GET'>";
// ช่องไฟเขียว
html += "<div class='input-group green-box'>";
html += "<label>🟢 ไฟเขียว (วินาที)</label>";
html += "<input type='number' name='g' min='1' max='99' value='" + String(intervalGreen/1000) + "'>";
html += "</div>";
// ช่องไฟเหลือง
html += "<div class='input-group yellow-box'>";
html += "<label>🟡 ไฟเหลือง (วินาที)</label>";
html += "<input type='number' name='y' min='1' max='99' value='" + String(intervalYellow/1000) + "'>";
html += "</div>";
// ช่องไฟแดง
html += "<div class='input-group red-box'>";
html += "<label>🔴 ไฟแดง (วินาที)</label>";
html += "<input type='number' name='r' min='1' max='99' value='" + String(intervalRed/1000) + "'>";
html += "</div>";
// ปุ่มยืนยัน
html += "<button type='submit'>บันทึกการตั้งค่า</button>";
html += "</form>";
html += "<div class='footer'>ระบบควบคุมอัจฉริยะโดย ESP32</div>";
html += "</div>";
html += "</body></html>";
server.send(200, "text/html", html);
}จุดที่อัปเกรดขึ้นมา
- Background & Box Shadow มีการใช้เงาแบบฟุ้งๆ (Soft Shadow) และทำมุมโค้ง (Border-radius: 24px) ทำให้ดูเหมือนแอปพลิเคชันของ Apple/Google
<html lang='th'>ใส่โค้ดบอกบราวเซอร์ว่าหน้านี้เป็นภาษาไทย ทำให้การแสดงผลตัวอักษรสมบูรณ์ขึ้น- Gradient Button ปุ่มกดไม่ได้เป็นแค่สีเดียว แต่ใช้เทคนิค
linear-gradientไล่สีฟ้าสดใส ทำให้ดูน่ากดมากยิ่งขึ้น
รักษาโรคความจำเสื่อมให้ ESP32 (Data Persistence)
สถานการณ์ปัญหา จากโค้ดที่เราเขียนไป เมื่อเราตั้งค่าไฟจราจรผ่านเว็บเสร็จแล้ว ระบบทำงานได้ตามปกติ แต่… “ถ้าเผลอทำปลั๊กหลุดหรือไฟดับ ค่าที่เราตั้งไว้จะหายวับไปทันที” ระบบจะกลับไปใช้ค่าดั้งเดิมคือ 10 3 10 วินาที ตามโค้ดที่เราเขียนไว้เสมอ
ทำไมถึงเป็นแบบนั้น เพราะตัวแปร long intervalGreen; ถูกเก็บไว้ใน RAM (หน่วยความจำชั่วคราว) ซึ่งต้องใช้ไฟฟ้าเลี้ยงตลอดเวลา ไฟดับปุ๊บ ข้อมูลหายปั๊บ
วิธีรักษา เราจะสอนให้ ESP32 รู้จักเซฟข้อมูลลงใน Flash Memory (หน่วยความจำถาวร) โดยเราจะใช้ไลบรารีประจำตัวของ ESP32 ที่ชื่อว่า Preferences (พรี-เฟอ-เรน-เซส) ซึ่งใช้งานง่ายกว่า EEPROM แบบเก่ามาก
🛠️ 3 ขั้นตอนการผ่าตัดโค้ดรักษาความจำเสื่อม
เปิดโค้ดเดิมขึ้นมา แล้วทำการเพิ่มโค้ด 3 จุดนี้เข้าไป
จุดที่ 1 เรียกใช้คุณหมอ Preferences
#include <WiFi.h>
#include <WebServer.h>
// ... (บรรทัดอื่นๆ เหมือนเดิม) ...
#include <Preferences.h> // 🌟 1. นำเข้าไลบรารี Preferences
Preferences preferences; // 🌟 2. สร้างกล่องชื่อ preferences ไว้เก็บข้อมูลจุดที่ 2 โหลดเซฟตอนเปิดเครื่อง
ไปที่ฟังก์ชัน setup() พิมพ์โค้ดชุดนี้แทรกลงไปก่อนฟังก์ชัน WiFi.begin()
void setup() {
Serial.begin(115200);
// ... (โค้ดตั้งค่าขาไฟ LED และ OLED เหมือนเดิม) ...
// 🌟 เริ่มกระบวนการดึงความจำ
preferences.begin("traffic", false); // เปิดโฟลเดอร์ชื่อ "traffic" (false = อ่าน/เขียนได้)
// false คือ การตอบปฏิเสธ โหมดอ่านอย่างเดียว (readOnly) ระบบจึงเปิดสิทธิ์ให้เราสามารถอ่านและเขียนข้อมูลทับได้
// ดึงค่าออกมา ถ้าหาไม่เจอให้ใช้ค่าเริ่มต้น (10000, 3000, 10000)
intervalGreen = preferences.getLong("green", 10000);
intervalYellow = preferences.getLong("yellow", 3000);
intervalRed = preferences.getLong("red", 10000);
Serial.println("โหลดข้อมูลความจำเรียบร้อย!");
// ... (โค้ดเชื่อมต่อ WiFi เหมือนเดิม) ...
}จุดที่ 3 บันทึกข้อมูลทุกครั้งที่มีการกด Update
ไปที่ฟังก์ชัน handleUpdate() เราต้องสั่งให้บอร์ดจดจำค่าใหม่ทันทีที่รับข้อมูลมาจากหน้าเว็บ
void handleUpdate() {
// รับค่ามาจากหน้าเว็บแล้วคูณ 1000 แปลงเป็นมิลลิวินาที
if (server.hasArg("g")) {
intervalGreen = server.arg("g").toInt() * 1000;
preferences.putLong("green", intervalGreen); // 🌟 เซฟค่าไฟเขียวลง Flash
}
if (server.hasArg("y")) {
intervalYellow = server.arg("y").toInt() * 1000;
preferences.putLong("yellow", intervalYellow); // 🌟 เซฟค่าไฟเหลืองลง Flash
}
if (server.hasArg("r")) {
intervalRed = server.arg("r").toInt() * 1000;
preferences.putLong("red", intervalRed); // 🌟 เซฟค่าไฟแดงลง Flash
}
server.sendHeader("Location", "/");
server.send(303);
Serial.println("อัปเดตและบันทึกลงหน่วยความจำถาวรแล้ว");
}🔍 สรุปกลไกการทำงาน
preferences.begin("traffic", false)เปรียบเสมือนการเปิดแฟ้มเอกสารที่ชื่อว่า “traffic” ขึ้นมา (false แปลว่าเราเปิดมาเพื่อทั้งอ่านและเขียนทับได้)putLong("ชื่อคีย์", ค่าตัวเลข)คือการเอาดินสอจดค่าตัวแปรลงไปในแฟ้ม แล้วแปะป้ายชื่อไว้ เช่น แปะป้าย “green” ว่าเท่ากับ 20000getLong("ชื่อคีย์", ค่าสำรอง)คือการไปค้นหาป้ายชื่อที่จดไว้ ถ้าเจอก็เอาค่านั้นมาใช้ แต่ถ้าเพิ่งซื้อบอร์ดมาใหม่เอี่ยม ยังไม่เคยจดอะไรเลย บอร์ดจะเอาค่าสำรองมาใช้แทน เพื่อไม่ให้โปรแกรมพัง
บททดสอบความสำเร็จ ให้นักศึกษาอัปโหลดโค้ด > ตั้งค่าเวลาใหม่ผ่านหน้าเว็บ > ดึงสาย USB ออกให้ไฟดับ > เสียบสายใหม่ > รอดูว่าบอร์ดยังจำเวลาที่ตั้งไว้ล่าสุดได้หรือไม่
แก้ไขอาการจอ OLED กระพริบ (Flicker) จังหวะเปลี่ยนสถานะ
ฟังก์ชัน loop() ทำงานเร็วมาก เป็นหมื่นครั้งต่อวินาที แปลว่าบอร์ดสั่ง display.clearDisplay() ล้างจอภาพแล้ววาดใหม่เป็นหมื่นรอบต่อวินาที จอ OLED ทำงานผ่านสาย I2C ซึ่งส่งข้อมูลไม่ทัน ส่งผลให้จอมีอาการวูบวาบและกินทรัพยากรบอร์ดโดยไม่จำเป็น
🛠️ วิธีแก้ไข : แยกสมองการทำงานออกจากหน้าจอ
วิธีแก้แบบมืออาชีพคือ เราจะแยกส่วนคิดคำนวณ (Logic) ออกจากการแสดงผล (UI) และสั่งให้จออัปเดตภาพแค่ 10 เฟรมต่อวินาทีก็พอ
แก้ไขฟังก์ชัน void loop() ด้านล่างนี้ ไปวางทับของเดิมได้เลย
void loop() {
server.handleClient();
unsigned long currentMillis = millis();
long remainingTime = 0;
// 🌟 1. สร้างตัวแปรมาพักข้อมูลไว้ก่อน อย่าเพิ่งส่งไปที่จอทันที
String displayStatus = "";
// --- ส่วนปุ่มกด (Manual Override) ---
if (digitalRead(BTN_PIN) == LOW && currentState == GREEN) {
currentState = YELLOW; previousMillis = currentMillis;
}
// --- ส่วนที่ 1: ตรรกะไฟจราจร (Logic) ---
switch (currentState) {
case GREEN:
updateLEDs(0, 0, 1);
remainingTime = (intervalGreen - (currentMillis - previousMillis)) / 1000;
displayStatus = "GO";
if (currentMillis - previousMillis >= intervalGreen) {
currentState = YELLOW;
previousMillis = currentMillis;
}
break;
case YELLOW:
updateLEDs(0, 1, 0);
remainingTime = (intervalYellow - (currentMillis - previousMillis)) / 1000;
displayStatus = "WAIT";
if (currentMillis - previousMillis >= intervalYellow) {
currentState = RED;
previousMillis = currentMillis;
}
break;
case RED:
updateLEDs(1, 0, 0);
remainingTime = (intervalRed - (currentMillis - previousMillis)) / 1000;
displayStatus = "STOP";
if (currentMillis - previousMillis >= intervalRed) {
currentState = GREEN;
previousMillis = currentMillis;
}
break;
}
// --- ส่วนที่ 2: การแสดงผลหน้าจอ (UI Update) ---
// 🌟 2. หน่วงเวลาจอภาพ: สั่งให้อัปเดตจอแค่ทุกๆ 100 มิลลิวินาที (10 ครั้ง/วินาที)
static unsigned long lastOledUpdate = 0;
if (currentMillis - lastOledUpdate >= 100) {
showDisplay(displayStatus, remainingTime);
lastOledUpdate = currentMillis;
}
}
สิ่งที่เกิดขึ้นในโค้ดชุดใหม่นี้
- จอ OLED จะไม่โดนสั่งล้างหน้าจอแบบรัวๆอีกต่อไป ทำให้ภาพนิ่งสนิท ตัวเลขเดินเนียนตา
- คำว่า
GOWAITSTOPจะถูกอัปเดตแสดงผลในจังหวะที่ถูกต้อง ไม่มีเฟรมผีโผล่มาแทรกตอนเปลี่ยนสีไฟ - บอร์ด ESP32 จะมีเวลาว่างไปรับคำสั่งจาก Web Server ได้ไวขึ้นด้วย เพราะไม่ต้องมัวแต่คุยกับจอ OLED ตลอดเวลา
สอนโดย อาจารย์นุ/ครูนุ (ภานุพงศ์ สะและหมัด)
ติดต่อ Line ID : salae44476
