AIoT Semiconductor — From LAB to LIFE
Thực hành · ESP32
Thiết kế board & lập trình nhúng IoT
0/0
01Bài thực hành

Lập trình hướng đối tượng kết nối WiFi cơ bản cho ESP32

Dựng bộ khung firmware nhiều file cho board IoT, đóng gói phần WiFi thành một class riêng, lấy số MAC của ESP32 làm ID thiết bị và kết nối vào mạng WiFi 2.4 GHz.

  • Tạo sketch và tách chương trình thành nhiều file .h / .cpp thay vì viết hết trong file .ino.
  • Hiểu và dùng macro chống include lặp (include guard) trong mỗi file header.
  • Khai báo class MyWIFI chứa biến và hàm phục vụ kết nối mạng.
  • Lấy số MAC của ESP32 — đây chính là ID để IoT-Cloud nhận diện board.
  • Kết nối WiFi có thời gian chờ và in trạng thái ra Serial Monitor.
  1. Tạo thư mục và file sketch

    Tạo một folder tên AIoTSemi. Mở Arduino IDE 2.x, chọn FileNew Sketch, bấm nút ở góc phải thanh tab → Rename để đổi tên file mặc định thành AIoTSemi.ino, rồi lưu vào folder vừa tạo.

    Lưu ý: file .ino bắt buộc nằm trong folder cùng tên — ở đây là AIoTSemi/AIoTSemi.ino, nếu không Arduino IDE sẽ không mở được sketch.
  2. Viết và nạp chương trình đầu tiên

    Gõ đoạn code dưới đây để kiểm tra toàn bộ chuỗi công cụ đã chạy được: soạn thảo → biên dịch → nạp → xem kết quả.

    • Chọn board: ToolsBoardesp32ESP32 Dev Module.
    • Chọn cổng: ToolsPort → cổng COM của KIT (nhớ cắm cáp USB vào laptop).
    • Nút để biên dịch, nút để nạp, nút kính lúp để mở Serial Monitor.
  3. Dựng bộ khung chương trình

    Bấm nút New Tab → nhập tên file, lần lượt tạo 3 file: ChuongTrinhChinh.cpp, ChuongTrinhChinh.h, AIoTSemi.h. Mỗi file .h đều phải mở đầu bằng bộ macro chống include lặp.

    Quy ước: tên macro đặt theo tên file — #ifndef _TenFile_h, #define _TenFile_h, … và kết thúc bằng #endif. Nhờ vậy nội dung header chỉ được biên dịch một lần dù bị gọi ở nhiều nơi.
  4. Khai báo hàm ở file .h, viết thân hàm ở file .cpp

    File header chỉ chứa tên hàm. Toàn bộ nội dung thực thi nằm trong file .cpp — copy luôn đoạn code đã viết trong AIoTSemi.ino sang cho nhanh.

  5. Gọi hàm từ file .ino

    File .ino từ nay chỉ còn hai lời gọi hàm: KhoiTao() chạy một lần trong setup(), ChayChuongTrinhChinh() chạy lặp trong loop().

    Kiểm tra: nạp lại code cho ESP32, nếu Serial Monitor vẫn in ra Hello IoTer's world nghĩa là bộ khung đã đúng.
  6. Tạo cặp file MyWIFI.cpp và MyWIFI.h

    Tạo thêm hai tab mới để chứa toàn bộ code kết nối WiFi. Trong MyWIFI.h tạo macro mặc định như các header khác, còn MyWIFI.cpp mở đầu bằng lời gọi #include "MyWIFI.h".

    Bắt buộc: tên file MyWIFI để không trùng với thư viện WiFi.h có sẵn của ESP32.
  7. Khai báo class WIFI

    Đây là phần "hướng đối tượng" của bài: gom biến thông tin mạng và các hàm thao tác WiFi vào chung một class.

    • Tiền tố public cho phép các hàm, các biến trong class được gọi từ bên ngoài class.
    • ESP32 chỉ hỗ trợ WiFi băng tần 2,4 GHz — không kết nối được mạng 5 GHz.
  8. Viết hàm lấy số MAC

    Số MAC là chuỗi định danh duy nhất của mỗi con ESP32. Hàm bỏ hết dấu : để có chuỗi ID gọn, sau này dùng làm mã thiết bị trên IoT-Cloud.

  9. Gọi hàm LaySoMAC từ chương trình chính

    Khai báo biến toàn cục _ID để giữ số MAC và biến đối tượng _WiFi để gọi các hàm public của class.

    Ghi lại số MAC này. Ở bài 02 bạn sẽ nhập đúng chuỗi đó vào app AIoTSemi để app nhận dữ liệu từ board của mình.
  10. Viết hàm kết nối WiFi

    Hàm nhận vào số giây tối đa được phép chờ. Vòng lặp kiểm tra trạng thái mỗi giây và in số đếm ra màn hình để biết board đang cố kết nối.

  11. Kết nối WiFi và kiểm tra trạng thái

    Bổ sung thư viện <MyWIFI.h> vào đầu ChuongTrinhChinh.cpp, gọi hàm kết nối trong KhoiTao() và in trạng thái mạng trong vòng lặp chính.

    Vì sao phải có thời gian chờ? Nếu để ESP32 chờ kết nối vô hạn, board sẽ bị watchdog reset và không thể phát Access Point khi cần cấu hình lại mạng.
02Bài thực hành

Lập trình gửi dữ liệu cảm biến lên IoT-Cloud và hiển thị trên app

Viết class POSTGET để board đẩy dữ liệu lên server qua API, đóng gói dữ liệu theo đúng định dạng chuỗi của IoT-Cloud, chạy tác vụ gửi dữ liệu trên một core riêng của ESP32 và xem kết quả trên app AIoTSemi.

  • Dùng thư viện HTTPClient để gửi request POST dạng JSON lên server.
  • Chuẩn hóa chuỗi dữ liệu theo format mà API của IoT-Cloud quy định.
  • Tạo task FreeRTOS ghim vào core 1, có mutex khóa không cho POST/GET chạy cùng lúc.
  • Cài app AIoTSemi, tạo tài khoản và thêm mã thiết bị (số MAC) để xem dữ liệu.
  • Kiểm thử API bằng Postman trước khi nạp firmware.
