DualGate IoT Access System(雙門物聯網門禁系統)-ESP32 Telegram 智慧門禁控制系統
這套整合 ESP32(Wokwi 邊緣端)、MQTT Broker (broker.emqx.io)、Python(中央控制台) 與 Telegram Bot(行動管理端) 的雙門禁智慧控制系統,其完整功能清單與運作流程如下:
一、 系統四大模組與核心功能
1. 邊緣硬體感應端(Wokwi ESP32 C++)
雙門獨立感應(共用 SPI):
CARD1(入口,SDA: GPIO 5):觸發進場事件。CARD2(出口,SDA: GPIO 4):觸發出場事件。
本地 Flash 白名單(Preferences NVS):
白名單儲存於 ESP32 Flash 內,斷電重啟名單不遺失,且離線時仍可獨立驗證開門。
周圍聲光與開鎖控制:
合法刷卡:蜂鳴器(GPIO 12)嗶 1 聲,繼電器 Relay(GPIO 13)導通 3 秒開鎖。
非法刷卡:蜂鳴器連響 3 聲警報(嗶-嗶-嗶),繼電器不動作。
LCD1602 (I2C) 即時狀態顯示:
待機時第一行顯示
== RFID ACCESS ==,第二行即時顯示目前場內人數In Room: X P。刷卡時即時顯示
Welcome In!或Access Denied!。
開機自動全量同步 (
REQ_ALL):開機連上網路後,主動發送
REQ_ALL向 Python 下載最新 SQLite 白名單並寫入 Flash,隨後發出 3 短聲提示。
2. 中央控制台(Python Tkinter + SQLite)
SQLite 資料庫 (
access_control.db):users:管理卡號 UID、人員姓名與部門。inside_cards:記錄當前人在場內的卡片與進場時間。access_logs:完整追蹤歷史進出、非法警報、手動清場與遠端開鎖紀錄。
Tkinter 三分頁視窗 GUI:
場內人員清單:即時顯示目前在場人數與人員名單。
歷史進出紀錄:條列歷史刷卡事件(包含進/出場、非法拒絕、遠端開鎖)。
人員卡號管理:提供 UI 介面供管理員新增、修改或刪除卡片成員。
MQTT 訊息中繼:
訂閱
wokwi/rfid/access(監聽刷卡事件與警報)。訂閱
wokwi/rfid/sync_req(處理開機同步)。發送
wokwi/rfid/cmd(發送ADD:、DEL:、AUTH_UNLOCK:、REMOTE_UNLOCK、COUNT:指令)。
3. 遠端行動管理端(Telegram Bot)
開機自動選單推播:Python 後台一啟動,自動向管理者 Telegram 發送系統選單說明。
遠端指令控制:
/status:隨時查詢場內總人數與名單。/unlock:驗證權限後,無條件遠端導通門鎖繼電器 3 秒。/add [UID] [姓名] [部門]:遠端新增成員白名單。/del [UID]:遠端刪除成員白名單。/clear:一鍵清空場內在場人員名單,並同步讓 LCD 顯示歸零。
多部門一鍵授權按鈕(Inline Keyboard):
收到非法刷卡警報時,訊息下方自動跳出
[ 💻 授權-研發部 ]、[ 🛠️ 授權-工程部 ]、[ 📋 授權-行政部 ]、[ ☕ 授權-訪客 ]按鈕。
二、 系統三大核心動作流程
動作流程 1:ESP32 開機全量同步 (Boot Sync)
[ ESP32 開機 / 重置 ]
│
▼
ESP32 從 Flash 載入快取白名單 ──► LCD 顯示 "Connecting WiFi..."
│
▼
連線至 MQTT Broker (broker.emqx.io)
│
▼
ESP32 向 MQTT 發送 "REQ_ALL" (Topic: wokwi/rfid/sync_req)
│
▼
Python 後台收到 "REQ_ALL" ──► 查詢 SQLite users 資料表
│
▼
Python 打包 JSON 格式回應 (Topic: wokwi/rfid/sync_res)
+ 廣播場內人數 "COUNT:X" (Topic: wokwi/rfid/cmd)
│
▼
ESP32 解析 JSON 覆寫 Flash 記憶體 ──► 蜂鳴器響 3 短聲 ──► LCD 顯示待機畫面 (In Room: X P)
動作流程 2:現場刷卡感應與驗證 (進場 / 出場 / 非法刷卡)
[ 現場卡片感應 (CARD1 或 CARD2) ]
│
▼
比對 ESP32 本地 NVS 白名單
│
┌─────────────────────────────────┴─────────────────────────────────┐
▼ (合法卡) ▼ (非法卡)
1. 蜂鳴器嗶 1 聲 1. 蜂鳴器連響 3 聲警報 (逼-逼-逼)
2. LCD 顯示 "Welcome In!" 2. LCD 顯示 "Access Denied!"
3. 繼電器導通 3 秒開鎖 3. 門鎖維持關閉
4. 發送 MQTT "AA:UID" (入口) 或 "BB:UID" (出口) 4. 發送 MQTT "DENIED:UID"
│ │
▼ ▼
[ Python 後台處置 ] [ Python 後台處置 ]
1. 更新 SQLite inside_cards & access_logs 1. 寫入 access_logs (DENIED 紀錄)
2. 廣播更新人數 "COUNT:X" 給 ESP32 LCD 2. Telegram 發送紅色警報通知
3. 跨執行緒即時刷新 Tkinter UI 表格 3. 訊息下方附加多部門授權 Inline 按鈕
4. Telegram 推播進/出場通知訊息
動作流程 3:Telegram 遠端一鍵授權開鎖 & /unlock 隨時開門
[ 管理者於 Telegram 點擊「一鍵授權按鈕」或輸入 /unlock ]
│
▼
[ Python 後台驗證 Chat ID ]
│
┌─────────────┴─────────────┐
▼ (一鍵授權) ▼ (/unlock 指令)
1. 新增卡片至 SQLite users 1. 寫入 access_logs (REMOTE_UNLOCK)
2. 發送 MQTT "AUTH_UNLOCK:UID" 2. 發送 MQTT "REMOTE_UNLOCK"
│ │
└─────────────┬─────────────┘
▼
[ ESP32 接收 MQTT 指令 ]
│
▼
1. 若為授權:自動將卡號寫入 ESP32 Flash 記憶體
2. LCD 顯示 "Telegram Auth" 或 "Remote Unlock"
3. 蜂鳴器發出 2 聲快音 (嗶-嗶,提示現場人員)
4. 繼電器 (Relay) 導通 3 秒,現場無須再次刷卡即可直接推門進入
5. 3 秒後繼電器復歸上鎖,LCD 回覆預設待機畫面
#include <WiFi.h>#include <PubSubClient.h>#include <SPI.h>#include <MFRC522.h>#include <Preferences.h>#include <ArduinoJson.h>#include <Wire.h>#include <LiquidCrystal_I2C.h>
// Wi-Fi 與 MQTT 伺服器設定const char* ssid = "Wokwi-GUEST";const char* password = "";const char* mqtt_server = "broker.emqx.io";
// MQTT Topics 定義const char* mqtt_topic_access = "wokwi/rfid/access"; // 刷卡與非法警報上報const char* mqtt_topic_cmd = "wokwi/rfid/cmd"; // 控制指令接收const char* mqtt_topic_sync_req = "wokwi/rfid/sync_req"; // 開機全量同步請求const char* mqtt_topic_sync_res = "wokwi/rfid/sync_res"; // 開機全量同步回應
WiFiClient espClient;PubSubClient client(espClient);Preferences preferences;
// 初始化 LCD1602 (I2C 位址預設 0x27, 16欄, 2列)LiquidCrystal_I2C lcd(0x27, 16, 2);
// 週邊控制腳位定義 (RST 改為 GPIO 16/17,釋放 GPIO 21/22 給 I2C)#define SS_1_PIN 5#define RST_1_PIN 16#define SS_2_PIN 4#define RST_2_PIN 17#define BUZZER_PIN 12#define RELAY_PIN 13
MFRC522 rfid1(SS_1_PIN, RST_1_PIN); // CARD1 (入口)MFRC522 rfid2(SS_2_PIN, RST_2_PIN); // CARD2 (出口)
// 動態白名單記憶體緩衝與場內人數const int MAX_CARDS = 50;String whiteList[MAX_CARDS];int cardCount = 0;int currentRoomCount = 0;
// LCD 顯示輔助函式void showLcdMessage(String line1, String line2) { lcd.clear(); lcd.setCursor(0, 0); lcd.print(line1); lcd.setCursor(0, 1); lcd.print(line2);}
// 顯示預設待機畫面 (第二行顯示場內總人數)void showLcdStandby() { showLcdMessage("== RFID ACCESS ==", "In Room: " + String(currentRoomCount) + " P");}
// 將白名單寫入 ESP32 NVS Flashvoid saveAllToNVS() { preferences.begin("whitelist", false); preferences.clear(); preferences.putInt("count", cardCount); for (int i = 0; i < cardCount; i++) { String key = "c" + String(i); preferences.putString(key.c_str(), whiteList[i]); } preferences.end(); Serial.println("💾 [NVS] 白名單已成功覆寫至 Flash");}
// 從 Flash 載入本地白名單void loadFromNVS() { preferences.begin("whitelist", true); cardCount = preferences.getInt("count", 0); for (int i = 0; i < cardCount; i++) { String key = "c" + String(i); whiteList[i] = preferences.getString(key.c_str(), ""); } preferences.end(); Serial.printf("📂 [NVS] 已載入本地快取白名單: %d 張\n", cardCount);}
// 檢查卡號是否為白名單成員bool isAuthorizedCard(String uid) { for (int i = 0; i < cardCount; i++) { if (whiteList[i] == uid) return true; } return false;}
// 蜂鳴器提示音控制void triggerBuzzer(int count) { for (int i = 0; i < count; i++) { digitalWrite(BUZZER_PIN, HIGH); delay(100); digitalWrite(BUZZER_PIN, LOW); if (i < count - 1) delay(100); }}
// 繼電器 (Relay) 導通開鎖 3 秒void triggerRelayUnlock() { digitalWrite(RELAY_PIN, HIGH); Serial.println("🔓 [繼電器導通] 門鎖開啟 3 秒..."); delay(3000); digitalWrite(RELAY_PIN, LOW); Serial.println("🔒 [繼電器復歸] 門鎖已上鎖"); showLcdStandby();}
// MQTT 訊息監聽與邏輯處理void mqttCallback(char* topic, byte* payload, unsigned int length) { String message = ""; for (unsigned int i = 0; i < length; i++) { message += (char)payload[i]; } message.trim();
// 1. 處理開機全量同步 JSON 回傳 if (String(topic) == mqtt_topic_sync_res) { Serial.println("\n🔄 [全量同步] 收到 Python 後台白名單資料..."); StaticJsonDocument<1024> doc; DeserializationError error = deserializeJson(doc, message);
if (!error) { JsonArray uids = doc["uids"]; cardCount = 0; for (JsonVariant v : uids) { if (cardCount < MAX_CARDS) { whiteList[cardCount++] = v.as<String>(); } } saveAllToNVS(); triggerBuzzer(3); showLcdMessage("Sync Success!", "Count: " + String(cardCount)); delay(1500); showLcdStandby(); } } // 2. 處理控制指令 else if (String(topic) == mqtt_topic_cmd) { // 同步場內人數廣播 if (message.startsWith("COUNT:")) { currentRoomCount = message.substring(6).toInt(); Serial.printf("📊 [人數同步] 當前場內人數: %d 人\n", currentRoomCount); showLcdStandby(); } else if (message.startsWith("ADD:")) { String uid = message.substring(4); uid.toUpperCase(); if (!isAuthorizedCard(uid) && cardCount < MAX_CARDS) { whiteList[cardCount++] = uid; saveAllToNVS(); triggerBuzzer(1); } } else if (message.startsWith("DEL:")) { String uid = message.substring(4); uid.toUpperCase(); for (int i = 0; i < cardCount; i++) { if (whiteList[i] == uid) { for (int j = i; j < cardCount - 1; j++) whiteList[j] = whiteList[j + 1]; cardCount--; saveAllToNVS(); triggerBuzzer(1); break; } } } // Telegram 一鍵授權 + 即刻開鎖 else if (message.startsWith("AUTH_UNLOCK:")) { String uid = message.substring(12); uid.toUpperCase(); if (!isAuthorizedCard(uid) && cardCount < MAX_CARDS) { whiteList[cardCount++] = uid; saveAllToNVS(); } showLcdMessage("Telegram Auth", "Welcome In!"); triggerBuzzer(2); triggerRelayUnlock(); } // Telegram /unlock 無條件遠端開鎖 else if (message == "REMOTE_UNLOCK") { showLcdMessage("Remote Unlock", "Door Opened!"); triggerBuzzer(2); triggerRelayUnlock(); } }}
void reconnect() { while (!client.connected()) { String clientId = "ESP32-RFID-" + String(random(0xffff), HEX); if (client.connect(clientId.c_str())) { Serial.println("🌐 [MQTT] 已連線至 broker.emqx.io"); client.subscribe(mqtt_topic_cmd); client.subscribe(mqtt_topic_sync_res); delay(200);
client.publish(mqtt_topic_sync_req, "REQ_ALL");
for (int i = 0; i < 10; i++) { client.loop(); delay(50); } } else { delay(2000); } }}
void readAndPublish(MFRC522 &mfrc522, String prefix) { if (!mfrc522.PICC_IsNewCardPresent() || !mfrc522.PICC_ReadCardSerial()) return;
String rawUID = ""; for (byte i = 0; i < mfrc522.uid.size; i++) { if (i > 0) rawUID += ":"; if (mfrc522.uid.uidByte[i] < 0x10) rawUID += "0"; rawUID += String(mfrc522.uid.uidByte[i], HEX); } rawUID.toUpperCase();
Serial.print("\n感應卡號: "); Serial.println(rawUID);
if (isAuthorizedCard(rawUID)) { showLcdMessage("Welcome In!", "UID: " + rawUID.substring(0, 8)); client.publish(mqtt_topic_access, (prefix + rawUID).c_str()); digitalWrite(BUZZER_PIN, HIGH); delay(100); digitalWrite(BUZZER_PIN, LOW); triggerRelayUnlock(); } else { showLcdMessage("Access Denied!", "Invalid Card"); client.publish(mqtt_topic_access, ("DENIED:" + rawUID).c_str()); triggerBuzzer(3); delay(2000); showLcdStandby(); }
mfrc522.PICC_HaltA(); mfrc522.PCD_StopCrypto1(); delay(1000);}
void setup() { Serial.begin(115200); SPI.begin();
lcd.init(); lcd.backlight(); showLcdMessage("Initializing...", "Connecting WiFi");
pinMode(BUZZER_PIN, OUTPUT); pinMode(RELAY_PIN, OUTPUT); digitalWrite(BUZZER_PIN, LOW); digitalWrite(RELAY_PIN, LOW);
loadFromNVS();
WiFi.begin(ssid, password); while (WiFi.status() != WL_CONNECTED) { delay(500); } Serial.println("\nWiFi 已連線");
client.setServer(mqtt_server, 1883); client.setCallback(mqttCallback);
rfid1.PCD_Init(); rfid2.PCD_Init();
showLcdStandby(); Serial.println("雙 MFRC522 + LCD1602 門禁系統運作中");}
void loop() { if (!client.connected()) reconnect(); client.loop();
readAndPublish(rfid1, "AA:"); readAndPublish(rfid2, "BB:");
delay(20);}
json
{ "version": 1, "author": "ESP32 Dual RFID Access Control System with LCD1602", "editor": "wokwi", "parts": [ { "type": "board-esp32-devkit-c-v4", "id": "esp", "top": -19.2, "left": -71.96, "attrs": {} }, { "type": "board-mfrc522", "id": "rfid1", "top": 16.55, "left": 397.04, "attrs": { "uid": "01:02:03:04" } }, { "type": "board-mfrc522", "id": "rfid2", "top": -185.05, "left": 387.44, "attrs": { "uid": "AA:11:22:33" } }, { "type": "wokwi-buzzer", "id": "bz1", "top": 12, "left": -238.2, "attrs": {} }, { "type": "wokwi-relay-module", "id": "relay1", "top": 134.6, "left": 144, "attrs": {} }, { "type": "wokwi-lcd1602", "id": "lcd1", "top": 217.6, "left": 159.2, "attrs": { "pins": "i2c" } }, { "type": "wokwi-led", "id": "led1", "top": 121.2, "left": 339.8, "attrs": { "color": "red", "flip": "" } }, { "type": "wokwi-resistor", "id": "r1", "top": 157.55, "left": 288, "attrs": { "value": "1000" } }, { "type": "wokwi-vcc", "id": "vcc1", "top": 183.16, "left": 403.2, "rotate": 90, "attrs": {} } ], "connections": [ [ "esp:TX", "$serialMonitor:RX", "", [] ], [ "esp:RX", "$serialMonitor:TX", "", [] ], [ "rfid1:VCC", "esp:3V3", "red", [] ], [ "rfid1:SCK", "esp:18", "blue", [ "h-182.4", "v19.2" ] ], [ "rfid1:MISO", "esp:19", "orange", [ "h-115.2", "v-9.6" ] ], [ "rfid1:MOSI", "esp:23", "yellow", [ "h-86.4", "v-57.6" ] ], [ "rfid1:SDA", "esp:5", "green", [ "h-326.4", "v38.4" ] ], [ "rfid1:RST", "esp:16", "gray", [] ], [ "rfid2:VCC", "esp:3V3", "red", [] ], [ "rfid2:GND", "esp:GND.2", "black", [] ], [ "rfid2:SCK", "esp:18", "blue", [ "h-172.8", "v220.8" ] ], [ "rfid2:MISO", "esp:19", "orange", [ "h-105.6", "v192" ] ], [ "rfid2:MOSI", "esp:23", "yellow", [ "h-76.8", "v144" ] ], [ "rfid2:SDA", "esp:4", "green", [ "h-28.8", "v268.8" ] ], [ "rfid2:RST", "esp:17", "gray", [ "h-57.6", "v182.4", "h-249.6", "v9.6" ] ], [ "bz1:1", "esp:GND.1", "black", [ "v9.6", "v28.8" ] ], [ "bz1:2", "esp:12", "purple", [ "v28.8", "h133.85" ] ], [ "relay1:IN", "esp:13", "orange", [ "v38.2", "h-249.6", "v-67.2" ] ], [ "lcd1:GND", "esp:GND.1", "black", [ "h-288", "v-115.2" ] ], [ "lcd1:VCC", "esp:5V", "red", [ "h-259.2", "v-76.7" ] ], [ "lcd1:SDA", "esp:21", "green", [ "h-105.6", "v-211" ] ], [ "lcd1:SCL", "esp:22", "yellow", [ "h-57.6", "v-249.3" ] ], [ "lcd1:VCC", "relay1:VCC", "red", [ "h-28.8", "v-105.5" ] ], [ "lcd1:GND", "relay1:GND", "black", [ "h-48", "v-86.4", "h9.6" ] ], [ "relay1:GND", "rfid1:GND", "black", [ "h-28.8", "v-58" ] ], [ "rfid2:3.3V", "esp:3V3", "red", [ "h-460.8" ] ], [ "rfid2:3.3V", "rfid1:3.3V", "red", [ "h-19.2", "v201.6" ] ], [ "led1:A", "vcc1:VCC", "red", [ "v0" ] ], [ "led1:C", "r1:2", "green", [ "v0" ] ], [ "relay1:COM", "r1:1", "green", [ "h0" ] ], [ "relay1:NO", "rfid1:GND", "black", [ "h126", "v-1.8" ] ] ], "dependencies": {}}{ "version": 1, "author": "ESP32 Dual RFID Access Control System with LCD1602", "editor": "wokwi", "parts": [ { "type": "board-esp32-devkit-c-v4", "id": "esp", "top": -19.2, "left": -71.96, "attrs": {} }, { "type": "board-mfrc522", "id": "rfid1", "top": 16.55, "left": 397.04, "attrs": { "uid": "01:02:03:04" } }, { "type": "board-mfrc522", "id": "rfid2", "top": -185.05, "left": 387.44, "attrs": { "uid": "AA:11:22:33" } }, { "type": "wokwi-buzzer", "id": "bz1", "top": 12, "left": -238.2, "attrs": {} }, { "type": "wokwi-relay-module", "id": "relay1", "top": 134.6, "left": 144, "attrs": {} }, { "type": "wokwi-lcd1602", "id": "lcd1", "top": 217.6, "left": 159.2, "attrs": { "pins": "i2c" } }, { "type": "wokwi-led", "id": "led1", "top": 121.2, "left": 339.8, "attrs": { "color": "red", "flip": "" } }, { "type": "wokwi-resistor", "id": "r1", "top": 157.55, "left": 288, "attrs": { "value": "1000" } }, { "type": "wokwi-vcc", "id": "vcc1", "top": 183.16, "left": 403.2, "rotate": 90, "attrs": {} } ], "connections": [ [ "esp:TX", "$serialMonitor:RX", "", [] ], [ "esp:RX", "$serialMonitor:TX", "", [] ], [ "rfid1:VCC", "esp:3V3", "red", [] ], [ "rfid1:SCK", "esp:18", "blue", [ "h-182.4", "v19.2" ] ], [ "rfid1:MISO", "esp:19", "orange", [ "h-115.2", "v-9.6" ] ], [ "rfid1:MOSI", "esp:23", "yellow", [ "h-86.4", "v-57.6" ] ], [ "rfid1:SDA", "esp:5", "green", [ "h-326.4", "v38.4" ] ], [ "rfid1:RST", "esp:16", "gray", [] ], [ "rfid2:VCC", "esp:3V3", "red", [] ], [ "rfid2:GND", "esp:GND.2", "black", [] ], [ "rfid2:SCK", "esp:18", "blue", [ "h-172.8", "v220.8" ] ], [ "rfid2:MISO", "esp:19", "orange", [ "h-105.6", "v192" ] ], [ "rfid2:MOSI", "esp:23", "yellow", [ "h-76.8", "v144" ] ], [ "rfid2:SDA", "esp:4", "green", [ "h-28.8", "v268.8" ] ], [ "rfid2:RST", "esp:17", "gray", [ "h-57.6", "v182.4", "h-249.6", "v9.6" ] ], [ "bz1:1", "esp:GND.1", "black", [ "v9.6", "v28.8" ] ], [ "bz1:2", "esp:12", "purple", [ "v28.8", "h133.85" ] ], [ "relay1:IN", "esp:13", "orange", [ "v38.2", "h-249.6", "v-67.2" ] ], [ "lcd1:GND", "esp:GND.1", "black", [ "h-288", "v-115.2" ] ], [ "lcd1:VCC", "esp:5V", "red", [ "h-259.2", "v-76.7" ] ], [ "lcd1:SDA", "esp:21", "green", [ "h-105.6", "v-211" ] ], [ "lcd1:SCL", "esp:22", "yellow", [ "h-57.6", "v-249.3" ] ], [ "lcd1:VCC", "relay1:VCC", "red", [ "h-28.8", "v-105.5" ] ], [ "lcd1:GND", "relay1:GND", "black", [ "h-48", "v-86.4", "h9.6" ] ], [ "relay1:GND", "rfid1:GND", "black", [ "h-28.8", "v-58" ] ], [ "rfid2:3.3V", "esp:3V3", "red", [ "h-460.8" ] ], [ "rfid2:3.3V", "rfid1:3.3V", "red", [ "h-19.2", "v201.6" ] ], [ "led1:A", "vcc1:VCC", "red", [ "v0" ] ], [ "led1:C", "r1:2", "green", [ "v0" ] ], [ "relay1:COM", "r1:1", "green", [ "h0" ] ], [ "relay1:NO", "rfid1:GND", "black", [ "h126", "v-1.8" ] ] ], "dependencies": {}}
PYTHON
import asyncio
import json
import sqlite3
import threading
import tkinter as tk
from datetime import datetime
from tkinter import messagebox, ttk
import paho.mqtt.client as mqtt
import requests
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import (
Application,
CallbackQueryHandler,
CommandHandler,
ContextTypes,
)
# --- Telegram Bot 設定 ---
TELEGRAM_BOT_TOKEN = "88451708831:AAEuRreXcFRyv1m--jGK7OCn8MiEsWXqpMT0"
TELEGRAM_CHAT_ID = "712652138469"
global_app = None
# --- MQTT 工具函式 (broker.emqx.io) ---
def send_mqtt_cmd(topic, payload):
def _pub():
try:
pub_client = mqtt.Client()
pub_client.connect("broker.emqx.io", 1883, 60)
pub_client.publish(topic, payload)
pub_client.disconnect()
print(f"📡 [MQTT 發送成功] Topic: {topic} -> {payload}")
except Exception as e:
print(f"❌ [MQTT 發送失敗]: {e}")
threading.Thread(target=_pub, daemon=True).start()
# 將當前場內總人數廣播給 ESP32 LCD 螢幕
def broadcast_room_count():
conn = sqlite3.connect("access_control.db")
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM inside_cards")
count = cursor.fetchone()[0]
conn.close()
send_mqtt_cmd("wokwi/rfid/cmd", f"COUNT:{count}")
# --- 通用 Telegram 訊息推播 ---
def send_telegram_msg(message, reply_markup=None):
def _send():
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
payload = {
"chat_id": TELEGRAM_CHAT_ID,
"text": message,
"parse_mode": "Markdown",
}
if reply_markup:
payload["reply_markup"] = reply_markup.to_json()
try:
requests.post(url, json=payload, timeout=5)
except Exception as e:
print(f"[Telegram 發送失敗]: {e}")
threading.Thread(target=_send, daemon=True).start()
# --- SQLite 資料庫初始化 ---
def init_db():
conn = sqlite3.connect("access_control.db")
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS access_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
card_uid TEXT,
user_name TEXT,
action TEXT,
timestamp TEXT
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS inside_cards (
card_uid TEXT PRIMARY KEY,
user_name TEXT,
entry_time TEXT
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS users (
card_uid TEXT PRIMARY KEY,
name TEXT NOT NULL,
department TEXT
)
""")
try:
cursor.execute(
"ALTER TABLE access_logs ADD COLUMN user_name TEXT DEFAULT ''"
)
except sqlite3.OperationalError:
pass
try:
cursor.execute(
"ALTER TABLE inside_cards ADD COLUMN user_name TEXT DEFAULT ''"
)
except sqlite3.OperationalError:
pass
default_users = [
("01:02:03:04", "張小明", "研發部"),
("11:22:33:44", "李美玲", "行政部"),
("55:66:77:88", "王大衛", "工程部"),
("AA:11:22:33", "陳經理", "管理部"),
("BB:44:55:66", "林專員", "業務部"),
]
cursor.executemany(
"INSERT OR IGNORE INTO users (card_uid, name, department) VALUES (?, ?,"
" ?)",
default_users,
)
conn.commit()
conn.close()
init_db()
def get_user_name(card_uid):
conn = sqlite3.connect("access_control.db")
cursor = conn.cursor()
cursor.execute(
"SELECT name, department FROM users WHERE card_uid = ?", (card_uid,)
)
row = cursor.fetchone()
conn.close()
if row:
return f"{row[0]} ({row[1]})"
return "未註冊卡片/訪客"
def send_full_whitelist_sync():
conn = sqlite3.connect("access_control.db")
cursor = conn.cursor()
cursor.execute("SELECT card_uid FROM users")
rows = cursor.fetchall()
conn.close()
uid_list = [row[0] for row in rows]
payload_data = {"count": len(uid_list), "uids": uid_list}
json_payload = json.dumps(payload_data)
send_mqtt_cmd("wokwi/rfid/sync_res", json_payload)
broadcast_room_count()
print(
f"🔄 [開機全量同步] 已回應 ESP32 白名單,包含 {len(uid_list)} 張卡號。"
)
# =====================================================================
# Telegram 指令與按鈕事件處理器
# =====================================================================
async def handle_start_command(
update: Update, _context: ContextTypes.DEFAULT_TYPE
):
welcome_text = (
"🤖 門禁管理機器人已上線!\n\n"
"👉 /status : 查詢當前場內人員與人數\n"
"👉 /unlock : 隨時遠端開啟門鎖 3 秒\n"
"👉 /add [UID] [姓名] [部門] : 新增/更新白名單成員\n"
"👉 /del [UID] : 刪除白名單成員\n"
"👉 /clear : 清空場內名單"
)
await update.message.reply_text(welcome_text)
async def handle_status_command(
update: Update, _context: ContextTypes.DEFAULT_TYPE
):
conn = sqlite3.connect("access_control.db")
cursor = conn.cursor()
cursor.execute(
"SELECT card_uid, user_name, entry_time FROM inside_cards ORDER BY"
" entry_time DESC"
)
records = cursor.fetchall()
count = len(records)
conn.close()
reply_msg = (
f"📊 【門禁場內狀態回報】\n當前場內總人數:{count}"
" 人\n------------------------------------\n"
)
if count == 0:
reply_msg += "目前場內無任何人員。"
else:
reply_msg += "📋 【在場人員清單】:\n"
for idx, (uid, name, entry_time) in enumerate(records, start=1):
reply_msg += f"{idx}. {name}\n UID: {uid}\n 入場時間: {entry_time}\n"
await update.message.reply_text(reply_msg)
async def handle_unlock_command(
update: Update, _context: ContextTypes.DEFAULT_TYPE
):
if str(update.effective_chat.id) != str(TELEGRAM_CHAT_ID):
await update.message.reply_text("⛔ 【權限不足】您非管理者!")
return
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
send_mqtt_cmd("wokwi/rfid/cmd", "REMOTE_UNLOCK")
conn = sqlite3.connect("access_control.db")
cursor = conn.cursor()
cursor.execute(
"INSERT INTO access_logs (card_uid, user_name, action, timestamp)"
" VALUES (?, ?, ?, ?)",
("REMOTE", "系統管理員", "REMOTE_UNLOCK (Telegram遠端開鎖)", now_str),
)
conn.commit()
conn.close()
if global_app and global_app.root:
global_app.root.after(0, global_app.refresh_ui)
reply_msg = (
f"🔓 **【遠端開鎖指令已發送】**\n"
f"觸發者: 系統管理員\n"
f"時間: {now_str}\n"
f"📡 門鎖繼電器已導通開鎖 3 秒!"
)
await update.message.reply_text(reply_msg, parse_mode="Markdown")
async def handle_add_command(
update: Update, context: ContextTypes.DEFAULT_TYPE
):
if str(update.effective_chat.id) != str(TELEGRAM_CHAT_ID):
await update.message.reply_text("⛔ 【權限不足】您無法執行此指令!")
return
args = context.args
if len(args) < 2:
await update.message.reply_text(
"⚠️ 【格式錯誤】格式:\n/add [UID] [姓名] [部門(選填)]"
)
return
uid = args[0].strip().upper()
name = args[1].strip()
dept = args[2].strip() if len(args) >= 3 else "未設定"
conn = sqlite3.connect("access_control.db")
cursor = conn.cursor()
cursor.execute(
"INSERT OR REPLACE INTO users (card_uid, name, department) VALUES (?, ?,"
" ?)",
(uid, name, dept),
)
conn.commit()
conn.close()
send_mqtt_cmd("wokwi/rfid/cmd", f"ADD:{uid}")
if global_app and global_app.root:
global_app.root.after(0, global_app.refresh_ui)
reply_msg = (
f"✅ 【新增成功】\nUID: {uid}\n姓名: {name}\n部門: {dept}\n📡"
" 已同步至 ESP32 Flash。"
)
await update.message.reply_text(reply_msg)
async def handle_del_command(
update: Update, context: ContextTypes.DEFAULT_TYPE
):
if str(update.effective_chat.id) != str(TELEGRAM_CHAT_ID):
await update.message.reply_text("⛔ 【權限不足】您無法執行此指令!")
return
args = context.args
if len(args) < 1:
await update.message.reply_text("⚠️ 【格式錯誤】格式:\n/del [UID]")
return
uid = args[0].strip().upper()
conn = sqlite3.connect("access_control.db")
cursor = conn.cursor()
cursor.execute("SELECT name FROM users WHERE card_uid = ?", (uid,))
row = cursor.fetchone()
user_name = row[0] if row else "未知人員"
cursor.execute("DELETE FROM users WHERE card_uid = ?", (uid,))
conn.commit()
conn.close()
send_mqtt_cmd("wokwi/rfid/cmd", f"DEL:{uid}")
if global_app and global_app.root:
global_app.root.after(0, global_app.refresh_ui)
reply_msg = (
f"🗑️ 【刪除成功】\nUID: {uid}\n原姓名: {user_name}\n📡"
" 已同步通知 ESP32 移除。"
)
await update.message.reply_text(reply_msg)
async def handle_clear_command(
update: Update, _context: ContextTypes.DEFAULT_TYPE
):
if str(update.effective_chat.id) != str(TELEGRAM_CHAT_ID):
await update.message.reply_text("⛔ 【權限不足】您無法執行此指令!")
return
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn = sqlite3.connect("access_control.db")
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM inside_cards")
cleared_count = cursor.fetchone()[0]
cursor.execute("DELETE FROM inside_cards")
cursor.execute(
"INSERT INTO access_logs (card_uid, user_name, action, timestamp)"
" VALUES (?, ?, ?, ?)",
("SYSTEM", "系統管理員", f"SYSTEM_CLEAR ({cleared_count}人)", now_str),
)
conn.commit()
conn.close()
broadcast_room_count()
reply_msg = f"🧹 【場內名單已清空】\n已清除人數: {cleared_count} 人"
await update.message.reply_text(reply_msg)
if global_app and global_app.root:
global_app.root.after(0, global_app.refresh_ui)
async def handle_button_click(
update: Update, _context: ContextTypes.DEFAULT_TYPE
):
query = update.callback_query
await query.answer()
if str(update.effective_chat.id) != str(TELEGRAM_CHAT_ID):
await query.message.reply_text("⛔ 【權限不足】您非管理者!")
return
data = query.data
if data.startswith("AUTH:"):
parts = data.split(":")
if len(parts) >= 3:
uid = parts[1]
dept = parts[2]
name = "臨時授權"
conn = sqlite3.connect("access_control.db")
cursor = conn.cursor()
cursor.execute(
"INSERT OR REPLACE INTO users (card_uid, name, department) VALUES"
" (?, ?, ?)",
(uid, name, dept),
)
conn.commit()
conn.close()
send_mqtt_cmd("wokwi/rfid/cmd", f"AUTH_UNLOCK:{uid}")
if global_app and global_app.root:
global_app.root.after(0, global_app.refresh_ui)
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
updated_msg = (
f"✅ **【卡片授權成功並已遠端開鎖】**\n"
f"卡號 UID: `{uid}`\n"
f"分配部門: **{dept}** ({name})\n"
f"執行動作: 已寫入 Flash / 門鎖開鎖 3 秒\n"
f"時間: {now_str}"
)
await query.edit_message_text(text=updated_msg, parse_mode="Markdown")
def start_telegram_bot_listener():
async def run_bot():
app = Application.builder().token(TELEGRAM_BOT_TOKEN).build()
app.add_handler(CommandHandler("start", handle_start_command))
app.add_handler(CommandHandler("status", handle_status_command))
app.add_handler(CommandHandler("unlock", handle_unlock_command))
app.add_handler(CommandHandler("add", handle_add_command))
app.add_handler(CommandHandler("del", handle_del_command))
app.add_handler(CommandHandler("clear", handle_clear_command))
app.add_handler(CallbackQueryHandler(handle_button_click))
print("Telegram Bot 監聽與一鍵授權服務已啟動...")
await app.initialize()
await app.start()
await app.updater.start_polling()
while True:
await asyncio.sleep(3600)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(run_bot())
# =====================================================================
# Tkinter GUI 主介面類別
# =====================================================================
class AccessControlApp:
def __init__(self, root):
self.root = root
self.root.title("RFID 門禁人員監控與 Telegram 系統")
self.root.geometry("820x520")
self.status_label = tk.Label(
root,
text="場內當前總人數: 0 人",
font=("Helvetica", 16, "bold"),
bg="#2c3e50",
fg="white",
pady=10,
)
self.status_label.pack(fill=tk.X)
notebook = ttk.Notebook(root)
notebook.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# 分頁 1: 當前場內人員
self.inside_frame = ttk.Frame(notebook)
notebook.add(self.inside_frame, text="場內人員清單")
self.inside_tree = ttk.Treeview(
self.inside_frame,
columns=("UID", "Name", "EntryTime"),
show="headings",
)
self.inside_tree.heading("UID", text="卡片 UID")
self.inside_tree.heading("Name", text="人員姓名/部門")
self.inside_tree.heading("EntryTime", text="入場時間")
self.inside_tree.column("UID", width=180, anchor="center")
self.inside_tree.column("Name", width=220, anchor="center")
self.inside_tree.column("EntryTime", width=250, anchor="center")
self.inside_tree.pack(fill=tk.BOTH, expand=True)
# 分頁 2: 歷史進出日誌
self.log_frame = ttk.Frame(notebook)
notebook.add(self.log_frame, text="歷史進出紀錄")
self.log_tree = ttk.Treeview(
self.log_frame,
columns=("ID", "UID", "Name", "Action", "Time"),
show="headings",
)
self.log_tree.heading("ID", text="編號")
self.log_tree.heading("UID", text="卡片 UID")
self.log_tree.heading("Name", text="人員姓名")
self.log_tree.heading("Action", text="動作")
self.log_tree.heading("Time", text="記錄時間")
self.log_tree.column("ID", width=50, anchor="center")
self.log_tree.column("UID", width=150, anchor="center")
self.log_tree.column("Name", width=180, anchor="center")
self.log_tree.column("Action", width=140, anchor="center")
self.log_tree.column("Time", width=200, anchor="center")
self.log_tree.pack(fill=tk.BOTH, expand=True)
# 分頁 3: 人員卡號管理
self.users_frame = ttk.Frame(notebook)
notebook.add(self.users_frame, text="人員卡號管理")
form_frame = ttk.LabelFrame(
self.users_frame, text="新增 / 編輯卡片成員", padding=10
)
form_frame.pack(fill=tk.X, padx=10, pady=5)
ttk.Label(form_frame, text="卡號 UID:").grid(
row=0, column=0, padx=5, pady=5, sticky=tk.W
)
self.uid_entry = ttk.Entry(form_frame, width=20)
self.uid_entry.grid(row=0, column=1, padx=5, pady=5)
ttk.Label(form_frame, text="姓名:").grid(
row=0, column=2, padx=5, pady=5, sticky=tk.W
)
self.name_entry = ttk.Entry(form_frame, width=15)
self.name_entry.grid(row=0, column=3, padx=5, pady=5)
ttk.Label(form_frame, text="部門:").grid(
row=0, column=4, padx=5, pady=5, sticky=tk.W
)
self.dept_entry = ttk.Entry(form_frame, width=15)
self.dept_entry.grid(row=0, column=5, padx=5, pady=5)
btn_add = ttk.Button(
form_frame, text="新增 / 更新人員", command=self.add_or_update_user
)
btn_add.grid(row=0, column=6, padx=10, pady=5)
self.users_tree = ttk.Treeview(
self.users_frame,
columns=("UID", "Name", "Department"),
show="headings",
)
self.users_tree.heading("UID", text="卡片 UID")
self.users_tree.heading("Name", text="姓名")
self.users_tree.heading("Department", text="部門")
self.users_tree.column("UID", width=200, anchor="center")
self.users_tree.column("Name", width=200, anchor="center")
self.users_tree.column("Department", width=200, anchor="center")
self.users_tree.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
action_frame = ttk.Frame(self.users_frame)
action_frame.pack(fill=tk.X, padx=10, pady=5)
btn_delete = ttk.Button(
action_frame, text="刪除選取人員", command=self.delete_user
)
btn_delete.pack(side=tk.RIGHT, padx=5)
self.refresh_ui()
def add_or_update_user(self):
uid = self.uid_entry.get().strip().upper()
name = self.name_entry.get().strip()
dept = self.dept_entry.get().strip()
if not uid or not name:
messagebox.showwarning("輸入錯誤", "卡號 UID 與姓名為必填項目!")
return
conn = sqlite3.connect("access_control.db")
cursor = conn.cursor()
cursor.execute(
"INSERT OR REPLACE INTO users (card_uid, name, department) VALUES (?, ?,"
" ?)",
(uid, name, dept),
)
conn.commit()
conn.close()
send_mqtt_cmd("wokwi/rfid/cmd", f"ADD:{uid}")
self.uid_entry.delete(0, tk.END)
self.name_entry.delete(0, tk.END)
self.dept_entry.delete(0, tk.END)
self.refresh_ui()
def delete_user(self):
selected = self.users_tree.selection()
if not selected:
messagebox.showwarning("選取錯誤", "請先點選欲刪除的人員項目!")
return
item = self.users_tree.item(selected[0])
uid = item["values"][0]
conn = sqlite3.connect("access_control.db")
cursor = conn.cursor()
cursor.execute("DELETE FROM users WHERE card_uid = ?", (uid,))
conn.commit()
conn.close()
send_mqtt_cmd("wokwi/rfid/cmd", f"DEL:{uid}")
self.refresh_ui()
def process_swipe(self, raw_data):
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn = sqlite3.connect("access_control.db")
cursor = conn.cursor()
telegram_msg = ""
reply_markup = None
# 1. 收到非法刷卡訊號 (DENIED:)
if raw_data.startswith("DENIED:"):
uid = raw_data[7:]
user_name = get_user_name(uid)
cursor.execute(
"INSERT INTO access_logs (card_uid, user_name, action, timestamp)"
" VALUES (?, ?, ?, ?)",
(uid, user_name, "DENIED (非法刷卡拒絕)", now_str),
)
keyboard = [
[
InlineKeyboardButton(
"💻 授權-研發部", callback_data=f"AUTH:{uid}:研發部"
),
InlineKeyboardButton(
"🛠️ 授權-工程部", callback_data=f"AUTH:{uid}:工程部"
),
],
[
InlineKeyboardButton(
"📋 授權-行政部", callback_data=f"AUTH:{uid}:行政部"
),
InlineKeyboardButton(
"☕ 授權-訪客", callback_data=f"AUTH:{uid}:訪客"
),
],
]
reply_markup = InlineKeyboardMarkup(keyboard)
telegram_msg = (
f"🚨 ⚠️ **偵測到未知非法卡片嘗試開鎖** ⚠️\n"
f"卡號 UID: `{uid}`\n"
f"狀態: 拒絕通行 / 警報聲響起\n"
f"時間: {now_str}\n\n"
f"👉 **一鍵授權並遠端開鎖:** 請點擊下方部門按鈕:"
)
# 2. 入口合法進場 (AA:)
elif raw_data.startswith("AA:"):
uid = raw_data[3:]
user_name = get_user_name(uid)
cursor.execute(
"INSERT INTO access_logs (card_uid, user_name, action, timestamp)"
" VALUES (?, ?, ?, ?)",
(uid, user_name, "IN (進場)", now_str),
)
cursor.execute(
"INSERT OR REPLACE INTO inside_cards (card_uid, user_name,"
" entry_time) VALUES (?, ?, ?)",
(uid, user_name, now_str),
)
cursor.execute("SELECT COUNT(*) FROM inside_cards")
current_count = cursor.fetchone()[0]
broadcast_room_count()
telegram_msg = (
f"🟢 [門禁進場通知]\n"
f"姓名: {user_name}\n"
f"卡號: `{uid}`\n"
f"地點: 入口處 (CARD1)\n"
f"時間: {now_str}\n"
f"📊 當前場內總人數: {current_count} 人"
)
# 3. 出口合法出場 (BB:)
elif raw_data.startswith("BB:"):
uid = raw_data[3:]
user_name = get_user_name(uid)
cursor.execute(
"INSERT INTO access_logs (card_uid, user_name, action, timestamp)"
" VALUES (?, ?, ?, ?)",
(uid, user_name, "OUT (出場)", now_str),
)
cursor.execute("DELETE FROM inside_cards WHERE card_uid = ?", (uid,))
cursor.execute("SELECT COUNT(*) FROM inside_cards")
current_count = cursor.fetchone()[0]
broadcast_room_count()
telegram_msg = (
f"🔴 [門禁出場通知]\n"
f"姓名: {user_name}\n"
f"卡號: `{uid}`\n"
f"地點: 出口處 (CARD2)\n"
f"時間: {now_str}\n"
f"📊 當前場內總人數: {current_count} 人"
)
conn.commit()
conn.close()
if telegram_msg:
send_telegram_msg(telegram_msg, reply_markup=reply_markup)
self.root.after(0, self.refresh_ui)
def refresh_ui(self):
conn = sqlite3.connect("access_control.db")
cursor = conn.cursor()
for item in self.inside_tree.get_children():
self.inside_tree.delete(item)
cursor.execute(
"SELECT card_uid, user_name, entry_time FROM inside_cards ORDER BY"
" entry_time DESC"
)
for row in cursor.fetchall():
self.inside_tree.insert("", tk.END, values=row)
for item in self.log_tree.get_children():
self.log_tree.delete(item)
cursor.execute(
"SELECT id, card_uid, user_name, action, timestamp FROM access_logs"
" ORDER BY id DESC LIMIT 50"
)
for row in cursor.fetchall():
self.log_tree.insert("", tk.END, values=row)
for item in self.users_tree.get_children():
self.users_tree.delete(item)
cursor.execute(
"SELECT card_uid, name, department FROM users ORDER BY card_uid ASC"
)
for row in cursor.fetchall():
self.users_tree.insert("", tk.END, values=row)
cursor.execute("SELECT COUNT(*) FROM inside_cards")
count = cursor.fetchone()[0]
self.status_label.config(text=f"場內當前總人數: {count} 人")
conn.close()
# --- MQTT 背景服務 (broker.emqx.io) ---
def start_mqtt(app):
def on_connect(client, _userdata, _flags, _rc):
print("MQTT 監聽服務已啟動 (Broker: broker.emqx.io)...")
client.subscribe("wokwi/rfid/access")
client.subscribe("wokwi/rfid/sync_req")
def on_message(_client, _userdata, msg):
topic = msg.topic
payload = msg.payload.decode("utf-8").strip()
if topic == "wokwi/rfid/sync_req" and payload == "REQ_ALL":
print("\n📥 收到 ESP32 開機全量白名單同步請求 (REQ_ALL)")
send_full_whitelist_sync()
elif topic == "wokwi/rfid/access":
app.process_swipe(payload)
client = mqtt.Client()
client.on_connect = on_connect
client.on_message = on_message
client.connect("broker.emqx.io", 1883, 60)
client.loop_forever()
# ---------------------------------------------------------------------
# 主程式啟動入口 (開啟介面並發送 Telegram 啟動通知)
# ---------------------------------------------------------------------
if __name__ == "__main__":
root = tk.Tk()
app = AccessControlApp(root)
global_app = app
# 啟動 MQTT 與 Telegram 監聽線程
threading.Thread(target=start_mqtt, args=(app,), daemon=True).start()
threading.Thread(target=start_telegram_bot_listener, daemon=True).start()
# 向 Telegram 推播系統開機選單說明
startup_msg = (
"🚀 **【門禁中央監控系統已成功啟動】**\n"
"------------------------------------\n"
"🤖 **Telegram 系統操作指令選單:**\n\n"
"📊 `/status` : 查詢當前場內總人數與名單\n"
"🔓 `/unlock` : 隨時遠端開啟門鎖 3 秒\n"
"➕ `/add [UID] [姓名] [部門]` : 新增白名單成員\n"
"❌ `/del [UID]` : 刪除白名單成員\n"
"🧹 `/clear` : 手動清空當前場內名單\n\n"
"💡 *當現場偵測到非法刷卡時,系統將自動推播警報並附帶一鍵部門授權按鈕!*"
)
send_telegram_msg(startup_msg)
root.mainloop()




