2026年4月28日 星期二

WOKWI board-mfrc522 RFID Reader

WOKWI board-mfrc522 RFID Reader




 #include <SPI.h>

#include <MFRC522.h>

#define SS_PIN  5
#define RST_PIN 21

MFRC522 rfid(SS_PIN, RST_PIN);

void setup() {
  Serial.begin(115200);
  SPI.begin();
  rfid.PCD_Init();
  Serial.println("MFRC522 Ready");
}

void loop() {
  delay(10);
  if (!rfid.PICC_IsNewCardPresent()) {
    return;
  }

  if (!rfid.PICC_ReadCardSerial()) {
    return;
  }

  Serial.print("UID:");
  for (byte i = 0; i < rfid.uid.size; i++) {
    Serial.print(rfid.uid.uidByte[i] < 0x10 ? ":0" : ":");
    Serial.print(rfid.uid.uidByte[i], HEX);
  }
  Serial.println();

  rfid.PICC_HaltA();
  delay(1000);
}


board-mfrc522 RFID Reader

MFRC522 RFID/NFC reader module with SPI interface for reading 13.56 MHz MIFARE cards and tags.

board-mfrc522

Pin names

NameDescription
3.3VVoltage supply (3.3V)
RSTReset (active low)
GNDGround
IRQInterrupt request (active low)
MISOSPI data out
MOSISPI data in
SCKSPI clock
SDASPI chip select (active low)

Attributes

NameDescriptionDefault value
uidCustom UID (for Blue Card only)""

UID format

The UID can be specified in the following lengths:

  • 4 bytes: 01:02:03:04 (MIFARE Classic)
  • 7 bytes: 04:11:22:33:44:55:66 (MIFARE Ultralight)

Each byte must be two hexadecimal characters (00-FF), separated by colons.

Card presets

The simulator provides built-in card presets for quick testing. Click on the MFRC522 to open the control panel where you can select a card:

IndexPresetColorUIDCard Type
0Blue CardBlue01:02:03:04MIFARE Classic 1K
1Green CardGreen11:22:33:44MIFARE Classic 1K
2Yellow CardYellow55:66:77:88MIFARE Classic 1K
3Red CardRedAA:BB:CC:DDMIFARE Classic 1K
4NFC TagGray04:11:22:33:44:55:66MIFARE Ultralight
5Key FobOrangeC0:FF:EE:99MIFARE Mini

The uid attribute only affects the Blue Card (index 0). Other cards always use their preset UIDs. This lets you customize your main card while keeping the others for multi-user testing.

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





參考來源 :https://randomnerdtutorials.com/esp32-rfid-user-management-web-server/

這個系統採用了 「邊緣感應、雲端/中控邏輯、網頁管理」 的三層架構,利用 MQTT 協議實現硬體與軟體之間的非同步通訊。

這種架構最大的優點在於解耦(Decoupling):ESP32 不需要知道使用者名單,只需要負責讀取硬體訊號;所有的權限判斷、檔案讀寫與網頁顯示都交由效能更強的 Python 伺服器處理。


系統架構組成

1. 硬體邊緣端 (ESP32 + RFID + LCD)

這是系統的「眼睛」與「手」,負責第一線的物理交互。

  • 感應 (RFID MFRC522):不斷掃描是否有卡片靠近,讀取 UID。

  • 通訊 (MQTT)

    • 發送:將讀取到的 UID 發布到 alex9ufo/rfid/detect

    • 接收:訂閱 alex9ufo/rfid/control,接收來自 Python 的 ON/OFF 指令。

  • 回饋 (LCD & LED):根據收到的指令,在 16x2 LCD 顯示「Access Granted」或「Denied」,並切換 GPIO 2 的 LED 亮滅。

2. 通訊中繼站 (MQTT Broker)

使用 broker.emqx.io 作為訊息交換中心。它就像一個郵局,負責把 ESP32 發出的 UID 傳遞給 Python,並把 Python 的控制指令傳回給 ESP32。

3. 後端管理中心 (Python Flask + Tkinter)

這是系統的「大腦」,負責運算與資料管理。

  • MQTT 監聽執行緒:全天候等待 UID 訊號。收到後去比對 users.txt,判斷權限後立即發回控制指令。

  • Flask Web Server

    • 提供 API (/view-log, /view-users) 讓前端讀取資料。

    • 處理邏輯 (/get),執行新增用戶、刪除日誌等檔案操作。

  • 資料儲存:使用簡單輕量、易於讀取的純文字檔 (log.txt, users.txt)。

  • Tkinter GUI:提供管理員一個即時監控視窗,並附帶一個快速開啟管理網頁的超連結。

4. 前端網頁介面 (HTML/CSS/JS)

這是使用者的「控制面板」。

  • 即時日誌 (Full Log):透過 JavaScript 自動從後端抓取 log.txt 並畫成表格。

  • 用戶管理 (Manage Users):顯示現有用戶清單,並提供刪除特定用戶的按鈕。

  • 新增用戶 (Add User):提供下拉選單(Admin, User1~User6),將 UID 與 Role 寫入系統。


運作流程圖解

  1. 刷卡階段

    • 使用者刷卡 $\rightarrow$ ESP32 讀取 UID $\rightarrow$ 透過 MQTT 發布 alex9ufo/rfid/detect

  2. 判斷階段

    • Python 接收到 UID $\rightarrow$ 查詢 users.txt $\rightarrow$ 判斷 Role (Admin/UserX/Unknown)。

  3. 執行與紀錄階段

    • Python 寫入 log.txt $\rightarrow$ 發布 ONOFFalex9ufo/rfid/control

    • 同時 Python 的 Tkinter 視窗顯示偵測紀錄。

  4. 硬體回饋階段

    • ESP32 收到 ON $\rightarrow$ LED 亮起、LCD 顯示 Welcome。

  5. 管理階段

    • 管理員打開網頁查看 log.txt 的統計結果。


資料夾結構複習

為了讓這個架構跑起來,您的檔案必須按此邏輯存放:

  • 主目錄.py 程式、.txt 資料檔。

  • templates/:存放所有的 .html 檔案。

  • static/:存放 style.css


在此架構中,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 會找不到網頁檔案

  • 📁 ESP32 RFID User Management System

    • 📄 User Management.py (下方的 Python 程式)

    • 📄 log.txt (自動產生)

    • 📄 users.txt (自動產生)

    • 📁 static

      • 📄 style.css (您上傳的 CSS 檔)

    • 📁 templates

      • 📄 full-log.html (您上傳的第 1 個 HTML)

      • 📄 manage-users.html (您上傳的第 2 個 HTML)

      • 📄 add-user.html (您上傳的第 3 個 HTML)

      • 📄 get.html (您上傳的第 4 個 HTML)





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>



WOKWI board-mfrc522 RFID Reader

WOKWI board-mfrc522 RFID Reader MFRC522 Basic Reading  - Basic RFID card detection with ESP32   #include < SPI.h > #include < MFR...