Luồng dữ liệu của bài: cảm biến → ESP32 thu thập (ví dụ qua giao thức I2C) → gửi data lên IoT-Cloud → hiển thị trên app.
  1. Tạo cặp file POSTGET.cpp và POSTGET.h

    Bấm nút New Tab để tạo hai file mới chứa toàn bộ code POST và GET data giữa IoT-Device và IoT-Cloud. Tạo macro mặc định trong POSTGET.h, và gọi #include "POSTGET.h" ở đầu POSTGET.cpp để dùng được các biến/hàm khai báo trong file .h.

  2. Khai báo class POSTGET

    Biến http chỉ dùng nội bộ nên để private; hàm POSTDuLieuBoard được gọi từ bên ngoài nên để public.

  3. Viết hàm POST dữ liệu lên cloud

    Hàm chỉ gửi khi WiFi đang kết nối. Dữ liệu được gói thành chuỗi JSON hai trường: ID là số MAC của board, S là chuỗi dữ liệu đã chuẩn hóa.

    JSON là gì? Là định dạng văn bản dạng {"khóa": "giá trị"} dùng để trao đổi dữ liệu giữa thiết bị và server — đây chính là chuỗi mà dòng String data = … đang tạo ra.
  4. Khai báo thêm hàm gửi dữ liệu trong chương trình chính

    Bổ sung một hàm thực hiện tác vụ (task) gửi dữ liệu lên IoT-Cloud vào file header của chương trình chính.

  5. Hiểu định dạng chuỗi dữ liệu gửi lên cloud

    API của IoT-Cloud nhận một chuỗi duy nhất theo cấu trúc K1K2K3MODERSSI;NhietDo;DoAm;RTC. Ví dụ chuỗi 100077;23.5;82.9;12:26:13 12/04/2024 được đọc như sau:

    BiếnTrườngÝ nghĩaVí dụ
    s00K1Relay 1 đang ON (1) hay OFF (0)1
    s01K2Trạng thái relay 20
    s02K3Trạng thái relay 30
    s03MODE0 = MANUAL (điều khiển bằng tay)0
    s04RSSIĐộ mạnh WiFi tính theo %77
    s1NhietDoNhiệt độ, đơn vị °C23.5
    s2DoAmĐộ ẩm, đơn vị %RH82.9
    s3RTCThời gian thực, format hh:mm:ss DD/MM/YYYY12:26:13 12/04/2024
    Đúng thứ tự: 5 ký tự đầu dính liền nhau không có dấu phân cách; ba trường còn lại ngăn nhau bằng dấu ;. Riêng chuỗi thời gian phải để giờ trước, ngày sau.
  6. Viết hàm POST dữ liệu trong chương trình chính

    Ở bài này board chưa đọc cảm biến thật nên các giá trị được đặt cứng để kiểm tra đường truyền trước. Nhớ khai báo biến toàn cục POSTGET _POSTGET; và thêm #include "POSTGET.h" ở đầu file.

  7. Tạo task gửi dữ liệu chạy trên core riêng

    ESP32 có 2 core, nên tách hẳn việc gửi dữ liệu thành một task riêng để không làm nghẽn chương trình chính. Biến đếm _tickPOSTDuLieu tăng mỗi 1 ms; khi đếm đủ ngưỡng thì gọi hàm POST một lần rồi reset về 0.

    Chu kỳ gửi: mỗi vòng lặp trễ 1 ms, nên ngưỡng 6000 ≈ 6 giây một lần gửi. Muốn 1 phút thì đổi thành 60000.
  8. Khởi tạo mutex và gắn task vào core 1

    Đoạn code khởi tạo được đặt cuối hàm ChayChuongTrinhChinh() để tạo khóa mutex và sinh task gửi dữ liệu.

  9. Cho số liệu thay đổi bằng hàm random

    Nếu để giá trị cố định thì trên app không thấy dữ liệu nhúc nhích. Thay các con số giả lập bằng random() để quan sát app cập nhật theo thời gian thực.

    Lưu ý: hàm random() chỉ trả về số nguyên, và phải chuyển kiểu từ số sang chuỗi bằng cú pháp String(random(N)).
  10. Cài app AIoTSemi và tạo tài khoản

    Tải app AIoTSemi trên App Store hoặc CH Play. Đăng nhập bằng tài khoản quản trị để mở màn hình đăng ký, sau đó tạo tài khoản riêng cho mình.

    TrườngNhập gì
    Tài khoản ADMINUsername AISAdmin · password AISAdmin — chỉ dùng để vào màn hình đăng ký mới một tài khoản
    Tên đăng nhậpTên tài khoản mới muốn tạo
    Mật khẩuMật khẩu của tài khoản mới
    Tên hiển thịTên xuất hiện trên app sau khi đăng nhập
    Số điện thoạiDùng để truy vấn lại khi quên mật khẩu

    Bấm Đăng ký; nếu app báo thành công thì bấm Đăng nhập để vào app luôn.

  11. Thêm mã thiết bị của board vào app

    App chỉ hiển thị dữ liệu của những board đã được khai báo. Trình tự trên app:

    • Từ màn hình chính, bấm nút bánh răng để sang màn hình Cài đặt.
    • Bấm nút + để sang màn hình Quản lý danh sách board.
    • Nhập số MAC của ESP32 (chuỗi in ra ở bài 01) vào ô nhập rồi bấm Thêm.
    • Bấm nút quay lại hai lần để về màn hình chính — thẻ thông tin của board vừa thêm sẽ xuất hiện.
    • Nút bút chì ở màn hình chính cho phép đặt mô tả và tên cho 3 kênh K1…K3.
    Đối chiếu: mã thiết bị trên app phải trùng từng ký tự với chuỗi ID: mà Serial Monitor in ra, ví dụ A0B765D56648.
  12. Kiểm thử API bằng Postman

    Trước khi nghi ngờ firmware, hãy thử bắn dữ liệu thẳng lên server bằng Postman (bản cài đặt hoặc chạy online) để chắc chắn API và mã thiết bị đều đúng.

    Thiết lậpGiá trị
    MethodPOST
    URLhttps://App.AIoTSemi.vn/api/TS3R_DuLieu
    BodyrawJSON
    HeaderContent-Type: application/json
    Chuỗi 111177 nghĩa là K1 = K2 = K3 = 1 (bật cả 3 relay), MODE = 1 và RSSI = 77. Thay ID bằng số MAC board của bạn để thấy dữ liệu chạy vào đúng thẻ thiết bị trên app.
  13. Ghép lại toàn bộ chương trình chính

    Sau bài 02, file ChuongTrinhChinh.cpp có thứ tự như dưới đây. Mở khối này để đối chiếu nếu code của bạn báo lỗi biên dịch.

    Xem trọn vẹn ChuongTrinhChinh.cpp
