Chuông cửa WiFi: kêu còi và ghi lại lịch sử bấm chuông xem trên điện thoại

Nút bấm chuông ngoài cổng làm còi trong nhà kêu, đèn nháy, đồng thời lưu lại giờ bấm (50 lần gần nhất) để xem trên điện thoại qua WiFi. Có chống bấm liên tục và chế độ im lặng (chỉ ghi lịch sử, không kêu) cho giờ ngủ.

⏱ 50 phút🔧 Mới học💰 Linh kiện ~129.200đ🧠 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 · 11 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 11 phép đo

Có người bấm chuông: còi kêu, đèn sáng

  • Q1: dẫn 29.2mA, Vce=0.04V (bão hòa tốt).
  • D1: dòng 5.9mA (cho phép 20.0mA).
  • Chân GPIO18 cấp 2.4mA (an toàn ≤ 20.0mA).
  • Chân GPIO19 cấp 5.9mA (an toàn ≤ 20.0mA).
  • Tổng dòng đỉnh từ USB 287.6mA (đủ với cục sạc 5V 1A).
  • Dòng qua còi: 29.2mA ✓
  • Dòng LED báo: 5.9mA ✓
  • Chân nút khi bấm (mức thấp): 0.00V ✓

Chờ (không ai bấm)

  • Tổng dòng đỉnh từ USB 250.0mA (đủ với cục sạc 5V 1A).
  • Chân nút được trở kéo lên giữ mức cao: 3.28V ✓
  • Còi im: 0.0mA ✓
Kiểm lần cuối: 17:09:17 12/11/2026

Nguyên lý hoạt động

Chân GPIO14 bật trở kéo lên bên trong (~45kΩ) nên bình thường ở 3,3V; khi bấm nút, chân bị kéo xuống 0V. Code lọc dội phím 40ms để một lần bấm chỉ tính một lần, và khóa 3 giây chống bấm liên tục. Khi có chuông, GPIO18 mở transistor S8050 (dòng gốc ~2,5mA qua trở 1kΩ) để cấp ~30mA cho còi 5V; GPIO19 nháy LED đỏ qua trở 220Ω (~5mA). Giờ bấm lấy từ Internet (múi giờ +7) và lưu 50 lần gần nhất trong bộ nhớ, không mất khi mất điện.

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.

⚡ Mô phỏng tương tácBấm thử — mạch được giải ngay trên máy bạn, điện áp và dòng hiện lên sơ đồ bên dưới.
Tình huống mẫu
Chân GPIO18điều khiển R1
Chân GPIO19điều khiển R2
Nút SW1

Đây là mô phỏng một chiều (DC) ở trạng thái tĩnh: dùng để kiểm tra mức áp, dòng và công suất. Hiệu ứng nhấp nháy, PWM hay giao tiếp số do code quyết định nên không chạy ở đây.

5V5VGNDGNDBTNBTNBZ_DRVBZ_DRVBZ_BASEBZ_BASEBZ_NEGBZ_NEGLED_DRVLED_DRVLED_ALED_AU1 · ESP32 DevKit V1VINGPIO14 (D14)GPIO18 (D18)GPIO19 (D19)GNDSW1 · Nút nhấn12R1 · Điện trở 1kΩ12Q1 · Transistor NPN S8050B (gốc)C (góp)E (phát)BZ1 · Còi chip chủ động 5V-+R2 · Điện trở 220Ω12D1 · Đèn LED 5mmA · chân dài (+)K · chân ngắn (−)
#DâyNối các điểmÁp mô phỏng
1đỏ
5V
U1 VIN ⟷ BZ1 +—
2đen
GND
U1 GND ⟷ SW1 2 ⟷ Q1 E (phát) ⟷ D1 K · chân ngắn (−)—
3vàng
BTN
U1 GPIO14 (D14) ⟷ SW1 1—
4xanh lá
BZ_DRV
U1 GPIO18 (D18) ⟷ R1 1—
5xanh dương
BZ_BASE
R1 2 ⟷ Q1 B (gốc)—
6tím
BZ_NEG
BZ1 - ⟷ Q1 C (góp)—
7xanh ngọc
LED_DRV
U1 GPIO19 (D19) ⟷ R2 1—
8nâu
LED_A
R2 2 ⟷ D1 A · chân dài (+)—

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
SW1Nút nhấn chuông cửa loại thường hở (hoặc nút nhấn 12mm chống nước)15.000đTìm mua
Q1Transistor S8050 (TO-92)500đTìm mua
R1Điện trở 1kΩ 1/4W (nâu-đen-đỏ)200đTìm mua
BZ1Còi chip chủ động 5V (loại có mạch, cấp điện là kêu; có chân dài +)3.000đTìm mua
D1LED đỏ 5mm (red)300đTìm mua
R2Điện trở 220Ω 1/4W (đỏ-đỏ-nâu)200đTìm mua
Tổng (chưa gồm board hàn, dây, thiếc)129.200đ

Các bước làm

Code nạp cho mạch

Chọn board DOIT ESP32 DEVKIT V1.

