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ủ.
✅ Đã kiểm thử
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 ✓
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.
| # | Dây | Nối các điểm |
|---|---|---|
| 1 | đỏ 5V | U1 VIN ⟷ BZ1 + |
| 2 | đen GND | U1 GND ⟷ SW1 2 ⟷ Q1 E (phát) ⟷ D1 K · chân ngắn (−) |
| 3 | vàng BTN | U1 GPIO14 (D14) ⟷ SW1 1 |
| 4 | xanh lá BZ_DRV | U1 GPIO18 (D18) ⟷ R1 1 |
| 5 | xanh dương BZ_BASE | R1 2 ⟷ Q1 B (gốc) |
| 6 | tím BZ_NEG | BZ1 - ⟷ Q1 C (góp) |
| 7 | xanh ngọc LED_DRV | U1 GPIO19 (D19) ⟷ R2 1 |
| 8 | nâu LED_A | R2 2 ⟷ D1 A · chân dài (+) |
Danh sách linh kiện
| Ký hiệu | Linh kiện | Giá tham khảo | |
|---|---|---|---|
| U1 | ESP32 DevKit V1 30 chân (ESP32-WROOM-32) | 110.000đ | Tìm mua |
| SW1 | Nú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 |
| Q1 | Transistor S8050 (TO-92) | 500đ | Tìm mua |
| R1 | Điện trở 1kΩ 1/4W (nâu-đen-đỏ) | 200đ | Tìm mua |
| BZ1 | Cò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 |
| D1 | LED đỏ 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();
}
}
Đo kiểm bằng đồng hồ vạn năng
| D14 và GND khi không bấm | 3,2 – 3,3V |
| D14 và GND khi bấm | 0V |
| Hai chân còi khi đang kêu | 4,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.