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: 11:41:41 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();
  }
}

Đ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.