Công tắc WiFi bật/tắt thiết bị 12–24V bằng điện thoại (hẹn giờ, lịch tự động)

Bật/tắt đèn, quạt, máy bơm chạy điện một chiều 12–24V từ điện thoại qua WiFi. Có hẹn giờ tắt sau N phút và lịch bật/tắt hằng ngày theo giờ Việt Nam (tự lấy giờ qua Internet). Tiếp điểm rơ-le cách ly hoàn toàn với ESP32 nhờ opto.

⏱ 45 phút🔧 Mới học💰 Linh kiện ~128.000đ🧠 ESP32 DevKit V1⚡ Nguồn USB 5V

✅ Đã kiểm thử

Luật điệnKhông lỗiSai chân, chập nguồn, quá áp 5V vào chân 3,3V, thiếu đi-ốt, thiếu trở kéo…
Mô phỏng SPICE2/2 kịch bản · 6 phép đo đạtDòng/áp từng linh kiện ở trường hợp xấu nhất (ngspice)
Biên dịch codeĐạt · dùng 76% bộ nhớesp32:esp32:esp32doit-devkit-v1
Xem chi tiết 6 phép đo

Rơ-le đóng (GPIO26 mức cao)

  • Chân GPIO26 cấp 2.1mA (an toàn ≤ 20.0mA).
  • Tổng dòng đỉnh từ USB 326.7mA (đủ với cục sạc 5V 1A).
  • Dòng chân GPIO26 kích opto (cần ≥1mA để rơ-le đóng chắc): 2.1mA ✓
  • Áp nuôi cuộn rơ-le: 5.00V ✓

Rơ-le nhả (GPIO26 mức thấp)

  • Tổng dòng đỉnh từ USB 324.6mA (đủ với cục sạc 5V 1A).
  • Không còn dòng kích khi tắt: 0.0mA ✓
Kiểm lần cuối: 16:28:57 12/11/2026

Nguyên lý hoạt động

ESP32 chạy trang web nhỏ. Khi bạn bấm Bật, chân GPIO26 lên 3,3V và cấp khoảng 2mA vào LED trong opto của module rơ-le; opto mở transistor trên module để cấp ~75mA cho cuộn rơ-le 5V từ chân VIN. Tiếp điểm COM–NO đóng lại như một công tắc thường, cách ly hoàn toàn với ESP32. Đặt jumper ở H (kích mức cao) để chân 3,3V của ESP32 điều khiển dứt khoát. Giờ được đồng bộ qua Internet (NTP, múi giờ +7) để chạy lịch hằng ngày.

Sơ đồ đấu dây

Mỗi màu là một dây. Chấm tròn là điểm hàn. Chạm vào tên dây trong bảng để làm nổi bật.

5V5VGNDGNDRELAY_INRELAY_INU1 · ESP32 DevKit V1VINGPIO26 (D26)GNDK1 · rơ-le 1 kênh 5V có optoVCCINGND
#DâyNối các điểm
1đỏ
5V
U1 VIN ⟷ K1 VCC
2đen
GND
U1 GND ⟷ K1 GND
3vàng
RELAY_IN
U1 GPIO26 (D26) ⟷ K1 IN

Danh sách linh kiện

Ký hiệuLinh kiệnGiá tham khảo
U1ESP32 DevKit V1 30 chân (ESP32-WROOM-32)110.000đTìm mua
K1Module rơ-le 1 kênh 5V có opto, có jumper chọn kích mức cao/thấp (H/L)18.000đTìm mua
Tổng (chưa gồm board hàn, dây, thiếc)128.000đ

Các bước làm

Code nạp cho mạch

Chọn board DOIT ESP32 DEVKIT V1.

/*
  Mạch Gọn — Công tắc WiFi (rơ-le) có hẹn giờ và lịch hằng ngày
  Board : ESP32 DevKit V1 (chọn "DOIT ESP32 DEVKIT V1")
  Chân  : GPIO26 -> IN module rơ-le 5V (jumper H: kích mức cao)
  Lần đầu: vào WiFi "MachGon-Relay" (mật khẩu machgon123) -> mở 192.168.4.1
*/
#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <Preferences.h>
#include <time.h>

