ระบบควบคุมและตรรกะขั้นสูง (Smart Logic Control)

โครงสร้างพื้นฐาน สำหรับการพัฒนาโปรแกรม 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) ที่ชัดเจน บอร์ดจะรู้ตัวเสมอว่าตอนนี้ตัวเองกำลังอยู่ในสถานะไหน และจะต้องทำอะไรต่อ

ตัวอย่าง เครื่องซักผ้า

  1. สถานะรอ (Idle) รอคนมากดปุ่ม
  2. สถานะเติมน้ำ (Filling) เปิดวาล์วน้ำ รอจนน้ำเต็ม
  3. สถานะซัก (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)
สั่งอุปกรณ์ให้ทำงาน (หลอดไฟ ปั๊ม เสียง)OUTPUTHIGH (สั่งเปิด) / LOW (สั่งปิด)
เซนเซอร์แผงวงจรสำเร็จรูปINPUTรับ HIGH หรือ LOW (ตามสเปกเซนเซอร์)
สวิตช์กลไก / หน้าสัมผัสเปล่าๆINPUT_PULLUPLOW (เวลากดสวิตช์หรือหน้าสัมผัสชนกัน)

💡 ข้อควรระวัง ถ้านักศึกษาเอาสวิตช์ลูกลอยเตือนน้ำเต็ม 2 เส้น มาต่อเข้า ESP32 แล้วลืมพิมพ์คำว่า _PULLUP (พิมพ์แค่ INPUT เฉยๆ) ปั๊มน้ำอาจจะทำงานสลับเปิด-ปิดรัวๆ จนมอเตอร์ไหม้ได้เลย เพราะระบบจะอ่านค่าผีหลอก (Floating) นั่นเอง

Lab 5 ระบบไฟจราจรอัจฉริยะพร้อมปุ่มกดข้ามถนน (Smart Traffic Light Control with Manual Override)

ในแล็บนี้ เราจะข้ามพื้นฐานการกะพริบไฟแบบธรรมดา แต่จะเน้นการเขียนโปรแกรมเชิงตรรกะแบบ State Machine และการใช้ Non-blocking Delay (millis()) เพื่อให้ ESP32 สามารถตรวจสอบการกดปุ่มได้ตลอดเวลาโดยไม่ค้างที่คำสั่ง delay()

วัตถุประสงค์

  1. เพื่อให้มีความเข้าใจในการจัดการสถานะ (State) ของโปรแกรม
  2. เพื่อฝึกการใช้งาน Digital Input (Pull-up) และ Digital Output
  3. เพื่อเรียนรู้การเขียนโค้ดแบบ 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 SwitchGPIO 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)

  1. ตรวจสอบสถานะปัจจุบัน ก่อนจะเปลี่ยนไฟ เราต้องเช็คก่อนว่าตอนนี้เป็นไฟเขียวหรือไม่
  2. เปลี่ยนเป้าหมาย หากมีการกดปุ่มขณะเป็นไฟเขียว ให้เปลี่ยน currentState เป็น YELLOW แทน RED
  3. รีเซ็ตเวลา บันทึกเวลาเริ่มต้นใหม่ (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หมายเหตุ
OLEDGNDGNDสายกราวด์ร่วม
VCC3.3Vห้ามต่อ 5V เพราะจออาจไหม้
SCLGPIO 22ขาสัญญาณนาฬิกา I2C
SDAGPIO 21ขาสัญญาณข้อมูล I2C
LED แดงขาบวก (Long Leg)GPIO 12ต่อผ่านตัวต้านทาน
LED เหลืองขาบวก (Long Leg)GPIO 14ต่อผ่านตัวต้านทาน
LED เขียวขาบวก (Long Leg)GPIO 27ต่อผ่านตัวต้านทาน
Switchขาที่ 1GPIO 26สำหรับรับคำสั่ง Manual
ขาที่ 2GNDเมื่อกดปุ่มจะดึงสัญญาณลง 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)

  1. เปลี่ยนจาก 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) ค่าใหม่ลงไปได้ตลอดเวลาขณะที่บอร์ดกำลังทำงานอยู่
  2. HTML Input Form สร้างหน้าเว็บที่มีช่องกรอกตัวเลข (Input Type=”number”) และปุ่มกดส่งข้อมูล (Submit)
  3. 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” ว่าเท่ากับ 20000
  • getLong("ชื่อคีย์", ค่าสำรอง) คือการไปค้นหาป้ายชื่อที่จดไว้ ถ้าเจอก็เอาค่านั้นมาใช้ แต่ถ้าเพิ่งซื้อบอร์ดมาใหม่เอี่ยม ยังไม่เคยจดอะไรเลย บอร์ดจะเอาค่าสำรองมาใช้แทน เพื่อไม่ให้โปรแกรมพัง

บททดสอบความสำเร็จ ให้นักศึกษาอัปโหลดโค้ด > ตั้งค่าเวลาใหม่ผ่านหน้าเว็บ > ดึงสาย 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;
  }
}

สิ่งที่เกิดขึ้นในโค้ดชุดใหม่นี้

  1. จอ OLED จะไม่โดนสั่งล้างหน้าจอแบบรัวๆอีกต่อไป ทำให้ภาพนิ่งสนิท ตัวเลขเดินเนียนตา
  2. คำว่า GO WAIT STOP จะถูกอัปเดตแสดงผลในจังหวะที่ถูกต้อง ไม่มีเฟรมผีโผล่มาแทรกตอนเปลี่ยนสีไฟ
  3. บอร์ด ESP32 จะมีเวลาว่างไปรับคำสั่งจาก Web Server ได้ไวขึ้นด้วย เพราะไม่ต้องมัวแต่คุยกับจอ OLED ตลอดเวลา

สอนโดย อาจารย์นุ/ครูนุ (ภานุพงศ์ สะและหมัด)

ติดต่อ Line ID : salae44476

Scroll to Top