ESP32 RFID User Management System (修正 新增/比對 模式)
系統邏輯設計
模式切換:在 Python 後端設定一個變數來紀錄當前模式(比對或新增)。
新增模式:ESP32 讀取 UID 後發送給伺服器,伺服器將 UID 傳送到網頁表單,並啟動 5 秒倒數,若無人存檔則設為 Unknown。
比對模式:即原本的邏輯,檢查白名單並回傳信號控制繼電器。
Python 後端程式整合了 MQTT 監聽、Flask 網頁伺服器、模式切換 API 以及 Tkinter 監控視窗。
請將此程式存為 User Management.py。
import paho.mqtt.client as mqtt
from flask import Flask, render_template, request, redirect, url_for, Response, jsonify
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"
# 系統全局狀態
current_mode = "compare" # 模式:'compare' (比對) 或 'add' (新增)
latest_uid = "" # 暫存新增模式下感應到的 UID
# 初始化檔案
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")
# 從 users.txt 查詢角色
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.lower(): return r
return "Unknown"
# --- MQTT 邏輯 ---
def on_message(client, userdata, msg):
global latest_uid, current_mode
uid = msg.payload.decode().lower().strip()
now = datetime.now()
if current_mode == "add":
# 新增模式:將 UID 傳給網頁,不觸發繼電器
latest_uid = uid
client.publish(TOPIC_CONTROL, "MODE_ADD")
log_msg = f"模式:新增 | 偵測 UID: {uid} (等待網頁存檔)"
else:
# 比對模式:檢查白名單
role = get_role(uid)
if role != "Unknown":
client.publish(TOPIC_CONTROL, "ON") # 通知 ESP32 開鎖
log_msg = f"模式:比對 | UID: {uid} | 角色: {role} -> 准許進入"
else:
client.publish(TOPIC_CONTROL, "OFF") # 通知 ESP32 拒絕/警報
log_msg = f"模式:比對 | UID: {uid} | 角色: 未知 -> 拒絕進入"
# 紀錄日誌到 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)
# 更新 GUI 視窗
if 'gui_log' in globals():
gui_log.insert(tk.END, f"[{now.strftime('%H:%M:%S')}] {log_msg}\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_page():
return render_template('add-user.html')
@app.route('/manage-users')
def manage_users():
return render_template('manage-users.html')
@app.route('/set_mode')
def set_mode():
global current_mode
mode = request.args.get('mode')
if mode in ['add', 'compare']:
current_mode = mode
return jsonify({"status": "success", "mode": current_mode})
@app.route('/get_latest_uid')
def get_uid():
global latest_uid
# 網頁端會持續輪詢此路徑
temp = latest_uid
latest_uid = "" # 讀取後清除,避免重複觸發倒數
return temp
@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} 權限: {role}")
# 處理刪除邏輯
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 GUI 視窗 ---
def run_gui():
global gui_log
root = tk.Tk()
root.title("RFID 系統監控後端")
root.geometry("500x450")
tk.Label(root, text="系統即時狀態紀錄", font=("Arial", 12, "bold")).pack(pady=5)
gui_log = scrolledtext.ScrolledText(root, width=60, height=20)
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", font=("Arial", 10, "underline"))
link_label.pack(pady=10)
link_label.bind("<Button-1>", lambda e: webbrowser.open_new(url))
root.mainloop()
# --- 主程式啟動 ---
if __name__ == '__main__':
init_files()
# 啟動 MQTT 執行緒
threading.Thread(target=mqtt_client.loop_forever, daemon=True).start()
# 啟動 Flask 執行緒
threading.Thread(target=lambda: app.run(port=5000, debug=False, use_reloader=False), daemon=True).start()
# 啟動 GUI (主執行緒)
run_gui()
程式邏輯說明:
/set_modeAPI:讓前端網頁的按鈕可以切換current_mode。/get_latest_uidAPI:前端網頁透過此 API 獲取剛才在 ESP32 刷下的卡號。on_message判斷:在 新增模式 下,它只會將卡號存入
latest_uid並通知 ESP32 顯示「模式:新增中」,不會去檢查白名單或開鎖。在 比對模式 下,它會比對
users.txt。若 UID 存在則發送ON(觸發繼電器),不存在則發送OFF(警報)。
Tkinter 視窗:提供管理員即時查看後端正在進行「比對」還是「新增」,並顯示刷卡結果。
ESP32 程式修正 (控制繼電器與警示)
修改 callback 函式來處理繼電器與警示聲(假設繼電器在 GPIO 4)。
系統運作說明:
新增模式:管理員在網頁點擊「進入新增模式」,此時刷卡 UID 會自動跳轉到網頁的輸入框,網頁啟動 5 秒倒數。若管理員沒選 User1~6,時間一到網頁會自動以
Unknown角色存檔。比對模式:系統預設狀態。刷卡後 Python 檢查
users.txt,若 UID 存在,發送ON給 ESP32。硬體執行:ESP32 收到
ON後將RELAY_PIN設為高電位(打開門鎖);若收到OFF則觸發BUZZER_PIN警報。
前端網頁介面 (add-user.html)
我們在表單上方加入「模式切換按鈕」,並利用 JavaScript 實作 5 秒倒數功能。
<section class="main-section">
<h2>🎮 系統控制面板</h2>
<div class="mode-selector" style="margin-bottom: 25px; padding: 15px; background: #eee; border-radius: 8px;">
<p><strong>切換運作模式:</strong></p>
<button onclick="changeMode('compare')" id="btn-compare" class="button" style="background-color: #555;">🔍 比對模式 (監控中)</button>
<button onclick="changeMode('add')" id="btn-add" class="button" style="background-color: #28a745;">➕ 進入新增模式</button>
<p id="current-status" style="margin-top: 10px; color: blue;">當前狀態:比對模式</p>
</div>
<div id="timer-box" style="color: red; font-weight: bold; font-size: 1.2rem; height: 30px;"></div>
<form action="get" id="add-user-form" class="user-form">
<label for="uid">感應到的 UID</label>
<input type="text" id="uid" name="uid" placeholder="請在新增模式下刷卡..." readonly 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>
<option value="unknown">Unknown</option>
</select>
<button type="submit" style="background-color: #007bff;">✅ 確認存檔</button>
</form>
</section>
<script>
let countdownTimer;
// 1. 切換模式
async function changeMode(mode) {
await fetch(`/set_mode?mode=${mode}`);
const statusText = document.getElementById('current-status');
if(mode === 'add') {
statusText.innerText = "當前狀態:新增模式 (請到設備刷卡)";
statusText.style.color = "green";
} else {
statusText.innerText = "當前狀態:比對模式";
statusText.style.color = "blue";
document.getElementById('timer-box').innerText = "";
clearInterval(countdownTimer);
}
}
// 2. 輪詢後端是否有新的 UID 刷入
setInterval(async () => {
const response = await fetch('/get_latest_uid');
const uid = await response.text();
if (uid && uid.length > 0) {
document.getElementById('uid').value = uid;
startAutoSaveTimer();
}
}, 1000);
// 3. 5秒倒數邏輯
function startAutoSaveTimer() {
let seconds = 5;
clearInterval(countdownTimer);
const timerBox = document.getElementById('timer-display');
countdownTimer = setInterval(() => {
document.getElementById('timer-box').innerText = `⏱️ 偵測到新卡片!將在 ${seconds} 秒後自動存為 Unknown...`;
if (seconds <= 0) {
clearInterval(countdownTimer);
// 如果管理員沒改選,就設為 unknown 並自動送出
const roleSelect = document.getElementById('role');
if (roleSelect.value === "admin") { // 假設預設是 admin,沒動就變 unknown
roleSelect.value = "unknown";
}
document.getElementById('add-user-form').submit();
}
seconds--;
}, 1000);
}
</script>
3. 操作流程說明
進入新增模式:在網頁點擊「進入新增模式」按鈕。
刷卡感應:拿一張新卡片去感應 ESP32。
自動填入:網頁會透過每秒一次的
fetch抓到這個 UID,並自動填入input欄位。倒數與決策:
手動操作:管理員有 5 秒鐘時間可以從下拉選單選擇
User1到User6,然後按「確認存檔」。自動超時:如果 5 秒內沒人理它,JavaScript 會強行把 Role 改成
Unknown並自動觸發submit()存入users.txt。
返回比對:存檔成功後,點擊「返回比對模式」,系統恢復正常的門禁開鎖功能。
這樣您的「控制面板」就真正具備了遠端操控硬體行為的能力。






















