2026年4月27日 星期一

ESP32 RFID User Management System (修正 新增/比對 模式)

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()

程式邏輯說明:

  1. /set_mode API:讓前端網頁的按鈕可以切換 current_mode

  2. /get_latest_uid API:前端網頁透過此 API 獲取剛才在 ESP32 刷下的卡號。

  3. on_message 判斷

    • 新增模式 下,它只會將卡號存入 latest_uid 並通知 ESP32 顯示「模式:新增中」,不會去檢查白名單或開鎖。

    • 比對模式 下,它會比對 users.txt。若 UID 存在則發送 ON (觸發繼電器),不存在則發送 OFF (警報)。

  4. Tkinter 視窗:提供管理員即時查看後端正在進行「比對」還是「新增」,並顯示刷卡結果。


ESP32 程式修正 (控制繼電器與警示)

修改 callback 函式來處理繼電器與警示聲(假設繼電器在 GPIO 4)。


#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 RELAY_PIN 4
#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);

void callback(char* topic, byte* payload, unsigned int length) {
  String msg = "";
  for (int i = 0; i < length; i++) msg += (char)payload[i];
 
  Serial.print("收到指令: "); Serial.println(msg);
  lcd.setCursor(0, 1);
 
  if (msg == "ON") {
    digitalWrite(RELAY_PIN, HIGH); // 打開電動鎖
    digitalWrite(LED_PIN, HIGH);
    lcd.print("Access: Granted ");
    delay(3000);
    digitalWrite(RELAY_PIN, LOW); // 自動關鎖
    digitalWrite(LED_PIN, LOW);
  } else if (msg == "OFF") {
    //digitalWrite(BUZZER_PIN, HIGH); // 報警聲
    lcd.print("Access: Denied  ");
    delay(500);
    //digitalWrite(BUZZER_PIN, LOW);
  } else if (msg == "MODE_ADD") {
    lcd.print("Mode: Registering");
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(LED_PIN, OUTPUT);
  pinMode(RELAY_PIN, OUTPUT);
  //pinMode(BUZZER_PIN, OUTPUT);
 
  Wire.begin(I2C_SDA, I2C_SCL);
  lcd.init(); lcd.backlight();
 
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
  lcd.clear(); lcd.print("Ready to Scan");

  SPI.begin();
  mfrc522.PCD_Init();
  client.setServer(mqtt_server, 1883);
  client.setCallback(callback);
}

void reconnect() {
  while (!client.connected()) {
    if (client.connect("ESP32_RFID_alex9ufo")) {
      client.subscribe(topic_control);
    } else { delay(5000); }
  }
}

void loop() {
  if (!client.connected()) reconnect();
  client.loop();

  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.setCursor(0, 0);
    lcd.print("UID: " + uid);
    Serial.println("偵測 UID: " + uid);
    client.publish(topic_detect, uid.c_str());
    mfrc522.PICC_HaltA();
    delay(2000);
  }
}

系統運作說明:

  1. 新增模式:管理員在網頁點擊「進入新增模式」,此時刷卡 UID 會自動跳轉到網頁的輸入框,網頁啟動 5 秒倒數。若管理員沒選 User1~6,時間一到網頁會自動以 Unknown 角色存檔。

  2. 比對模式:系統預設狀態。刷卡後 Python 檢查 users.txt,若 UID 存在,發送 ON 給 ESP32。

  3. 硬體執行:ESP32 收到 ON 後將 RELAY_PIN 設為高電位(打開門鎖);若收到 OFF 則觸發 BUZZER_PIN 警報。


前端網頁介面 (add-user.html)

我們在表單上方加入「模式切換按鈕」,並利用 JavaScript 實作 5 秒倒數功能。

HTML
<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. 操作流程說明

  1. 進入新增模式:在網頁點擊「進入新增模式」按鈕。

  2. 刷卡感應:拿一張新卡片去感應 ESP32。

  3. 自動填入:網頁會透過每秒一次的 fetch 抓到這個 UID,並自動填入 input 欄位。

  4. 倒數與決策

    • 手動操作:管理員有 5 秒鐘時間可以從下拉選單選擇 User1User6,然後按「確認存檔」。

    • 自動超時:如果 5 秒內沒人理它,JavaScript 會強行把 Role 改成 Unknown 並自動觸發 submit() 存入 users.txt

  5. 返回比對:存檔成功後,點擊「返回比對模式」,系統恢復正常的門禁開鎖功能。

這樣您的「控制面板」就真正具備了遠端操控硬體行為的能力。

沒有留言:

張貼留言

ESP32 RFID User Management System (修正 新增/比對 模式)

ESP32 RFID User Management System (修正  新增/比對 模式) 系統邏輯設計 模式切換 :在 Python 後端設定一個變數來紀錄當前模式(比對或新增)。 新增模式 :ESP32 讀取 UID 後發送給伺服器,伺服器將 UID 傳送到網頁表單,並...