03Bài thực hành

Lập trình lấy thời gian thực (RTC) từ IoT-Cloud của AIoTSemi

ESP32 không có đồng hồ chạy khi mất điện. Bài này viết class RTC gọi API của IoT-Cloud để xin thời gian thực qua HTTP GET, giúp board luôn biết đúng ngày giờ mà không cần phần cứng đồng hồ.

  • Tạo cặp file RTC.cpp / RTC.h và khai báo class RTC riêng cho phần thời gian.
  • Dùng HTTP GET để lấy chuỗi thời gian từ API /api/ThoiGian của AIoTSemi.
  • Hiểu quy trình chuẩn của HTTPClient: beginGET → kiểm tra mã 200 → getStringend.
  • Gọi hàm lấy RTC bên trong task gửi dữ liệu đã dựng ở bài 02.
  1. Tạo cặp file RTC.cpp và RTC.h

    Bấm nút New Tab tạo hai file mới để viết code lấy thời gian thực. Trong RTC.h tạo macro chống include lặp và khai báo class RTC; đầu RTC.cpp gọi #include "RTC.h".

    Lưu ý: class ở đây tên là RTC (viết hoa) — trùng ý nghĩa nhưng không đụng thư viện nào của ESP32, nên dùng được.
  2. Khai báo hàm lấy RTC từ IoT-Cloud

    Thêm khai báo hàm public trả về bool: true nếu lấy được thời gian, false nếu thất bại.

  3. Viết hàm gọi API lấy thời gian

    Hàm chỉ chạy khi WiFi đang kết nối. Server trả tối đa 512 byte, nên code kiểm tra kích thước payload trước khi đọc chuỗi. Nếu chuỗi trả về có độ dài lớn hơn 0 thì coi như lấy RTC thành công.

    Mã 200 (HTTP_CODE_OK) là tín hiệu server trả dữ liệu thành công. Nếu nhận mã khác, hàm đóng kết nối và trả về false để chương trình chính biết mà xử lý.
  4. Khai báo biến RTC và gọi hàm trong chương trình chính

    Trong ChuongTrinhChinh.cpp, thêm #include "RTC.h", khai báo biến toàn cục RTC _RTC; rồi gọi hàm lấy thời gian ngay trong task gửi dữ liệu đã dựng ở bài 02.

    Kết quả là chuỗi JSON: API trả về thứ, ngày, tháng, năm, giờ, phút, giây gói trong một object JSON. Bài 06 sẽ tách các trường này ra để chỉnh đồng hồ phần cứng.
04Bài thực hành

Lập trình lấy thời gian thực (RTC) từ NTP Server

Ngoài IoT-Cloud của AIoTSemi, board còn có thể lấy giờ chuẩn từ NTP Server công cộng. Bài này thêm hàm thứ hai vào class RTC dùng thư viện NTPClient, tính múi giờ GMT+7 và tách sẵn ngày, tháng, năm, giờ, phút, giây.

  • Cài và dùng hai thư viện NTPClientWiFiUdp để hỏi giờ từ NTP.
  • Cấu hình máy chủ asia.pool.ntp.org và độ lệch múi giờ GMT+7 = 25200 giây.
  • Đổi epoch time (số giây từ 1970) thành ngày/tháng/năm bằng gmtime.
  • Bổ sung hàm này song song với hàm lấy RTC từ IoT-Cloud đã viết ở bài 03.
  1. Khai báo thêm hàm lấy RTC từ NTP

    Mở lại RTC.h, thêm một dòng khai báo hàm mới bên cạnh hàm cũ.

  2. Nhúng thư viện và cấu hình NTP Client

    Ở đầu RTC.cpp thêm hai thư viện, tạo đối tượng UDP và một timeClient trỏ tới máy chủ NTP châu Á với độ lệch múi giờ Việt Nam.

    25200 = 7 × 3600. Đây là số giây chênh giữa giờ Việt Nam (GMT+7) và giờ chuẩn UTC. Đổi máy chủ hay múi giờ khác thì sửa hai tham số này.
  3. Viết hàm lấy và bóc tách thời gian NTP

    getEpochTime() trả về số giây từ năm 1970. Hàm gmtime chuyển số đó thành cấu trúc ngày tháng; nhớ cộng tm_mon + 1tm_year + 1900 để ra tháng và năm đúng.

    Vì sao +1 và +1900? Cấu trúc tm đếm tháng từ 0 và đếm năm kể từ 1900, nên phải cộng bù để ra tháng và năm quen thuộc.
  4. Gọi hàm lấy RTC từ NTP trong chương trình chính

    Trong task ở ChuongTrinhChinh.cpp, thêm dòng gọi hàm NTP ngay cạnh dòng gọi hàm lấy RTC từ IoT-Cloud.

    Chọn nguồn giờ nào? NTP cho giờ chuẩn quốc tế rất chính xác; IoT-Cloud của AIoTSemi tiện khi muốn đồng bộ giờ với server ứng dụng. Bạn có thể bật/tắt từng hàm tùy nhu cầu.
05Bài thực hành

Lập trình lấy thời gian thực từ module RTC DS3231

