Đây là trọn bộ giải pháp kết nối phần cứng ESP32 điều khiển hệ thống 8 rơ-le (Relay) theo lịch trình (Thời gian + Các ngày trong tuần), lưu cấu hình cục bộ bằng Preferences.h, đồng bộ và gửi log trạng thái (On/Off) qua giao thức bảo mật HTTPS GET/POST (SSL Root CA) đến hệ thống Web Server (PHP API & phpMyAdmin/MySQL). ------------------------------ ## 1. Kiến trúc Cơ sở Dữ liệu (MySQL / phpMyAdmin) Tạo cơ sở dữ liệu trên máy chủ của bạn và chạy 2 lệnh SQL sau để khởi tạo bảng cấu hình lịch trình rơ-le và bảng lưu trữ nhật ký hoạt động. -- Bảng chứa cấu hình lịch trình cho từng RelayCREATE TABLE IF NOT EXISTS `relay_config` ( `relay_id` INT NOT NULL PRIMARY KEY, `selected_time` TIME NOT NULL, -- Ví dụ: '08:30:00' `selected_days` VARCHAR(50) NOT NULL, -- Lưu chuỗi định dạng: "Sun,Mon,Wed" `active` TINYINT(1) NOT NULL DEFAULT 1 -- 1: Bật lịch, 0: Tắt lịch ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Chèn dữ liệu mẫu cho 8 RelayINSERT INTO `relay_config` (`relay_id`, `selected_time`, `selected_days`, `active`) VALUES (1, '08:00:00', 'Mon,Tue,Wed,Thu,Fri', 1), (2, '17:30:00', 'Mon,Wed,Fri', 1), (3, '06:00:00', 'Sat,Sun', 1), (4, '12:00:00', 'Sun,Mon,Tue,Wed,Thu,Fri,Sat', 1), (5, '22:00:00', 'Thu,Fri', 0), (6, '00:15:00', 'Sat', 1), (7, '13:45:00', 'Tue,Thu', 1), (8, '18:00:00', 'Sun', 1); -- Bảng lưu trữ nhật ký (Log) trạng thái relay gửi từ ESP32CREATE TABLE IF NOT EXISTS `relay_log` ( `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, `relay_id` INT NOT NULL, `status` ENUM('on', 'off') NOT NULL, -- Trạng thái 'on' hoặc 'off' `logged_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ------------------------------ ## 2. Mã nguồn PHP Backend API (api.php) Xử lý đồng thời cả 2 tác vụ: Trả về cấu hình JSON cho ESP32 (GET) và ghi nhận nhật ký trạng thái rơ-le (POST). [1] connect_error) { echo json_encode(["status" => "error", "message" => "Database connection failed"]); exit(); } $method = $_SERVER['REQUEST_METHOD']; // 1. GET REQUEST: ESP32 tải lịch trình về máy if ($method === 'GET') { $sql = "SELECT relay_id, selected_time, selected_days, active FROM relay_config"; $result = $conn->query($sql); $schedules = []; while($row = $result->fetch_assoc()) { // Ép kiểu active về integer $row['active'] = (int)$row['active']; $schedules[] = $row; } echo json_encode($schedules); } // 2. POST REQUEST: ESP32 đẩy Log trạng thái lên hệ thống elseif ($method === 'POST') { // Đọc luồng dữ liệu JSON nhận được $inputData = json_decode(file_get_contents('php://input'), true); if (isset($inputData['relay_id']) && isset($inputData['status'])) { $relay_id = (int)$inputData['relay_id']; $status = $conn->real_escape_string($inputData['status']); // 'on' hoặc 'off' if (in_array($status, ['on', 'off'])) { $stmt = $conn->prepare("INSERT INTO relay_log (relay_id, status) VALUES (?, ?)"); $stmt->bind_param("is", $relay_id, $status); if ($stmt->execute()) { echo json_encode(["status" => "success", "message" => "Log inserted successfully"]); } else { echo json_encode(["status" => "error", "message" => "Execution failed"]); } $stmt->close(); } else { echo json_encode(["status" => "error", "message" => "Invalid status value"]); } } else { echo json_encode(["status" => "error", "message" => "Missing required fields"]); } } else { echo json_encode(["status" => "error", "message" => "Method not allowed"]); } $conn->close(); ?> ------------------------------ ## 3. Mã nguồn ESP32 (Arduino IDE) Đoạn mã này sử dụng thư viện Preferences.h để lưu cấu hình offline, ArduinoJson.h (V6/V7) để bóc tách/đóng gói dữ liệu, đồng bộ thời gian thực qua NTP server và thực hiện các kết nối bảo mật HTTPS qua chứng chỉ Root CA. [2, 3, 4, 5, 6, 7, 8, 9] #include #include #include #include #include #include "time.h" // --- Cấu hình WiFi ---const char* ssid = "YOUR_WIFI_SSID";const char* password = "YOUR_WIFI_PASSWORD"; // --- Cấu hình Server API ---const char* api_url = "https://your-domain.com"; /* --- Chứng chỉ Root CA SSL --- Thay thế bằng chuỗi Root CA của bên cung cấp tên miền của bạn (ví dụ: Let's Encrypt, DigiCert) Lấy bằng lệnh: openssl s_client -showcerts -connect your-domain.com:443 */const char* root_ca = R"( -----BEGIN CERTIFICATE----- MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4 WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY ... (Thêm phần còn lại của mã chứng chỉ vào đây) ... -----END CERTIFICATE----- )"; // --- Định nghĩa chân Pins cho 8 Relay ---const int relayPins[8] = {13, 12, 14, 27, 26, 25, 33, 32}; // --- Cấu hình NTP lấy thời gian ---const char* ntpServer = "pool.ntp.org";const long gmtOffset_sec = 25200; // GMT+7 (Việt Nam: 7 * 3600)const int daylightOffset_sec = 0; Preferences prefs; // Cấu trúc dữ liệu lịch trình rơ-le lưu trong RAMstruct RelaySchedule { char selected_time[9]; // "HH:MM:SS" char selected_days[40]; // "Sun,Mon,Tue..." bool active; };RelaySchedule schedules[8]; unsigned long lastSyncTime = 0;const unsigned long syncInterval = 300000; // Tự động đồng bộ lại Server sau mỗi 5 phút void setup() { Serial.begin(115200); // Cấu hình chân Relay đầu ra for (int i = 0; i < 8; i++) { pinMode(relayPins[i], OUTPUT); digitalWrite(relayPins[i], LOW); // Mặc định tắt rơ-le khi khởi động } // Kết nối WiFi WiFi.begin(ssid, password); Serial.print("Connecting to WiFi"); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println("\nWiFi Connected!"); // Cấu hình đồng bộ thời gian thực qua NTP (Bắt buộc cho SSL) configTime(gmtOffset_sec, daylightOffset_sec, ntpServer); syncLocalTime(); // Khởi tạo và đọc cấu hình cũ từ bộ nhớ Flash Preferences prefs.begin("relay_app", false); loadSchedulesFromFlash(); // Đột phá lấy dữ liệu mới từ Server API fetchSchedulesFromServer(); } void loop() { checkSchedules(); // Kiểm tra định kỳ để cập nhật lịch trình mới từ MySQL if (millis() - lastSyncTime > syncInterval) { fetchSchedulesFromServer(); lastSyncTime = millis(); } delay(1000); // Quét mỗi giây một lần } // Kiểm tra thời gian thực tại để kích hoạt hoặc tắt rơ-levoid checkSchedules() { struct tm timeinfo; if (!getLocalTime(&timeinfo)) return; char currentTime[9]; strftime(currentTime, sizeof(currentTime), "%H:%M:%S", &timeinfo); char currentDay[4]; strftime(currentDay, sizeof(currentDay), "%a", &timeinfo); // Trả về dạng: "Sun", "Mon", "Tue"... for (int i = 0; i < 8; i++) { if (!schedules[i].active) { if(digitalRead(relayPins[i]) == HIGH) { digitalWrite(relayPins[i], LOW); logRelayStatus(i + 1, "off"); } continue; } // Kiểm tra xem ngày hiện tại có nằm trong danh sách đăng ký lịch không if (strstr(schedules[i].selected_days, currentDay) != NULL) { // Logic mẫu: Bật rơ-le trong vòng 1 phút tính từ mốc thời gian thiết lập char targetHourMin[6], currentHourMin[6]; strncpy(targetHourMin, schedules[i].selected_time, 5); targetHourMin[5] = '\0'; strncpy(currentHourMin, currentTime, 5); currentHourMin[5] = '\0'; if (strcmp(targetHourMin, currentHourMin) == 0) { if (digitalRead(relayPins[i]) == LOW) { digitalWrite(relayPins[i], HIGH); logRelayStatus(i + 1, "on"); } } else { if (digitalRead(relayPins[i]) == HIGH) { digitalWrite(relayPins[i], LOW); logRelayStatus(i + 1, "off"); } } } else { if (digitalRead(relayPins[i]) == HIGH) { digitalWrite(relayPins[i], LOW); logRelayStatus(i + 1, "off"); } } } } // Đồng bộ danh sách lịch trình từ Server PHP (HTTPS GET)void fetchSchedulesFromServer() { if (WiFi.status() != WL_CONNECTED) return; WiFiClientSecure client; client.setCACert(root_ca); // Kích hoạt bảo mật chứng chỉ Root CA HTTPClient https; if (https.begin(client, api_url)) { int httpCode = https.GET(); if (httpCode == HTTP_CODE_OK) { String payload = https.getString(); // Khởi tạo vùng nhớ Json (Dùng định mức lớn vì chuỗi cấu hình dài) DynamicJsonDocument doc(4096); DeserializationError error = deserializeJson(doc, payload); if (!error) { JsonArray arr = doc.as(); int index = 0; for (JsonObject repo : arr) { if(index >= 8) break; int r_id = repo["relay_id"]; // Lấy ID tương ứng (1-8) int arr_idx = r_id - 1; if(arr_idx >= 0 && arr_idx < 8) { String s_time = repo["selected_time"]; String s_days = repo["selected_days"]; bool active = repo["active"]; strcpy(schedules[arr_idx].selected_time, s_time.c_str()); strcpy(schedules[arr_idx].selected_days, s_days.c_str()); schedules[arr_idx].active = active; // Lưu trực tiếp vào Flash chống mất dữ liệu khi mất nguồn char key_time[10], key_days[10], key_act[10]; sprintf(key_time, "t_%d", arr_idx); sprintf(key_days, "d_%d", arr_idx); sprintf(key_act, "a_%d", arr_idx); prefs.putString(key_time, s_time); prefs.putString(key_days, s_days); prefs.putBool(key_act, active); } index++; } Serial.println("Schedules successfully updated from Server and Flash!"); } } https.end(); } } // Đẩy bản tin Log trạng thái Rơ-le lên Web Server (HTTPS POST JSON)void logRelayStatus(int relayId, const char* status) { if (WiFi.status() != WL_CONNECTED) return; WiFiClientSecure client; client.setCACert(root_ca); HTTPClient https; if (https.begin(client, api_url)) { https.addHeader("Content-Type", "application/json"); DynamicJsonDocument doc(256); doc["relay_id"] = relayId; doc["status"] = status; String requestBody; serializeJson(doc, requestBody); int httpCode = https.POST(requestBody); if (httpCode > 0) { Serial.printf("[Log Sent] Relay %d updated to %s. Server reply code: %d\n", relayId, status, httpCode); } https.end(); } } // Đọc dữ liệu lịch trình từ Flash trong trường hợp khởi động offline mất mạngvoid loadSchedulesFromFlash() { for (int i = 0; i < 8; i++) { char key_time[10], key_days[10], key_act[10]; sprintf(key_time, "t_%d", i); sprintf(key_days, "d_%d", i); sprintf(key_act, "a_%d", i); String s_time = prefs.getString(key_time, "00:00:00"); String s_days = prefs.getString(key_days, "Sun,Mon,Tue,Wed,Thu,Fri,Sat"); bool active = prefs.getBool(key_act, false); strcpy(schedules[i].selected_time, s_time.c_str()); strcpy(schedules[i].selected_days, s_days.c_str()); schedules[i].active = active; } } void syncLocalTime() { struct tm timeinfo; if (!getLocalTime(&timeinfo)) { Serial.println("Failed to obtain time from NTP Server"); return; } Serial.println("Time Synchronized successfully!"); } ------------------------------ ## Cần lưu ý gì tiếp theo để vận hành hệ thống? * * Bạn có muốn tối ưu hóa tính năng chạy ngầm (Deep Sleep) của ESP32 khi không đến giờ kích hoạt rơ-le để tiết kiệm năng lượng không? * Bạn có cần xây dựng thêm giao diện quản lý Web Dashboard chạy bằng HTML/JS để tương tác trực quan với file PHP này từ xa không? [10, 11, 12] * [1] [https://www.techrm.com](https://www.techrm.com/how-to-build-a-rest-api-server-with-esp32/) [2] [https://arduinojson.org](https://arduinojson.org/v6/how-to/use-arduinojson-with-httpclient/) [3] [https://randomnerdtutorials.com](https://randomnerdtutorials.com/esp32-save-data-permanently-preferences/) [4] [https://zbotic.in](https://zbotic.in/esp32-preferences-library-non-volatile-storage-nvs-guide/) [5] [https://github.com](https://github.com/witnessmenow/arduino-sample-api-request/blob/master/ESP32/HTTP_GET_JSON/HTTP_GET_JSON.ino) [6] [https://zbotic.in](https://zbotic.in/esp32-https-rest-api-secure-data-post-to-cloud-servers/) [7] [https://randomnerdtutorials.com](https://randomnerdtutorials.com/esp32-esp8266-run-daily-task/) [8] [https://zbotic.in](https://zbotic.in/esp32-http-client-get-post-requests-to-rest-api-guide/) [9] [https://randomnerdtutorials.com](https://randomnerdtutorials.com/esp32-https-requests/) [10] [https://randomnerdtutorials.com](https://randomnerdtutorials.com/esp32-deep-sleep-arduino-ide-wake-up-sources/) [11] [https://www.youtube.com](https://www.youtube.com/watch?v=dyvpRYfWjkY) [12] [https://github.com](https://github.com/fdeferia/ESP32-E-paper-Display-Weather-Calendar-and-News)