ESP32 RFID User Management System
參考來源 :https://randomnerdtutorials.com/esp32-rfid-user-management-web-server/
這個系統採用了 「邊緣感應、雲端/中控邏輯、網頁管理」 的三層架構,利用 MQTT 協議實現硬體與軟體之間的非同步通訊。
這種架構最大的優點在於解耦(Decoupling):ESP32 不需要知道使用者名單,只需要負責讀取硬體訊號;所有的權限判斷、檔案讀寫與網頁顯示都交由效能更強的 Python 伺服器處理。
系統架構組成
1. 硬體邊緣端 (ESP32 + RFID + LCD)
這是系統的「眼睛」與「手」,負責第一線的物理交互。
2. 通訊中繼站 (MQTT Broker)
使用 broker.emqx.io 作為訊息交換中心。它就像一個郵局,負責把 ESP32 發出的 UID 傳遞給 Python,並把 Python 的控制指令傳回給 ESP32。
3. 後端管理中心 (Python Flask + Tkinter)
這是系統的「大腦」,負責運算與資料管理。
MQTT 監聽執行緒:全天候等待 UID 訊號。收到後去比對 users.txt,判斷權限後立即發回控制指令。
Flask Web Server:
資料儲存:使用簡單輕量、易於讀取的純文字檔 (log.txt, users.txt)。
Tkinter GUI:提供管理員一個即時監控視窗,並附帶一個快速開啟管理網頁的超連結。
4. 前端網頁介面 (HTML/CSS/JS)
這是使用者的「控制面板」。
即時日誌 (Full Log):透過 JavaScript 自動從後端抓取 log.txt 並畫成表格。
用戶管理 (Manage Users):顯示現有用戶清單,並提供刪除特定用戶的按鈕。
新增用戶 (Add User):提供下拉選單(Admin, User1~User6),將 UID 與 Role 寫入系統。
運作流程圖解
刷卡階段:
判斷階段:
執行與紀錄階段:
硬體回饋階段:
管理階段:
資料夾結構複習
為了讓這個架構跑起來,您的檔案必須按此邏輯存放:
在此架構中,MQTT 扮演了硬體與軟體之間的橋樑。
ESP32 Arduino 程式 (負責感應與 MQTT 通訊)
此程式會讀取 RFID 標籤,並透過 MQTT 主題alex9ufo/rfid/detect 發送 UID。同時監聽 alex9ufo/rfid/control 以接收來自 Python 端的回饋(例如開門或 LED 提示)。
#include <SPI.h>
#include <MFRC522.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
// 腳位與設定
#define I2C_SDA 17
#define I2C_SCL 16
#define LED_PIN 2
#define SS_PIN 5
#define RST_PIN 22
LiquidCrystal_I2C lcd(0x27, 16, 2);
MFRC522 mfrc522(SS_PIN, RST_PIN);
const char* ssid = "Wokwi-GUEST";
const char* password = "";
const char* mqtt_server = "broker.emqx.io";
const char* topic_detect = "alex9ufo/rfid/detect";
const char* topic_control = "alex9ufo/rfid/control";
WiFiClient espClient;
PubSubClient client(espClient);
// 接收來自 Python 的指令
void callback(char* topic, byte* payload, unsigned int length) {
String msg = "";
for (int i = 0; i < length; i++) msg += (char)payload[i];
// 輸出到 Serial Monitor
Serial.print("收到控制指令: ");
Serial.println(msg);
lcd.setCursor(0, 1);
if (msg == "ON") {
digitalWrite(LED_PIN, HIGH);
lcd.print("Access: Granted ");
Serial.println("狀態: 允許進入 (LED亮)");
} else {
digitalWrite(LED_PIN, LOW);
lcd.print("Access: Denied ");
Serial.println("狀態: 拒絕進入 (LED滅)");
}
}
void setup() {
Serial.begin(115200);
Serial.println("系統初始化中...");
pinMode(LED_PIN, OUTPUT);
Wire.begin(I2C_SDA, I2C_SCL);
lcd.init();
lcd.backlight();
lcd.print("WiFi Connecting");
Serial.print("正在連接 WiFi: ");
Serial.println(ssid);
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWiFi 連線成功!");
Serial.print("IP 地址: ");
Serial.println(WiFi.localIP());
lcd.clear();
lcd.print("WiFi Connected!");
delay(1000);
lcd.clear();
lcd.print("Ready to Scan");
Serial.println("等待 RFID 感應...");
SPI.begin();
mfrc522.PCD_Init();
client.setServer(mqtt_server, 1883);
client.setCallback(callback);
}
void reconnect() {
while (!client.connected()) {
Serial.print("嘗試連接 MQTT Broker...");
if (client.connect("ESP32_RFID_alex9ufo")) {
Serial.println("成功連線!");
client.subscribe(topic_control);
Serial.print("已訂閱主題: ");
Serial.println(topic_control);
} else {
Serial.print("失敗, rc=");
Serial.print(client.state());
Serial.println(" 5秒後重試");
delay(5000);
}
}
}
void loop() {
if (!client.connected()) reconnect();
client.loop();
// 偵測 RFID 卡片
if (mfrc522.PICC_IsNewCardPresent() && mfrc522.PICC_ReadCardSerial()) {
String uid = "";
for (byte i = 0; i < mfrc522.uid.size; i++) {
uid += String(mfrc522.uid.uidByte[i] < 0x10 ? "0" : "");
uid += String(mfrc522.uid.uidByte[i], HEX);
}
// 輸出到 LCD
lcd.setCursor(0, 0);
lcd.print("UID: " + uid);
// 輸出到 Serial Monitor
Serial.print("\n偵測到卡片! UID: ");
Serial.println(uid);
// 發送到 MQTT
Serial.print("發送至 MQTT: ");
Serial.println(topic_detect);
client.publish(topic_detect, uid.c_str());
mfrc522.PICC_HaltA();
delay(2000); // 避免短時間重複感應
}
}
這套系統已經完整整合了 ESP32 (硬體端) 與 Python Flask + Tkinter (伺服器端)。
1. 檔案目錄結構 (重要)
請確保您的電腦資料夾結構如下,否則 Flask 會找不到網頁檔案:
D:\2026 RFID\ESP32 RFID User Management System\
├── User Management.py (Python的程式)
├── log.txt
├── users.txt
├── static/
│ └── style.css
└── templates/
├── full-log.html
├── add-user.html
├── manage-users.html
└── get.html
Copy the following to the full-log.html file.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Manage Users</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<nav>
<div class="nav-container">
<a href="/" class="brand">User Management</a>
<ul class="nav-menu">
<li><a href="/">📄 Full Log</a></li>
<li><a href="add-user">➕ Add User</a></li>
<li><a href="manage-users">👤 Manage Users</a></li>
</ul>
</div>
</nav>
<div class="main-container">
<section class="main-section">
<h2>📄 Full Access Log</h2>
<table id="tableData">
<thead>
<tr>
<th>Date</th>
<th>Time</th>
<th>UID</th>
<th>Role</th>
</tr>
</thead>
<tbody>
<!-- Data from log.txt will be loaded here -->
</tbody>
</table>
</section>
</div>
<div class="main-container">
<a href="get?delete=log"><button class="button button-delete">🗑️ Delete log.txt File</button></a>
</div>
<script>
// JavaScript to load and parse log.txt
async function loadTableData() {
try {
const response = await fetch('view-log');
const data = await response.text();
const rows = data.trim().split('\n').slice(1); // Skip the header line
const tableBody = document.querySelector('#tableData tbody');
rows.forEach(row => {
const columns = row.split(',');
const tr = document.createElement('tr');
columns.forEach(column => {
const td = document.createElement('td');
td.textContent = column;
tr.appendChild(td);
});
tableBody.appendChild(tr);
});
} catch (error) {
console.error('Error loading log data:', error);
}
}
// Call the function to load log data
loadTableData();
</script>
</body>
</html>
Copy the following to the manage-users.html file.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Manage Users</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<nav>
<div class="nav-container">
<a href="/" class="brand">User Management</a>
<ul class="nav-menu">
<li><a href="/">📄 Full Log</a></li>
<li><a href="add-user">➕ Add User</a></li>
<li><a href="manage-users">👤 Manage Users</a></li>
</ul>
</div>
</nav>
<div class="main-container">
<section class="main-section">
<h2>👤 User Log</h2>
<table id="tableData">
<thead>
<tr>
<th>UID</th>
<th>Role</th>
<th>Delete</th>
</tr>
</thead>
<tbody>
<!-- Data from users.txt will be loaded here -->
</tbody>
</table>
</section>
</div>
<div class="main-container">
<a href="get?delete=users"><button class="button button-delete">🗑️ Delete users.txt File</button></a>
</div>
<script>
// JavaScript to load and parse users.txt
async function loadTableData() {
try {
const response = await fetch('view-users');
const data = await response.text();
const rows = data.trim().split('\n').slice(1); // Skip the header line
const tableBody = document.querySelector('#tableData tbody');
rows.forEach((row, index) => {
const columns = row.split(',');
const tr = document.createElement('tr');
// Add remaining columns
columns.forEach(column => {
const td = document.createElement('td');
td.textContent = column;
tr.appendChild(td);
});
// Create and add row number cell with a delete link
const noCell = document.createElement('td');
const deleteLink = document.createElement('a');
deleteLink.href = `get?delete-user=${index + 1}`;
deleteLink.textContent = "❌ Delete User #" + (index + 1);
noCell.appendChild(deleteLink);
tr.appendChild(noCell);
tableBody.appendChild(tr);
});
} catch (error) {
console.error('Error loading log data:', error);
}
}
// Call the function to load log data
loadTableData();
</script>
</body>
</html>
Copy the following to the add-user.html file.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Add User</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<nav>
<div class="nav-container">
<a href="/" class="brand">User Management</a>
<ul class="nav-menu">
<li><a href="/">📄 Full Log</a></li>
<li><a href="add-user">➕ Add User</a></li>
<li><a href="manage-users">👤 Manage Users</a></li>
</ul>
</div>
</nav>
<div class="main-container">
<section class="main-section">
<h2>➕ Add User</h2>
<p>Enter the UID in lower case letters and no spaces.</p><br>
<form action="get" class="user-form">
<label for="uid">UID</label>
<input type="text" id="uid" name="uid" required>
<label for="role">Role</label>
<select id="role" name="role">
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
<button type="submit">✅ Save</button>
</form>
</section>
</div>
</body>
</html>
Copy the following to the get.html file.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Add User</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<nav>
<div class="nav-container">
<a href="/" class="brand">User Management</a>
<ul class="nav-menu">
<li><a href="/">📄 Full Log</a></li>
<li><a href="add-user">➕ Add User</a></li>
<li><a href="manage-users">👤 Manage Users</a></li>
</ul>
</div>
</nav>
<div class="main-container">
<section class="main-section">
<p>%inputmessage%</p>
</section>
</div>
</body>
</html>
Copy the following to the style.css file.
/* General Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
background-color: #f4f4f9;
color: #333;
display: flex;
flex-direction: column;
align-items: center;
height: 100vh;
margin: 0;
}
/* Navigation Bar Styles */
nav {
width: 100%;
background-color: #333;
padding: 1rem 0;
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 1rem;
}
.brand {
color: #fff;
text-decoration: none;
font-size: 1.5rem;
font-weight: bold;
}
.nav-menu {
list-style-type: none;
display: flex;
}
.nav-menu li {
margin-left: 1.5rem;
}
.nav-menu a {
color: #fff;
text-decoration: none;
font-size: 1rem;
transition: color 0.3s;
}
.nav-menu a:hover, .nav-menu a.active {
color: #f4f4f9;
}
.main-container {
display: flex;
justify-content: center;
align-items: center;
flex-grow: 1;
width: 100%;
}
.main-section {
max-width: 500px;
padding: 2rem;
background-color: #fff;
border-radius: 5px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
text-align: center;
}
.main-section h2 {
margin-bottom: 1rem;
color: #333;
}
.user-form label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
color: #333;
}
.user-form input, .user-form select {
width: 100%;
padding: 0.5rem;
margin-bottom: 1rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.user-form button {
width: 100%;
padding: 0.7rem;
background-color: #333;
color: #fff;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.3s;
}
.user-form button:hover {
background-color: #555;
}
.button {
display: inline-block;
padding: 10px 20px;
margin: 10px;
font-size: 16px;
border: none;
border-radius: 5px;
cursor: pointer;
transition-duration: 0.4s;
}
.button-delete {
background-color: #780320;
color: #fff;
}
.button-home {
background-color: #333;
color: #fff;
}
#tableData {
font-family: Arial, Helvetica, sans-serif;
border-collapse: collapse;
width: 100%;
}
#tableData td, #tableData th {
border: 1px solid #ddd;
padding: 8px;
}
#tableData tr:nth-child(even) {
background-color: #f2f2f2;
}
#tableData tr:hover {
background-color: #ddd;
}
#tableData th {
padding-top: 12px;
padding-bottom: 12px;
text-align: left;
background-color: #1f1f1f;
color: white;
}
2. Python 伺服器程式 (User Management.py)
這段程式整合了 MQTT 監聽、Flask 網頁伺服器,以及一個可點擊超連結的 Tkinter 視窗。
import paho.mqtt.client as mqtt
from flask import Flask, render_template, request, redirect, url_for, Response
import threading
import os
import webbrowser
from datetime import datetime
import tkinter as tk
from tkinter import scrolledtext, font as tkfont
# --- 基礎設定 ---
app = Flask(__name__)
LOG_FILE = "log.txt"
USER_FILE = "users.txt"
MQTT_BROKER = "broker.emqx.io"
TOPIC_DETECT = "alex9ufo/rfid/detect"
TOPIC_CONTROL = "alex9ufo/rfid/control"
# 初始化檔案
def init_files():
if not os.path.exists(LOG_FILE):
with open(LOG_FILE, "w", encoding="utf-8") as f: f.write("Date,Time,UID,Role\n")
if not os.path.exists(USER_FILE):
with open(USER_FILE, "w", encoding="utf-8") as f: f.write("UID,Role\n")
# 查詢使用者權限
def get_role(uid):
if os.path.exists(USER_FILE):
with open(USER_FILE, "r", encoding="utf-8") as f:
for line in f.readlines()[1:]:
if "," in line:
u, r = line.strip().split(',')
if u == uid: return r
return "Unknown"
# --- MQTT 邏輯 ---
def on_message(client, userdata, msg):
uid = msg.payload.decode().lower().strip()
role = get_role(uid)
now = datetime.now()
# 紀錄到 log.txt
log_entry = f"{now.strftime('%Y-%m-%d')},{now.strftime('%H:%M:%S')},{uid},{role}\n"
with open(LOG_FILE, "a", encoding="utf-8") as f: f.write(log_entry)
# 權限判斷回傳給 ESP32 (控制 LED)
# 只有在 users.txt 裡的已知用戶才發送 ON
response_msg = "ON" if role != "Unknown" else "OFF"
client.publish(TOPIC_CONTROL, response_msg)
# 更新 GUI 顯示
if 'gui_log' in globals():
gui_log.insert(tk.END, f"[{now.strftime('%H:%M:%S')}] 偵測: {uid} -> {role}\n")
gui_log.see(tk.END)
mqtt_client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
mqtt_client.on_message = on_message
mqtt_client.connect(MQTT_BROKER, 1883)
mqtt_client.subscribe(TOPIC_DETECT)
# --- Flask 路由 ---
@app.route('/')
def index(): return render_template('full-log.html')
@app.route('/add-user')
def add_user(): return render_template('add-user.html')
@app.route('/manage-users')
def manage_users(): return render_template('manage-users.html')
@app.route('/view-log')
def view_log():
with open(LOG_FILE, "r", encoding="utf-8") as f: return Response(f.read(), mimetype='text/plain')
@app.route('/view-users')
def view_users():
with open(USER_FILE, "r", encoding="utf-8") as f: return Response(f.read(), mimetype='text/plain')
@app.route('/get')
def handle_get():
# 處理新增用戶
uid = request.args.get('uid')
role = request.args.get('role')
if uid and role:
with open(USER_FILE, "a", encoding="utf-8") as f: f.write(f"{uid.lower().strip()},{role}\n")
return render_template('get.html', inputmessage=f"成功新增用戶: {uid}")
# 處理刪除功能
target = request.args.get('delete')
if target == 'log':
with open(LOG_FILE, "w", encoding="utf-8") as f: f.write("Date,Time,UID,Role\n")
return redirect(url_for('index'))
elif target == 'users':
with open(USER_FILE, "w", encoding="utf-8") as f: f.write("UID,Role\n")
return redirect(url_for('manage_users'))
# 處理單一用戶刪除
user_idx = request.args.get('delete-user')
if user_idx:
with open(USER_FILE, "r", encoding="utf-8") as f: lines = f.readlines()
idx = int(user_idx)
if 0 < idx < len(lines):
del lines[idx]
with open(USER_FILE, "w", encoding="utf-8") as f: f.writelines(lines)
return redirect(url_for('manage_users'))
return redirect(url_for('index'))
# --- Tkinter 視窗與超連結 ---
def run_gui():
global gui_log
root = tk.Tk()
root.title("RFID MQTT 監控系統")
root.geometry("450x400")
tk.Label(root, text="即時刷卡紀錄", font=("Arial", 12, "bold")).pack(pady=5)
gui_log = scrolledtext.ScrolledText(root, width=50, height=15)
gui_log.pack(padx=10, pady=10)
url = "http://127.0.0.1:5000"
link_label = tk.Label(root, text=f"點此開啟網頁管理介面\n{url}", fg="blue", cursor="hand2")
link_label.pack(pady=10)
# 點擊開啟瀏覽器
def open_url(event): webbrowser.open_new(url)
link_label.bind("<Button-1>", open_url)
# 字體底線效果
f = tkfont.Font(link_label, link_label.cget("font"))
f.configure(underline=True)
link_label.configure(font=f)
root.mainloop()
if __name__ == '__main__':
init_files()
threading.Thread(target=mqtt_client.loop_forever, daemon=True).start()
threading.Thread(target=lambda: app.run(port=5000, debug=False, use_reloader=False), daemon=True).start()
run_gui()









新增 Users
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Add User</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<nav>
<div class="nav-container">
<a href="/" class="brand">User Management</a>
<ul class="nav-menu">
<li><a href="/">📄 Full Log</a></li>
<li><a href="add-user">➕ Add User</a></li>
<li><a href="manage-users">👤 Manage Users</a></li>
</ul>
</div>
</nav>
<div class="main-container">
<section class="main-section">
<h2>➕ Add User</h2>
<p>Enter the UID in lower case letters and no spaces.</p><br>
<form action="get" class="user-form">
<label for="uid">UID</label>
<input type="text" id="uid" name="uid" required>
<label for="role">Role</label>
<select id="role" name="role">
<option value="admin">Admin</option>
<option value="user1">User1</option>
<option value="user2">User2</option>
<option value="user3">User3</option>
<option value="user4">User4</option>
<option value="user5">User5</option>
<option value="user6">User6</option>
</select>
<button type="submit">✅ Save</button>
</form>
</section>
</div>
</body>
</html>