DS3231 là IC đồng hồ thời gian thực có pin nuôi riêng, vẫn giữ giờ khi board mất điện và không cần mạng. Bài này định nghĩa kiểu dữ liệu ThoiGian, đọc giờ từ DS3231 qua giao thức I2C và lưu vào một biến dùng chung cho cả class.

  • Cài thư viện DS3231 và nhúng vào class RTC.
  • Định nghĩa struct ThoiGian gom 7 trường: thứ, ngày, tháng, năm, giờ, phút, giây.
  • Phân biệt biến private ds (nói chuyện với IC) và biến public dt (chứa kết quả để bên ngoài đọc).
  • Đọc giờ từ DS3231 và cộng 2000 để ra đúng số năm bốn chữ số.
  1. Nhúng thư viện DS3231 và định nghĩa struct ThoiGian

    Mở RTC.h. Thêm thư viện DS3231 (cần cài trước), rồi định nghĩa một kiểu dữ liệu có cấu trúc để gom trọn một mốc thời gian vào một biến.

    struct là gì? Là cách gộp nhiều biến liên quan thành một khối. Thay vì truyền 7 biến rời rạc, ta truyền một biến ThoiGian duy nhất — gọn và dễ đọc.
  2. Bổ sung class RTC với biến ds, dt và hàm mới

    Biến ds để private vì chỉ dùng nội bộ để đọc IC; biến dt để public để chương trình chính đọc được kết quả. Thêm khai báo hàm LayRTCTuDS3231.

  3. Viết hàm đọc giờ từ DS3231

    Kết quả đọc được đổ thẳng vào biến public dt để bên ngoài class lấy ra dùng. IC trả năm dạng hai chữ số nên phải cộng 2000.

    ds và dt khác nhau: ds là "kênh" nói chuyện với chip DS3231; dt là "hộp" đựng kết quả. Hàm đọc từ ds rồi cất vào dt.
  4. Khởi tạo I2C và khai báo chân kết nối

    DS3231 nói chuyện với ESP32 qua giao thức I2C (2 dây SDA và SCL). Trong ChuongTrinhChinh.h nhúng thư viện Wire.h; trong ChuongTrinhChinh.cpp khai báo chân và khởi tạo I2C ở hàm KhoiTao().

    Sơ đồ chân: theo thiết kế phần cứng của board IoT, chân 21 là SDAchân 22 là SCL. Mọi thiết bị I2C (DS3231, SHT3x, PCF8574) đều dùng chung hai dây này.
  5. Gọi hàm và hiển thị giờ từ DS3231

    Trong task ở ChuongTrinhChinh.cpp, gọi hàm rồi in ra Serial. Vì các trường của dt là số, phải ép sang String mới ghép chuỗi để hiển thị được.

    Lưu ý: ban đầu IC DS3231 chứa giờ mặc định của nhà sản xuất (số vô nghĩa như trên). Cần lập trình thiết lập thời gian ban đầu cho DS3231 — chính là nội dung bài 06.
06Bài thực hành

Lập trình cập nhật RTC cho IC DS3231 theo RTC từ IoT-Cloud

Ghép ba nguồn giờ lại với nhau: board xin giờ chuẩn từ IoT-Cloud, so với giờ đang chạy trong DS3231, nếu lệch quá thì tự chỉnh lại đồng hồ phần cứng. Cuối cùng đóng gói giờ thực của board thành chuỗi để gửi lên cloud hiển thị trên app.

  • Dùng thư viện Arduino_JSON để tách chuỗi JSON thời gian từ IoT-Cloud thành các trường.
  • Viết hàm cài đặt giờ cho DS3231 và hàm so sánh sai lệch giữa DS3231 và Cloud.
  • Tự động chỉnh DS3231 khi lệch quá 3 phút hoặc khác ngày/tháng/năm.
  • Chuẩn hóa giờ của board thành chuỗi HH:mm:ss DD/MM/YYYY để POST lên cloud.
  1. Khai báo thêm hai hàm cài đặt và so sánh RTC

    Trong RTC.h, thêm hai khai báo hàm: một hàm ghi giờ xuống DS3231, một hàm so giờ giữa DS3231 và Cloud rồi cập nhật nếu cần.

    Hai vai trò: CaiDatRTCChoDS3231 là hàm ghi giờ xuống chip; SoSanhSaiLechRTCDeCapNhatDS3231 là hàm kiểm tra — nếu DS3231 lệch với Cloud thì mới gọi hàm ghi.
  2. Nhúng thư viện Arduino_JSON

    Chuỗi thời gian mà IoT-Cloud trả về là JSON. Thêm thư viện Arduino_JSON.h (cần cài) vào đầu RTC.cpp để bóc tách chuỗi này.

  3. Bổ sung phần tách JSON vào hàm lấy RTC từ Cloud

    Quay lại hàm LayRTCTuServerAIoTSemi ở bài 03. Ngay sau khi lấy được chuỗi, dùng JSON.parse để tách từng trường rồi đổ vào biến public dt. Kiểu JSONVar nằm trong thư viện Arduino_JSON.

    Biến public dt đóng vai cầu nối: hàm lấy RTC từ Cloud giờ không chỉ in ra màn hình mà còn cất giờ vào dt, để các hàm khác trong class dùng lại — không cần gọi mạng thêm lần nữa.
  4. Viết hàm cài đặt giờ cho DS3231

    Hàm lấy giờ đang chứa trong biến public dt (vừa được nạp từ Cloud) rồi ghi từng trường xuống chip DS3231.

  5. Viết hàm so sánh sai lệch và cập nhật

    Hàm này xin giờ chuẩn từ Cloud (nạp vào dt), đọc giờ đang chạy trong DS3231 (nạp vào biến tạm t), rồi so hai bên. Chênh quá 3 phút hoặc khác ngày/tháng/năm thì gọi hàm cài đặt lại.

    Quy đổi ra phút để so: giờ × 60 + phút gộp giờ và phút thành một số phút duy nhất, giúp so sánh chênh lệch đơn giản hơn.
  6. Gọi hàm kiểm tra trong task chính

    Trong task ở ChuongTrinhChinh.cpp, thay các dòng lấy RTC riêng lẻ bằng lời gọi hàm so sánh — mỗi chu kỳ board tự kiểm tra và chỉnh DS3231 nếu lệch.

    Ý nghĩa: khi chạy, board thấy DS3231 lệch so với Cloud nên tự động ghi lại giờ chuẩn xuống chip. Từ đó board luôn giữ đúng giờ ngay cả khi sau này mất mạng.
  7. Chuẩn hóa chuỗi RTC để gửi về Cloud

    Bài 02 dùng thời gian đặt cứng. Giờ viết hàm biến giờ thực của board thành đúng định dạng HH:mm:ss DD/MM/YYYY mà IoT-Cloud yêu cầu, thêm số 0 ở đầu khi cần cho đủ hai chữ số.

    Vì sao thêm số 0? Giờ 9 phải hiển thị là 09 để định dạng luôn đủ hai chữ số. Nếu không, chuỗi 9:5:3 sẽ sai định dạng và app đọc lỗi.
  8. Dùng giờ thực khi POST dữ liệu lên Cloud

    Bước cuối: trong hàm POSTDuLieuVeCloudDeHienThiTrenAPP() của bài 02, thay dòng đặt cứng thời gian bằng lời gọi hàm chuẩn hóa vừa viết. Từ nay board luôn gửi giờ thực lên cloud.

    Hoàn tất chuỗi RTC: phần cuối chuỗi data giờ là thời gian thực 19:22:45 28/09/2025 lấy trực tiếp từ board, không còn là số đặt cứng. Trên app, thời gian máy chủ và thời gian trên board sẽ khớp nhau.