/*
  Mạch Gọn — Chuông cửa WiFi có lịch sử bấm chuông
  Board : ESP32 DevKit V1 (chọn "DOIT ESP32 DEVKIT V1")
  Chân  : GPIO14 <- nút chuông (về GND, INPUT_PULLUP)
          GPIO18 -> R1k -> B S8050 (còi 5V ở cực C)
          GPIO19 -> R220 -> LED đỏ
  Lần đầu: vào WiFi "MachGon-Chuong" (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_BTN    = 14;
const uint8_t  PIN_BUZZER = 18;
const uint8_t  PIN_LED    = 19;
const uint32_t DEBOUNCE_MS = 40;
const uint32_t LOCKOUT_MS  = 3000;
const int      HISTORY     = 50;
const char*    AP_SSID  = "MachGon-Chuong";
const char*    HOSTNAME = "chuong";
const long     TZ_OFFSET = 7 * 3600;

// Mẫu chuông: thời gian (ms) xen kẽ kêu / im
const uint16_t PATTERN[] = {250, 120, 250, 120, 600};
const int      PATTERN_N = sizeof(PATTERN) / sizeof(PATTERN[0]);

WebServer server(80);
Preferences cfg;
uint32_t hist[HISTORY];      // epoch (giây); 0 = trống; giá trị nhỏ = chưa có giờ (đếm theo uptime)
int histHead = 0, histCount = 0;
bool silent = false;
uint32_t lastRing = 0, total = 0;
int patStep = -1; uint32_t patAt = 0;

void loadHistory() {
  memset(hist, 0, sizeof(hist));
  cfg.getBytes("hist", hist, sizeof(hist));
  histHead = cfg.getInt("head", 0) % HISTORY; histCount = constrain((int)cfg.getInt("cnt", 0), 0, HISTORY);
  total = cfg.getUInt("total", 0);
}

void addHistory() {
  time_t now = time(nullptr);
  hist[histHead] = now > 1700000000 ? (uint32_t)now : 1;   // 1 = chưa rõ giờ
  histHead = (histHead + 1) % HISTORY;
  if (histCount < HISTORY) histCount++;
  total++;
  cfg.putBytes("hist", hist, sizeof(hist)); cfg.putInt("head", histHead); cfg.putInt("cnt", histCount); cfg.putUInt("total", total);
}

void ring() {
  addHistory();
  Serial.println("Co nguoi bam chuong!");
  patStep = 0; patAt = millis();
  digitalWrite(PIN_BUZZER, silent ? LOW : HIGH);
  digitalWrite(PIN_LED, HIGH);
}

void runPattern() {
  if (patStep < 0) return;
  if (millis() - patAt < PATTERN[patStep]) return;
  patAt = millis(); patStep++;
  if (patStep >= PATTERN_N) { patStep = -1; digitalWrite(PIN_BUZZER, LOW); digitalWrite(PIN_LED, LOW); return; }
  bool on = (patStep % 2) == 0;
  digitalWrite(PIN_BUZZER, on && !silent ? HIGH : LOW);
  digitalWrite(PIN_LED, on ? HIGH : LOW);
}

void pollButton() {
  static bool stable = HIGH, lastRead = HIGH; static uint32_t changedAt = 0;
  bool r = digitalRead(PIN_BTN);
  if (r != lastRead) { lastRead = r; changedAt = millis(); }
  if (millis() - changedAt >= DEBOUNCE_MS && r != stable) {
    stable = r;
    if (stable == LOW && millis() - lastRing > LOCKOUT_MS) { lastRing = millis(); ring(); }
  }
}

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>Chuông cửa</title>
<style>
:root{--b:#0f172a;--c:#1e293b;--a:#38bdf8;--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;align-items:center}small{color:var(--m)}
section{margin:14px;padding:14px;background:var(--c);border-radius:12px}h3{margin:0 0 10px;font-size:16px}
.last{font-size:22px;font-weight:700}.new{animation:f 1s 3}@keyframes f{50%{background:#0369a1}}
ul{list-style:none;padding:0;margin:0}li{padding:9px 0;border-bottom:1px solid #334155;display:flex;justify-content:space-between}
button{font:inherit;border:0;border-radius:8px;padding:9px 14px;background:#334155;color:var(--t)}button.on{background:#f59e0b;color:#111}
input{width:100%;font:inherit;padding:9px;margin:4px 0 8px;border-radius:8px;border:1px solid #475569;background:#0b1220;color:var(--t)}.s{background:var(--a);color:#082f49;font-weight:600}
</style></head><body>
<header><b>🔔 Chuông cửa</b><small id="clk"></small></header>
<section id="top"><small>Lần bấm gần nhất</small><div class="last" id="last">—</div><small id="tot"></small></section>
<section><button id="sil">…</button> <button onclick="test()">Thử chuông</button></section>
<section><h3>Lịch sử</h3><ul id="list"></ul></section>
<section><h3>Cài đặt WiFi nhà</h3><small id="wst"></small><input id="ssid" placeholder="Tên WiFi"><input id="pass" type="password" placeholder="Mật khẩu"><button class="s" onclick="wifi()">Lưu & khởi động lại</button></section>
<script>
const $=s=>document.querySelector(s);let lastTot=-1,S={};
const fmt=t=>t<=1?'chưa rõ giờ':new Date(t*1000).toLocaleString('vi-VN',{timeZone:'Asia/Ho_Chi_Minh',hour:'2-digit',minute:'2-digit',second:'2-digit',day:'2-digit',month:'2-digit'});
async function api(p,o){const r=await fetch(p,o);return r.json()}
async function load(){try{const s=await api('/api/state');S=s;
 $('#last').textContent=s.hist.length?fmt(s.hist[0]):'Chưa có ai bấm';$('#tot').textContent='Tổng: '+s.total+' lần';
 $('#list').innerHTML=s.hist.map((t,i)=>`<li><span>${fmt(t)}</span><small>#${s.total-i}</small></li>`).join('');
 $('#sil').textContent=s.silent?'🔕 Đang im lặng (bấm để bật còi)':'🔔 Còi đang bật (bấm để im lặng)';$('#sil').className=s.silent?'on':'';
 $('#clk').textContent=s.time||'chưa có giờ';$('#wst').textContent=s.sta?('Đã nối "'+s.ssid+'" — IP '+s.ip+' (hoặc http://chuong.local)'):'Chưa nối WiFi nhà';
 if(lastTot>=0&&s.total>lastTot){$('#top').classList.add('new');navigator.vibrate&&navigator.vibrate([200,100,200]);setTimeout(()=>$('#top').classList.remove('new'),3000)}lastTot=s.total}catch(e){}}
$('#sil').onclick=async()=>{await api('/api/silent?on='+(S.silent?0:1),{method:'POST'});load()};
async function test(){await api('/api/test',{method:'POST'});load()}
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://chuong.local')}
load();setInterval(load,3000);
</script></body></html>)HTML";

String stateJson() {
  String h = "[";
  for (int i = 0; i < histCount; i++) {
    int idx = (histHead - 1 - i + HISTORY) % HISTORY;
    if (i) h += ',';
    h += String(hist[idx]);
  }
  h += "]";
  struct tm t; char buf[20] = "";
  if (getLocalTime(&t, 0) && t.tm_year > 120) strftime(buf, sizeof(buf), "%H:%M %d/%m", &t);
  bool sta = WiFi.status() == WL_CONNECTED;
  return String("{\"hist\":") + h + ",\"total\":" + total + ",\"silent\":" + (silent ? "true" : "false") + ",\"time\":\"" + buf +
         "\",\"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/silent", HTTP_POST, []() { silent = server.arg("on") == "1"; cfg.putBool("silent", silent); server.send(200, "application/json", "{\"ok\":true}"); });
  server.on("/api/test", HTTP_POST, []() { ring(); 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_BTN, INPUT_PULLUP);
  pinMode(PIN_BUZZER, OUTPUT); digitalWrite(PIN_BUZZER, LOW);
  pinMode(PIN_LED, OUTPUT); digitalWrite(PIN_LED, LOW);
  cfg.begin("cfg", false);
  silent = cfg.getBool("silent", false);
  loadHistory();

  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) { pollButton(); delay(10); }
  }
  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();
  pollButton();
  runPattern();
  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.

+−+−5510101515202025253030JIHGFEDCBAU1 · ESP32 DevKit V1 (30 chân)191814GNDVINSW1Q1 S8050R1 1kBZ1+D1 redR2 220
  • 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×63,5 mm45 lỗ khoan · mũi 0,8 / 0,9 / 1mm
Đi dây 1 mặt14 đoạn · 407,6 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
U1SW1Q1R1BZ1D1R2EN363934353233252627141213GNDVIN232213211918517164215GND23V3
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: Chuông cửa WiFi: kêu còi và ghi lại lịch sử bấm chuông xem trên điện thoại

Kích thước board: 55,9×63,5 mm · 45 lỗ khoan (mũi 0,8, 0,9 và 1 mm) · 14 đoạn mạch, tổng 407,6 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 0,8mm, 0,9mm và 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 0,8mm — 7 lỗmũi khoan 0,9mm — 8 lỗmũi khoan 1mm — 30 lỗ

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

D14 và GND khi không bấm3,2 – 3,3V
D14 và GND khi bấm0V
Hai chân còi khi đang kêu4,6 – 5V

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

Còi không kêu nhưng LED có nháy

Kiểm tra chiều còi (chân dài +) và chân S8050 (E-B-C). Còi phải là loại chủ động (cấp điện là kêu); còi thụ động sẽ chỉ tách tách.

Chuông tự kêu khi không ai bấm

Dây nút quá dài bị nhiễu: xoắn đôi dây, hoặc thêm tụ 100nF giữa D14 và GND sát ESP32.

Lịch sử không có giờ

Mạch chưa nối WiFi nhà có Internet. Khi chưa có giờ, lịch sử ghi "chưa rõ giờ".

⚠️ An toàn

  • Mạch chạy 5V, an toàn. Nút ngoài trời nên dùng loại chống nước hoặc che mưa.