// ---- Cấu hình ----
const uint8_t PIN_RELAY = 26;
const uint8_t PIN_LED   = 2;
const bool    RELAY_ACTIVE_HIGH = true;    // jumper H
const char*   AP_SSID   = "MachGon-Relay";
const char*   HOSTNAME  = "congtac";
const long    TZ_OFFSET = 7 * 3600;        // giờ Việt Nam
const int     MAX_SCHED = 6;

struct Sched { uint8_t en, h, m, on, days; };   // days: bit0=CN ... bit6=T7
Sched sched[MAX_SCHED];
bool relayOn = false;
uint32_t offAtMs = 0;           // 0 = không hẹn giờ
int lastMinuteRun = -1;
WebServer server(80);
Preferences cfg;

void setRelay(bool on, const char* why) {
  relayOn = on;
  digitalWrite(PIN_RELAY, (on == RELAY_ACTIVE_HIGH) ? HIGH : LOW);
  digitalWrite(PIN_LED, on ? HIGH : LOW);
  cfg.putBool("state", on);
  Serial.printf("Rơ-le %s (%s)\n", on ? "BẬT" : "TẮT", why);
}

bool timeOk(struct tm& t) { return getLocalTime(&t, 0) && t.tm_year > 120; }

void loadSched() {
  memset(sched, 0, sizeof(sched));
  cfg.getBytes("sched", sched, sizeof(sched));
}