07Bài thực hành

Lập trình lấy dữ liệu cảm biến SHT31 từ board đưa lên IoT-Cloud để hiển thị trên app

Đến đây board đã biết đúng giờ. Bài này gắn cảm biến nhiệt độ và độ ẩm SHT31 qua I2C, đóng gói thành class SHT3x, xử lý trường hợp cảm biến rớt kết nối, rồi thay số liệu giả lập ở bài 02 bằng số đo thật để app hiển thị nhiệt độ và độ ẩm sống.

  • Cài thư viện Adafruit_SHT31 và tạo cặp file CambienSHT31.cpp / .h.
  • Khai báo class SHT3x với hai biến public NhietDo, DoAm.
  • Khởi tạo cảm biến ở địa chỉ I2C 0x44 và đọc nhiệt độ, độ ẩm.
  • Bắt lỗi NAN khi cảm biến mất kết nối, trả về -1 để app báo cho người dùng biết.
  • Đưa số đo thật vào chuỗi POST thay cho random() ở bài 02.
  1. Tạo cặp file CambienSHT31.cpp và CambienSHT31.h

    Bấm nút New Tab tạo hai file mới. Trong CambienSHT31.h tạo macro chống include lặp; đầu CambienSHT31.cpp gọi #include "CambienSHT31.h".

    Cần cài trước: thư viện Adafruit SHT31 trong Library Manager của Arduino IDE. Thư viện này dùng cho cả dòng SHT30 và SHT31.
  2. Nhúng thư viện và khai báo địa chỉ, chân I2C

    Cảm biến dùng chung hai dây I2C (SDA chân 21, SCL chân 22) đã khởi tạo ở bài 05. Địa chỉ mặc định của SHT31 trên bus I2C là 0x44.

    0x44 là gì? Mỗi thiết bị trên bus I2C có một địa chỉ riêng để ESP32 gọi đúng. SHT31 mặc định là 0x44 (một số module có thể chuyển sang 0x45 bằng cầu nối chân ADDR).
  3. Khai báo class SHT3x

    Biến sht31 (đối tượng của thư viện Adafruit) để private vì chỉ dùng nội bộ; hai biến kết quả NhietDo, DoAm để public để chương trình chính đọc ra.

    Vì sao dùng double? Nhiệt độ và độ ẩm là số thực (ví dụ 28.7 °C), nên phải dùng kiểu số thực để giữ được phần thập phân, không dùng int.
  4. Viết hàm khởi tạo cảm biến

    Hàm gọi sht31.begin() với địa chỉ I2C. Vì bài 05 đã khởi tạo Wire nên ở đây chỉ cần truyền địa chỉ, không cần khai lại chân SDA/SCL.

  5. Viết hàm đọc nhiệt độ và độ ẩm

    Khi cảm biến rớt kết nối, thư viện trả về NAN (không phải số). Hàm kiểm tra bằng isnan(), nếu lỗi thì đặt cả hai giá trị về -1 làm tín hiệu để app hiển thị "không có cảm biến".

    NAN = Not A Number. Đây là giá trị đặc biệt báo phép đọc thất bại. Không so sánh NAN bằng dấu == được — bắt buộc dùng hàm isnan().
  6. Khai báo biến và khởi tạo cảm biến trong chương trình chính

    Trong ChuongTrinhChinh.cpp, thêm #include "CambienSHT31.h", khai báo biến toàn cục SHT3x _SHT3x; rồi gọi hàm khởi tạo trong KhoiTao(), đặt sau lệnh khởi tạo I2C của bài 05.

  7. Đưa số đo thật vào chuỗi POST lên cloud

    Bước cuối: trong POSTDuLieuVeCloudDeHienThiTrenAPP(), gọi hàm đọc cảm biến rồi thay hai giá trị random() của bài 02 bằng nhiệt độ và độ ẩm thật.

    Kiểm tra trên app: mở app AIoTSemi, thẻ thiết bị của bạn giờ hiển thị nhiệt độ và độ ẩm sống (ví dụ 28.7 °C và 72.51 %). Rút cảm biến ra thì cả hai về -1 để báo mất kết nối.
08Bài thực hành

Lập trình điều khiển ON/OFF các relay

Từ đọc cảm biến, board chuyển sang tác động ra thế giới thật: đóng ngắt relay. Bài này đóng gói phần relay thành class Relay, cấu hình chân ra kiểu OUTPUT, viết hàm bật/tắt cả cụm lẫn từng kênh, và chạy thử bằng một task tự đảo trạng thái mỗi 10 giây.

  • Tạo cặp file Relay.cpp / .h và khai báo class Relay với 3 biến trạng thái K1, K2, K3.
  • Cấu hình chân relay là OUTPUT và xuất tín hiệu 1/0 để đóng/ngắt.
  • Viết hàm bật/tắt cả cụm và hàm ON/OFF từng kênh chỉ khi trạng thái thay đổi.
  • Dùng #pragma region để gấp/mở khối code cho dễ đọc.
  • Tạo task điều khiển chạy trên core 1 để kiểm nghiệm relay tự đảo trạng thái mỗi 10 giây.
  1. Tạo cặp file Relay.cpp và Relay.h

    Bấm nút New Tab tạo hai file mới để lập trình điều khiển ON/OFF các relay. Trong Relay.h tạo macro chống include lặp; đầu Relay.cpp gọi #include "Relay.h".

  2. Khai báo chân relay và hằng số ON/OFF

    Ba relay nối vào ba chân GPIO của ESP32 theo thiết kế phần cứng của board. Đặt tên hằng số cho các chân và cho hai mức tín hiệu để code dễ đọc và dễ sửa.

    Lưu ý: chân kết nối relay thay đổi theo thiết kế phần cứng của board IoT. Nên đặt các chân bằng #define cho tường minh — đổi board chỉ cần sửa số ở đây, không phải dò khắp code.
  3. Khai báo class Relay

    Ba biến K1, K2, K3 để public để lưu trạng thái ON/OFF hiện tại của từng relay, cho phần khác của chương trình đọc ra. Khai báo các hàm khởi tạo, bật/tắt cụm và ON/OFF từng kênh.

    Hai nhóm hàm: ONCacRole/OFFCacRole tác động cả 3 relay cùng lúc; ba hàm ONOFFBangTayKx điều khiển riêng từng kênh theo lệnh app gửi xuống (dùng ở bài 10).
  4. Viết hàm khởi tạo chân relay

    Chân điều khiển relay phải đặt chế độ OUTPUT — tức là ESP32 xuất tín hiệu ra chân này để đóng/ngắt relay (khác với chân đọc cảm biến là INPUT).

    OUTPUT nghĩa là gì? Chân được đặt để "phát" tín hiệu ra ngoài. ESP32 gửi mức 1 (bật) hoặc 0 (tắt) tới relay, thay vì "nghe" tín hiệu vào như chân cảm biến.
  5. Viết hàm bật và tắt cả cụm relay

    digitalWrite xuất mức tín hiệu ra chân. Sau khi đóng/ngắt, hàm cập nhật luôn ba biến trạng thái để nơi khác biết relay đang bật hay tắt.

  6. Viết hàm ON/OFF từng kênh (điều khiển bằng tay)

    Hàm cho kênh K1 chỉ tác động khi trạng thái mới khác trạng thái cũ — tránh đóng/ngắt relay liên tục vô ích. Trả về true nếu có thực thi, false nếu không đổi gì. Khối code được bọc bằng #pragma region.

    Copy cho K2, K3: hai hàm ONOFFBangTayK2ONOFFBangTayK3 viết tương tự — chỉ đổi K1 → K2/K3_pinRelay1 → _pinRelay2/_pinRelay3.
    #pragma region là gì? Cú pháp giúp gấp/mở một khối code dài trong trình soạn thảo cho dễ đọc, hoàn toàn không ảnh hưởng đến việc biên dịch chương trình.
  7. Khai báo biến relay trong chương trình chính

    Trong ChuongTrinhChinh.cpp, thêm #include "Relay.h" và khai báo biến toàn cục Relay _Relay;.

  8. Khởi tạo chân relay trong hàm KhoiTao()

    Gọi hàm khởi tạo chân ở đầu hàm KhoiTao(), rồi OFF ngay để đảm bảo các thiết bị đều tắt lúc board vừa cấp nguồn — tránh tình trạng relay bật bất ngờ khi khởi động.

    Vì sao đặt ở đầu? Chân relay phải được cấu hình OUTPUT và tắt ngay khi board mở máy, nếu không relay có thể bật ngẫu nhiên trong lúc chờ các phần khác khởi tạo — nguy hiểm với thiết bị điện thật.
  9. Tạo task kiểm nghiệm relay đảo trạng thái mỗi 10 giây

    Thêm một task chuyên trách điều khiển relay. Biến đếm tăng mỗi 1 ms; đủ 10000 (≈ 10 giây) thì đảo cả cụm: đang bật thì tắt, đang tắt thì bật.

  10. Gắn task vào core 1

    Trong hàm ChayChuongTrinhChinh(), tạo task điều khiển relay và ghim vào core 1, giống cách làm với task POST dữ liệu ở bài 02.

    Quan sát: sau mỗi 10 giây, cả 3 relay chuyển trạng thái ON ↔ OFF, có thể nghe tiếng "tách" của relay. Đây chỉ là task kiểm nghiệm — bài 09 và 10 sẽ thay bằng lệnh điều khiển thật từ cloud và app.
    Phân core về sau: khi có nhiều task, cần cân nhắc chia chúng lên hai core của ESP32 để board chạy tối ưu nhất.
09Bài thực hành

Lập trình lấy lệnh điều khiển từ IoT-Cloud của AIoTSemi gửi xuống board