const char PAGE[] PROGMEM = R"HTML(<!doctype html><html lang="vi"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Công tắc WiFi</title>
<style>
:root{--b:#0f172a;--c:#1e293b;--a:#22c55e;--t:#e2e8f0;--m:#94a3b8}*{box-sizing:border-box}
body{margin:0;font-family:system-ui,sans-serif;background:var(--b);color:var(--t)}
header{padding:12px 14px;background:var(--c);display:flex;justify-content:space-between}small{color:var(--m)}
.big{display:block;margin:22px auto;width:180px;height:180px;border-radius:50%;border:0;font-size:26px;font-weight:700;background:#334155;color:var(--t)}
.big.on{background:var(--a);color:#052e16;box-shadow:0 0 40px #22c55e88}
section{margin:14px;padding:12px;background:var(--c);border-radius:12px}h3{margin:0 0 8px;font-size:16px}
input,select{font:inherit;padding:8px;border-radius:8px;border:1px solid #475569;background:#0b1220;color:var(--t)}
button{font:inherit;border:0;border-radius:8px;padding:8px 12px;background:#334155;color:var(--t)}button.p{background:var(--a);color:#052e16;font-weight:600}
.row{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin:6px 0}.d{display:flex;gap:2px}.d label{font-size:12px}
</style></head><body>
<header><b>🔌 Công tắc WiFi</b><small id="clk">…</small></header>
<button class="big" id="big">…</button>
<section><h3>Hẹn giờ tắt</h3><div class="row">
<button onclick="tmr(15)">15 phút</button><button onclick="tmr(30)">30 phút</button><button onclick="tmr(60)">1 giờ</button><button onclick="tmr(120)">2 giờ</button><button onclick="tmr(0)">Hủy</button></div><small id="tm"></small></section>
<section><h3>Lịch hằng ngày</h3><div id="sch"></div><button class="p" onclick="saveS()">Lưu lịch</button> <small id="ss"></small></section>
<section><h3>Cài đặt WiFi nhà</h3><small id="wst"></small><div class="row"><input id="ssid" placeholder="Tên WiFi"><input id="pass" type="password" placeholder="Mật khẩu"></div>
<button class="p" onclick="wifi()">Lưu & khởi động lại</button></section>
<script>
const $=s=>document.querySelector(s),D=['CN','T2','T3','T4','T5','T6','T7'];
async function api(p,o){const r=await fetch(p,o);if(!r.ok)throw new Error(await r.text());return r.json()}
function draw(s){$('#big').textContent=s.on?'ĐANG BẬT':'ĐANG TẮT';$('#big').className='big'+(s.on?' on':'');
 $('#clk').textContent=s.time?('Giờ: '+s.time):'Giờ: chưa đồng bộ';$('#tm').textContent=s.offIn>0?('Sẽ tự tắt sau '+Math.ceil(s.offIn/60)+' phút'):'';
 $('#wst').textContent=s.sta?('Đã nối "'+s.ssid+'" — IP '+s.ip+' (hoặc http://congtac.local)'):'Chưa nối WiFi nhà (lịch cần Internet để lấy giờ)'}
async function st(){try{draw(await api('/api/state'))}catch(e){}}
$('#big').onclick=async()=>draw(await api('/api/toggle',{method:'POST'}));
async function tmr(m){draw(await api('/api/timer?min='+m,{method:'POST'}))}
function rowS(x,i){return `<div class="row"><input type="checkbox" id="e${i}" ${x.en?'checked':''}><input type="time" id="t${i}" value="${String(x.h).padStart(2,'0')}:${String(x.m).padStart(2,'0')}">
<select id="o${i}"><option value="1" ${x.on?'selected':''}>Bật</option><option value="0" ${x.on?'':'selected'}>Tắt</option></select>
<span class="d">${D.map((d,j)=>`<label><input type="checkbox" id="d${i}_${j}" ${(x.days>>j)&1?'checked':''}>${d}</label>`).join('')}</span></div>`}
async function loadS(){const s=await api('/api/sched');$('#sch').innerHTML=s.map(rowS).join('')}
async function saveS(){const a=[];for(let i=0;i<6;i++){const[h,m]=($('#t'+i).value||'00:00').split(':');let d=0;for(let j=0;j<7;j++)if($('#d'+i+'_'+j).checked)d|=1<<j;
 a.push([$('#e'+i).checked?1:0,+h,+m,+$('#o'+i).value,d].join(','))}
 await api('/api/sched',{method:'POST',body:a.join(';')});$('#ss').textContent='✅ Đã lưu'}
async function wifi(){await fetch('/api/wifi',{method:'POST',body:new URLSearchParams({ssid:$('#ssid').value,pass:$('#pass').value})});alert('Đã lưu, mạch khởi động lại. Nối lại WiFi nhà rồi mở http://congtac.local')}
st();loadS();setInterval(st,3000);
</script></body></html>)HTML";

String stateJson() {
  struct tm t; char buf[24] = "";
  bool ok = timeOk(t);
  if (ok) strftime(buf, sizeof(buf), "%H:%M %d/%m", &t);
  long offIn = offAtMs ? (long)(offAtMs - millis()) / 1000 : 0;
  bool sta = WiFi.status() == WL_CONNECTED;
  return String("{\"on\":") + (relayOn ? "true" : "false") + ",\"time\":\"" + buf + "\",\"offIn\":" + offIn +
         ",\"sta\":" + (sta ? "true" : "false") + ",\"ip\":\"" + (sta ? WiFi.localIP().toString() : WiFi.softAPIP().toString()) +
         "\",\"ssid\":\"" + cfg.getString("ssid", "") + "\"}";
}

void setupWeb() {
  server.on("/", []() { server.send_P(200, "text/html; charset=utf-8", PAGE); });
  server.on("/api/state", HTTP_GET, []() { server.send(200, "application/json", stateJson()); });
  server.on("/api/toggle", HTTP_POST, []() { setRelay(!relayOn, "web"); if (!relayOn) offAtMs = 0; server.send(200, "application/json", stateJson()); });
  server.on("/api/set", []() { setRelay(server.arg("on") == "1", "api"); server.send(200, "application/json", stateJson()); });
  server.on("/api/timer", HTTP_POST, []() {
    long m = server.arg("min").toInt();
    if (m <= 0 || m > 24 * 60) offAtMs = 0;
    else { offAtMs = millis() + (uint32_t)m * 60000UL; if (offAtMs == 0) offAtMs = 1; if (!relayOn) setRelay(true, "hẹn giờ"); }
    server.send(200, "application/json", stateJson());
  });
  server.on("/api/sched", HTTP_GET, []() {
    String j = "[";
    for (int i = 0; i < MAX_SCHED; i++) {
      if (i) j += ',';
      j += "{\"en\":" + String(sched[i].en) + ",\"h\":" + sched[i].h + ",\"m\":" + sched[i].m + ",\"on\":" + sched[i].on + ",\"days\":" + sched[i].days + "}";
    }
    server.send(200, "application/json", j + "]");
  });
  server.on("/api/sched", HTTP_POST, []() {
    String b = server.arg("plain");
    Sched tmp[MAX_SCHED]; memset(tmp, 0, sizeof(tmp));
    int i = 0, start = 0;
    while (i < MAX_SCHED && start < (int)b.length()) {
      int end = b.indexOf(';', start); if (end < 0) end = b.length();
      int v[5] = {0}; int k = 0, s2 = start;
      while (k < 5 && s2 <= end) { int c = b.indexOf(',', s2); if (c < 0 || c > end) c = end; v[k++] = b.substring(s2, c).toInt(); s2 = c + 1; }
      tmp[i] = { (uint8_t)(v[0] ? 1 : 0), (uint8_t)constrain(v[1], 0, 23), (uint8_t)constrain(v[2], 0, 59), (uint8_t)(v[3] ? 1 : 0), (uint8_t)(v[4] & 0x7F) };
      i++; start = end + 1;
    }
    memcpy(sched, tmp, sizeof(sched));
    cfg.putBytes("sched", sched, sizeof(sched));
    server.send(200, "application/json", "{\"ok\":true}");
  });
  server.on("/api/wifi", HTTP_POST, []() {
    cfg.putString("ssid", server.arg("ssid")); cfg.putString("pass", server.arg("pass"));
    server.send(200, "application/json", "{\"ok\":true}"); delay(500); ESP.restart();
  });
  server.onNotFound([]() { server.sendHeader("Location", "/"); server.send(302); });
  server.begin();
}

void setup() {
  Serial.begin(115200);
  pinMode(PIN_RELAY, OUTPUT);
  digitalWrite(PIN_RELAY, RELAY_ACTIVE_HIGH ? LOW : HIGH);   // luôn tắt lúc khởi động
  pinMode(PIN_LED, OUTPUT);
  cfg.begin("cfg", false);
  loadSched();
  setRelay(cfg.getBool("state", false) && cfg.getBool("restore", true), "khôi phục sau mất điện");

  WiFi.mode(WIFI_AP_STA);
  WiFi.softAP(AP_SSID, cfg.getString("appass", "machgon123").c_str());
  String ssid = cfg.getString("ssid", "");
  if (ssid.length()) {
    WiFi.begin(ssid.c_str(), cfg.getString("pass", "").c_str());
    uint32_t t = millis();
    while (WiFi.status() != WL_CONNECTED && millis() - t < 15000) delay(250);
  }
  configTime(TZ_OFFSET, 0, "pool.ntp.org", "time.google.com");
  if (MDNS.begin(HOSTNAME)) MDNS.addService("http", "tcp", 80);
  setupWeb();
  Serial.printf("San sang. AP %s IP %s\n", AP_SSID, WiFi.softAPIP().toString().c_str());
}

void loop() {
  server.handleClient();
  if (offAtMs && (int32_t)(millis() - offAtMs) >= 0) { offAtMs = 0; setRelay(false, "hết giờ hẹn"); }

  static uint32_t lastTick = 0;
  if (millis() - lastTick > 1000) {
    lastTick = millis();
    struct tm t;
    if (timeOk(t)) {
      int minuteKey = t.tm_yday * 1440 + t.tm_hour * 60 + t.tm_min;
      if (minuteKey != lastMinuteRun) {
        lastMinuteRun = minuteKey;
        for (int i = 0; i < MAX_SCHED; i++) {
          const Sched& s = sched[i];
          if (s.en && s.h == t.tm_hour && s.m == t.tm_min && ((s.days >> t.tm_wday) & 1)) setRelay(s.on, "lịch");
        }
      }
    }
  }
  static uint32_t lastCheck = 0;
  if (millis() - lastCheck > 30000) {
    lastCheck = millis();
    if (cfg.getString("ssid", "").length() && WiFi.status() != WL_CONNECTED) WiFi.reconnect();
  }
}

🔌 Cắm thử trên breadboard

Cắm nháp trên board cắm trước cho chắc, chạy đúng rồi hãy làm mạch in. Sơ đồ này do máy tự xếp từ chính netlist ở trên và đã đối chiếu lại: hai chân cùng một cột (cùng nửa board) là thông nhau, không có net nào bị chập.

+−+−55101015152020JIHGFEDCBAU1 · ESP32 DevKit V1 (30 chân)26GNDVINK1
  • Mỗi cột 5 lỗ trong cùng một nửa board thông nhau; hai nửa cách nhau bởi rãnh giữa.
  • Hai đường kẻ dọc mép board là đường nguồn: hàng + (vạch đỏ) và hàng − (vạch xanh). Nhớ bắc dây nối đường nguồn trên với đường nguồn dưới nếu dùng cả hai nửa.
  • Màu dây trên hình trùng với màu trong bảng nối dây ở mục Sơ đồ, cắm theo màu cho dễ dò.

🧲 Mạch in để ủi (PCB 1 mặt)

Bản mạch in một mặt đồng, tự xếp linh kiện và tự đi dây, đã kiểm quy tắc khoảng cách và đối chiếu đúng 100% với sơ đồ nguyên lý ở trên. In tỉ lệ 1:1 rồi ủi lên tấm đồng.

Kích thước board55,9×55,9 mm33 lỗ khoan · mũi 1mm
Đi dây 1 mặt3 đoạn · 25,4 mmKhông cần cầu nhảy nào
Kiểm tra bản mạchĐạt DRC · khớp 100% netlistĐường ≥0,8mm, khe ≥0,5mm; mọi net đối chiếu lại từ hình học thật
Mặt đồng — đã lật gương sẵn để in rồi ủi úp xuống tấm đồng
Mặt linh kiện — vị trí, chiều cắm và tên từng chân
U1K1EN363934353233252627141213GNDVIN232213211918517164215GND23V3VCCGNDIN
Hướng dẫn ủi – ăn mòn – khoan – hàn (đầy đủ)

Hướng dẫn làm mạch in bằng phương pháp ủi

Mạch: Công tắc WiFi bật/tắt thiết bị 12–24V bằng điện thoại (hẹn giờ, lịch tự động)

Kích thước board: 55,9×55,9 mm · 33 lỗ khoan (mũi 1 mm) · 3 đoạn mạch, tổng 25,4 mm · 0 cầu nhảy.

Quy tắc đã kiểm: đường mạch rộng 0,8mm (nguồn và GND 1mm), khe cách tối thiểu 0,5mm. Mạch in đã được máy đối chiếu đúng 100% với sơ đồ nguyên lý.

1. In đúng tỉ lệ 1:1

  • In trang "MẶT ĐỒNG" bằng máy in LASER, đặt tỉ lệ 100%, KHÔNG chọn "Fit to page" hay "Scale to fit".
  • Dùng thước trên bản in đo lại đoạn 100mm: phải đúng 100mm mới dùng được.
  • In trên giấy in ảnh bóng, giấy decal bóc, hoặc giấy tạp chí láng. Mực càng dày càng tốt.
  • Trang "MẶT ĐỒNG" đã được LẬT GƯƠNG sẵn: úp mặt có mực vào tấm đồng là đúng chiều.

2. Chuẩn bị tấm đồng

  • Cắt phíp đồng (board đồng một mặt) lớn hơn kích thước mạch mỗi bề khoảng 5mm.
  • Chà mặt đồng bằng giấy nhám mịn (P800–P1200) rồi rửa sạch, lau khô. Mặt đồng phải sáng, không vết dầu mỡ, không chạm tay vào.

3. Ủi mực sang đồng

  • Úp mặt có mực vào tấm đồng, dùng băng keo giấy dán cố định một cạnh.
  • Bàn ủi để mức nóng nhất (cotton/linen), KHÔNG dùng chế độ phun hơi nước.
  • Ủi đè mạnh và đều 3–5 phút, tập trung từng vùng nhỏ, chú ý các góc và mép.
  • Để nguội hẳn rồi ngâm cả tấm vào nước ấm 5–10 phút, bóc giấy thật nhẹ. Giấy decal thì bóc khi còn ấm.
  • Soi kỹ: chỗ nào mực bị thiếu hoặc đứt đường thì tô lại bằng bút lông dầu (bút CD) rồi để khô.

4. Ăn mòn (etching)

  • Pha dung dịch sắt(III) clorua FeCl3 theo hướng dẫn trên bao bì, hoặc dung dịch oxy già + muối ăn + a-xít; dùng hộp nhựa, KHÔNG dùng đồ kim loại.
  • Nhiệt độ dung dịch khoảng 40–50°C thì ăn nhanh hơn. Lắc nhẹ hộp cho đều, thường 10–25 phút là xong.
  • An toàn: đeo găng tay cao su và kính bảo hộ, làm ở nơi thoáng. Dung dịch ăn mòn làm hỏng quần áo, bồn rửa và gây bỏng rát da.
  • Xong thì rửa board bằng nước sạch, lau khô, dùng xăng thơm hoặc axeton lau sạch lớp mực.
  • Dung dịch đã dùng KHÔNG đổ xuống bồn rửa hay cống; đựng vào can kín và bỏ đúng nơi thu gom chất thải.

5. Khoan lỗ

  • Đối chiếu trang "BẢN VẼ LỖ KHOAN" để biết lỗ nào dùng mũi nào.
  • Dùng mũi khoan 1mm, khoan từ MẶT ĐỒNG vào để mũi tự định tâm giữa pad.
  • Khoan tốc độ cao, đẩy nhẹ; mũi nhỏ rất dễ gãy. Khoan xong chà nhẹ bavia hai mặt.

6. Hàn linh kiện

  • Linh kiện cắm ở MẶT KHÔNG CÓ ĐỒNG, hàn ở mặt đồng. Theo trang "MẶT LINH KIỆN" để đặt đúng vị trí và đúng chiều.
  • Hàn linh kiện thấp trước (điện trở, đi-ốt), cao sau (tụ, hàng chân, board vi điều khiển).
  • Chú ý chiều: LED chân dài là (+); đi-ốt có vạch trắng là K; tụ hoá có vạch là (−); transistor theo mặt chữ như nét vẽ đỏ.
  • Trước khi cấp điện: đo thông mạch giữa chân nguồn và GND — KHÔNG được kêu (không được chập).

Lưu ý với board vi điều khiển

  • Board nhái có thể khác thứ tự chân. Đối chiếu tên chân in cạnh từng lỗ trên trang "MẶT LINH KIỆN" với chữ in trên board thật TRƯỚC KHI ăn mòn. Nếu khác, đừng hàn mà báo lại để sửa bản vẽ.
Bản vẽ lỗ khoan
BẢN VẼ LỖ KHOAN (nhìn từ mặt linh kiện)mũi khoan 1mm — 33 lỗ

Đo kiểm bằng đồng hồ vạn năng

Giữa VCC và GND của module rơ-le4,6 – 5,1V
Giữa IN và GND khi bấm Bậtkhoảng 3,2V; khi Tắt: 0V
Thang thông mạch giữa COM và NO (rút điện thiết bị)Bật: kêu (thông); Tắt: không kêu

Sửa lỗi thường gặp

Bấm Bật mà rơ-le không kêu

Kiểm tra jumper đã ở H chưa; đo IN có 3,2V không; đảm bảo VCC module nối VIN (5V), không phải 3V3.

Rơ-le luôn đóng, bấm Tắt không nhả

Jumper đang ở L (kích mức thấp). Chuyển sang H.

Lịch không chạy đúng giờ

Mạch phải nối WiFi nhà có Internet để lấy giờ. Trang web hiện "Giờ: chưa đồng bộ" là chưa có Internet.

ESP32 khởi động lại khi rơ-le đóng

Cục sạc/cáp yếu. Dùng cục sạc 5V 1A trở lên, cáp ngắn.

⚠️ An toàn

  • Hướng dẫn này chỉ dành cho thiết bị điện một chiều 12–24V (đèn LED 12V, quạt 12V, bơm 12V…).
  • Nếu muốn bật tắt thiết bị điện 220V: phần đấu nối 220V bắt buộc nhờ thợ điện có chuyên môn thực hiện, có hộp nhựa cách điện và cầu chì/aptomat; hoặc dùng ổ cắm thông minh có chứng nhận an toàn. Không tự đấu điện lưới.
  • Phía ESP32 chỉ có 5V, an toàn khi chạm tay.