Đến giờ board mới chỉ gửi dữ liệu lên. Bài này mở chiều ngược lại: board hỏi cloud xem người dùng vừa bấm nút gì trên app. Vì giờ có hai luồng truy cập mạng (gửi lên và lấy về) chạy song song, bài giới thiệu mutex tĩnh để hai task không giẫm chân nhau.

  • Thêm hàm GET lệnh vào class POSTGET, gọi API lấy lệnh điều khiển từ IoT-Cloud.
  • Xử lý chuỗi trả về: loại bỏ ký tự [] để tách JSON.
  • Nâng cấp từ mutex thường sang mutex tĩnh (static) với ba hàm bao an toàn.
  • Viết lại các task theo cơ chế takeMutexSafe / giveMutexSafe.
  • Tạo task lắng nghe lệnh chạy nhanh (100 ms) để phản hồi tức thì khi người dùng bấm nút.
  1. Khai báo hàm GET lệnh trong POSTGET.h

    Mở lại POSTGET.h (bài 02), thêm một hàm trả về String — chính là chuỗi lệnh mà app gửi xuống thông qua cloud.

  2. Viết hàm GET lệnh trong POSTGET.cpp

    Hàm dùng phương thức GET để hỏi API. Chuỗi trả về có dấu [ ở đầu và ] ở cuối (dạng mảng JSON), nên phải cắt bỏ hai ký tự này để còn lại object JSON thuần.

    Vì sao cắt []? Server trả về một mảng JSON dạng [{...}]. Bỏ hai dấu ngoặc vuông ở hai đầu để còn lại đúng một object {...}, giúp bước tách JSON sau này gọn hơn.
  3. Khai báo các hàm mới trong ChuongTrinhChinh.h

    Thêm hàm lắng nghe lệnh và ba hàm quản lý mutex an toàn. Đây là các hàm cần cho lập trình đa nhiệm FreeRTOS để hai luồng POST và GET không xung đột.

    Mutex là gì? Là "chìa khóa" chỉ có một, ai giữ được mới vào vùng dùng chung (ở đây là kết nối mạng). Nhờ vậy task POST và task GET không cùng lúc gọi HTTP, tránh xung đột và reset board.
  4. Chuyển sang mutex tĩnh (static)

    Trong ChuongTrinhChinh.cpp, thay biến mutex cũ bằng mutex tĩnh. Mutex tĩnh dùng vùng nhớ cấp phát sẵn nên không bao giờ bị mất, hiệu suất cao và an toàn hơn mutex thường.

    FreeRTOS có sẵn trong ESP32: không cần cài thêm thư viện, Arduino IDE đã tự nhúng. Các hàm xSemaphore... dùng được ngay.
  5. Viết ba hàm bao mutex an toàn

    Bộ ba hàm này bọc mutex thành một lớp an toàn: tự khởi tạo nếu chưa có, kiểm tra NULL trước khi dùng, tránh lỗi runtime khi task chưa kịp khởi tạo mutex.

    HàmVai trò
    initMutex()Tạo mutex nếu chưa có, dùng buffer cấp sẵn
    takeMutexSafe()Chiếm quyền dùng mutex trong khoảng timeout; trả về true nếu lấy được
    giveMutexSafe()Trả lại quyền sau khi xong; chỉ nhả khi mutex đã tồn tại
  6. Viết lại task POST theo mutex an toàn

    Task gửi dữ liệu giờ phải xin mutex trước khi làm việc, và nhả mutex sau khi xong. Chu kỳ 30 giây/lần để tránh spam cloud; timeout chờ mutex là 3 giây vì việc cập nhật RTC không cần gấp.

    Bắt buộc nhả mutex: sau khi xong tác vụ phải gọi giveMutexSafe(), nếu không task khác sẽ chờ vô hạn. Đây là lỗi kinh điển khi lập trình đa nhiệm.
  7. Tạo task lắng nghe lệnh từ app

    Task này phải chạy nhanh để phản hồi ngay khi người dùng bấm nút. Timeout chờ mutex và chu kỳ lặp đều để 100 ms — bởi lệnh bấm trên app cần xuống board gần như tức thì.

    Hai nhịp khác nhau: task POST 30 giây/lần (gửi định kỳ), task GET 100 ms/lần (phản hồi tức thì). Cùng dùng chung một mutex nên không bao giờ đụng nhau khi gọi mạng.
  8. Viết hàm nội dung lắng nghe lệnh

    Hàm gọi GETLenhGuiXuongBoard để lấy chuỗi lệnh, rồi in ra để kiểm tra. Chuỗi này chứa trạng thái các nút, chế độ làm việc từng kênh và mã lệnh (CODE).

    Mã lệnh 11: khi người dùng bấm nút điều khiển trên app, mã lệnh (ví dụ 11) sẽ đi kèm chuỗi JSON này xuống board. Bài 10 sẽ đọc mã đó để thật sự đóng/ngắt relay.
  9. Khởi tạo mutex đầu tiên và chuyển task xuống cuối KhoiTao()

    Trong KhoiTao(): gọi initMutex() trước tiên để mutex sẵn sàng, rồi chuyển toàn bộ phần tạo task từ ChayChuongTrinhChinh() lên cuối hàm KhoiTao(). Task GET ghim vào core 0, task POST vào core 1.

    Thứ tự quan trọng: mutex phải khởi tạo trước khi tạo bất kỳ task nào, nếu không task chạy lên mà chưa có mutex sẽ gây lỗi. Stack size 8192 = 1024 × 8 — task POST cần nhiều bộ nhớ hơn vì xử lý JSON và HTTP.
  10. Thêm chốt kiểm tra mutex và dọn code cũ

    Trong ChayChuongTrinhChinh(), thêm đoạn kiểm tra định kỳ: nếu mutex vô tình bị mất thì tự khởi tạo lại. Đồng thời gỡ (hoặc comment) khối tạo task cũ vì đã chuyển lên KhoiTao().

    Vì sao kiểm tra trong loop? Đây là lưới an toàn: nếu vì lý do nào đó mutex bị xóa, chương trình tự phục hồi thay vì treo. Với thiết bị IoT chạy 24/7, những chốt phòng ngừa như vậy rất quan trọng.
10Bài thực hành

Lập trình điều khiển ON/OFF relay trên board từ app

Đây là bài khép vòng: bấm nút trên app → cloud gửi lệnh xuống → board đóng/ngắt relay thật → board báo trạng thái mới về cloud để app hiển thị đúng. Bài 08 đã biết bật/tắt relay, bài 09 đã biết lấy lệnh từ cloud. Bài này ghép hai mảnh đó lại: đọc mã CODE trong lệnh, thực thi đúng kênh người dùng chọn, rồi "bắt tay" phản hồi để app và board luôn đồng bộ.

  • Thêm hàm CheDoMANUAL vào class Relay để đóng/ngắt cả 3 kênh theo lệnh tay từ app.
  • Thêm hàm phản hồi mã CODE vào POSTGET để báo cloud biết board đã nhận lệnh.
  • Bóc tách chuỗi JSON lệnh: lấy trạng thái K1/K2/K3 và mã CODE.
  • Viết ThucThiTacVuTheoCODE để đóng/ngắt relay đúng nút bấm rồi phản hồi CODE=0.
  • Đồng bộ hai chiều: relay vừa đổi trạng thái là POST lại ngay để app khớp với thực tế.
  1. Bổ sung hàm CheDoMANUAL vào class Relay

    Mở lại Relay.h (bài 08), thêm một dòng khai báo hàm điều khiển ON/OFF cả 3 relay ở chế độ MANUAL (bằng tay). Hàm này gom ba hàm ONOFFBangTayKx đã viết ở bài 08 thành một lời gọi duy nhất.

    Về sau còn chế độ AUTO: khi cần tự động ON/OFF theo nhiệt độ, độ ẩm hay theo giờ cài đặt, ta chỉ việc khai báo thêm một hàm CheDoAUTO(...) tương tự — cấu trúc class đã sẵn sàng để mở rộng.
  2. Viết hàm CheDoMANUAL trong Relay.cpp

    Hàm gọi lần lượt ba hàm bật/tắt từng kênh, mỗi hàm trả về true nếu kênh đó thật sự đổi trạng thái. Nếu bất kỳ kênh nào đổi thì hàm trả về true — tín hiệu để chương trình chính biết cần cập nhật lại dữ liệu lên cloud.

    Vì sao cần biết "có đổi hay không"? Nếu người dùng bấm nút mà trạng thái vốn đã đúng (đang ON lại bấm ON), relay không cần đổi và cũng không cần POST lại data. Chỉ khi thật sự có thay đổi mới gửi dữ liệu — tiết kiệm băng thông và tránh spam cloud.
  3. Khai báo hàm phản hồi CODE trong POSTGET.h

    Mở lại POSTGET.h (bài 02, 09), thêm hàm gửi mã CODE phản hồi từ board lên cloud. Đây là bước "bắt tay": board báo cho cloud biết nó đã nhận được lệnh, tránh tình trạng lệnh gửi đi mà không rõ board có nhận được hay không.

    "Bắt tay" (handshake): lệnh đi xuống mang CODE=11; board nhận xong sẽ phản hồi CODE=0 lên lại. Cloud thấy CODE về 0 là biết board đã thực thi, coi như hai bên đã bắt tay thành công.
  4. Viết hàm phản hồi CODE trong POSTGET.cpp

    Hàm dùng phương thức POST đẩy lên một chuỗi JSON gồm hai trường: ID (số MAC của board) và S (chuỗi trạng thái kèm mã CODE). API đích do IoT-Cloud của AIoTSemi cung cấp sẵn.

    data là chuỗi JSON: chứa đúng hai thông tin IDS, ví dụ {"ID":"F024F950D8E0","S":"1010000"}. Cloud đọc ID để biết đúng board nào, đọc S để cập nhật trạng thái và mã CODE.
  5. Nhúng thư viện JSON và khai báo 2 hàm mới trong ChuongTrinhChinh.h

    Mở ChuongTrinhChinh.h: thêm thư viện xử lý JSON (đã cài từ bài lấy RTC từ cloud) và khai báo hai hàm — một hàm thực thi tác vụ theo CODE, một hàm phản hồi CODE về cloud.

    Đã cài trước đó: thư viện Arduino_JSON được thêm từ bài lấy thời gian thực từ IoT-Cloud (dùng để bóc tách chuỗi RTC). Nay chương trình chính cũng cần nó để đọc chuỗi lệnh, nên khai báo #include ở file này.
  6. Khai báo mã CODE và các biến lệnh trong ChuongTrinhChinh.cpp

    Mỗi loại thao tác trên app tương ứng một mã CODE. Ở đây quy ước CODEUserChonONOFF = 11: khi người dùng bấm các nút ON/OFF, cloud sẽ gửi mã 11 xuống. Thêm ba biến toàn cục để lưu lệnh ON/OFF của từng kênh.

    Ba biến này giữ trạng thái ON/OFF hiện tại của 3 relay để các hàm khác cùng dùng.
  7. Nâng cấp hàm lắng nghe lệnh để bóc tách CODE

    Đây là bản nâng cấp của LangNgheLenhAppGuiXuongBoard() ở bài 09. Sau khi lấy được chuỗi lệnh, dùng JSON.parse để tách trường S, rồi cắt chuỗi lấy trạng thái từng kênh và mã CODE. Nếu CODE đúng là mã người dùng chọn ON/OFF thì gọi hàm thực thi.

    Chuỗi S mã hóa toàn bộ lệnh theo từng vị trí ký tự. Ví dụ với S = "101011":

    Đoạn cắtÝ nghĩaGiá trị ("101011")
    S.substring(0,1)K11 (ON)
    S.substring(1,2)K20 (OFF)
    S.substring(2,3)K31 (ON)
    vị trí kếMODE0 (MANUAL)
    S.substring(4)CODE11
  8. Viết hàm thực thi tác vụ theo CODE

    Hàm dùng switch để chọn việc theo mã CODE. Với mã ON/OFF: gọi CheDoMANUAL đóng/ngắt relay, phản hồi CODE=0 về cloud, và chỉ khi relay thật sự đổi trạng thái mới POST dữ liệu mới lên để app hiển thị đúng.

    Đồng bộ hai chiều — mấu chốt của bài: ngay khi relay dưới board đổi trạng thái, board lập tức POST data mới lên cloud để nút trên app khớp đúng thực tế. Nhờ vậy tránh được cảnh nút app đang ON nhưng relay dưới board vẫn OFF (và ngược lại).
  9. Viết hàm phản hồi CODE về cloud

    Hàm ghép chuỗi trạng thái ba relay hiện tại với mã CODE rồi gọi hàm POSTGET vừa viết ở bước 4 để đẩy lên cloud. Chuỗi có dạng K1 K2 K3 "0" CODE.

    Ví dụ chuỗi phản hồi: nếu sau lệnh K1=1, K2=0, K3=1 và code="0", chuỗi gửi lên là "101" + "0" + "0" = "10100". Mã CODE cuối bằng 0 chính là tín hiệu "board đã nhận lệnh xong".
  10. Đưa trạng thái relay thật vào chuỗi POST lên cloud

    Bước cuối: quay lại POSTDuLieuVeCloudDeHienThiTrenAPP() (bài 02, 07). Thay ba giá trị đặt cứng "0" của s00/s01/s02 bằng trạng thái thật của ba relay. Từ nay app hiển thị đúng relay nào đang bật, relay nào đang tắt.

    Kiểm tra trên app: mở app AIoTSemi, bấm nút K1 và K3 sang ON — chỉ sau khoảnh khắc, relay dưới board "tách" đóng lại và thẻ thiết bị trên app hiện đúng K1, K3 đang bật, kèm nhiệt độ và độ ẩm sống. Đến đây board đã điều khiển được hai chiều trọn vẹn giữa app ↔ cloud ↔ thiết bị.
    Bước tiếp theo tự nhiên: thêm chế độ AUTO (mã CODE khác) để board tự đóng/ngắt relay theo nhiệt độ, độ ẩm hoặc theo lịch giờ — chỉ cần thêm một case trong ThucThiTacVuTheoCODE và một hàm CheDoAUTO trong class Relay.