1. 项目概述与核心价值

最近在折腾一个挺有意思的玩意儿:用ESP32和RFID模块自己动手做一套智能门锁系统。这项目听起来可能有点硬核,但实际做下来,你会发现它完美融合了硬件、嵌入式软件和移动端开发,是个绝佳的 物联网 入门实战案例。传统的机械锁钥匙容易丢、配钥匙麻烦,而市面上成熟的智能锁产品又是个“黑盒”,你很难知道里面是怎么跑的。自己从零搭建一套,不仅能完全掌控安全逻辑,还能根据需求灵活添加功能,比如远程授权、开门记录查询,甚至和家里的其他智能设备联动。

这套系统的核心思路很清晰:用 ESP32 作为大脑,它负责运行逻辑、连接网络;用 RFID RC522 模块作为“眼睛”,识别用户手中的卡片(UID卡);用一个电磁锁(Solenoid Lock)作为执行机构,控制门的开关;最后,再用 Flutter 写个手机App,通过 Firebase 这个云端后台来管理用户卡片、查看历史记录。这样一来,就构成了一个从物理感知(刷卡)到本地决策(ESP32验证),再到云端同步(Firebase)和数据展示(App)的完整闭环。无论你是嵌入式爱好者想深入玩转ESP32,还是移动端开发者想了解如何与硬件交互,亦或是物联网初学者想体验全栈开发流程,这个项目都能给你带来实实在在的收获。接下来,我就把从硬件选型、电路连接、代码编写到App集成的全过程,以及中间踩过的坑和总结的经验,毫无保留地分享出来。

2. 系统整体设计与核心组件选型解析

做一个项目,最忌讳的就是拿到零件就开干。先花点时间把整体架构和每个组件为什么选它想明白,后面会省力很多。这套智能门锁系统,我们可以把它拆解成几个清晰的层次。

2.1 硬件层:感知、控制与执行

硬件是整个系统的物理基础,选型直接决定了系统的稳定性、成本和扩展性。

  1. 主控单元:为什么是ESP32? 市面上常见的微控制器有Arduino Uno、NodeMCU(ESP8266)和ESP32。我选择ESP32 DEVKIT V1,原因有三点:一是 双核处理器 性能足够,一核处理RFID读取和锁控逻辑,另一核可以轻松应对Wi-Fi连接和网络通信,不会卡顿;二是 集成了Wi-Fi和蓝牙 ,这意味着我们不需要额外模块就能实现联网功能,为后续接入Firebase和手机App直连提供了可能;三是 GPIO口丰富 ,驱动RC522、LCD、继电器等外设绰绰有余。相比之下,Arduino Uno需要额外加装Wi-Fi模块,增加了复杂性和成本;ESP8266虽然也有Wi-Fi,但处理能力和外设接口稍弱。

  2. 身份识别:RFID RC522模块 RFID(射频识别)技术在这里用于非接触式身份验证。RC522是一个基于13.56MHz频率的读写器模块,价格低廉,社区支持好,与ESP32的SPI接口通信非常方便。它读取的是卡片内置的 UID (唯一标识符),这个号码在出厂时就被固化,无法修改,非常适合作为身份的“物理指纹”。需要注意的是,普通Mifare卡的UID虽然不能改,但数据区是可写的,存在被克隆的风险。对于更高安全级别的项目,可以考虑使用CPU卡或加入动态加密验证,但作为原型验证和大多数低风险场景(如办公室储物柜、宿舍门禁),UID验证已经足够。

  3. 执行机构:电磁锁与继电器驱动 门锁的开关动作由 电磁锁(Solenoid Lock) 完成。给它通电(通常是12V),内部的电磁铁吸合,锁舌收回;断电后,弹簧使锁舌弹出,上锁。ESP32的GPIO口只能输出3.3V、几十毫安的电流,根本无法直接驱动12V的电磁锁。因此,必须使用 继电器模块 作为“电子开关”。ESP32的GPIO输出一个高/低电平信号来控制继电器的通断,继电器再负责接通或切断通往电磁锁的大电流电路。这是一种非常典型且安全的强弱电隔离方案。

  4. 电源管理:双电池与降压模块 系统涉及不同电压的器件:ESP32和RC522需要3.3V或5V,电磁锁需要12V。原方案使用两块9V电池。我的实践是:一块9V电池通过 LM2596降压模块 调整为5V,给ESP32、RC522、继电器控制端供电;另一块9V电池直接为电磁锁供电(如果电磁锁是12V的,则需要考虑升压或更换电池方案)。LM2596是开关型降压芯片,效率比线性稳压器(如LDO)高得多,能延长电池寿命。这里有个关键点:务必确保两个电源的“地”(GND)连接在一起,否则电路无法形成回路。

  5. 人机交互:LCD显示屏 使用一块 16x2字符型LCD ,并搭配 I2C接口转换板 。I2C转换板将LCD所需的16个引脚通信简化为仅需2根数据线(SDA, SCL)和2根电源线,极大节省了ESP32的GPIO资源。它可以实时显示系统状态,如“等待刷卡”、“欢迎,用户X”、“卡无效”等,让交互更直观。

2.2 软件与云端层:逻辑、数据与交互

硬件是身体,软件是灵魂。这套系统的软件部分分为三块:运行在ESP32上的固件、运行在云端的数据库、以及运行在用户手机上的App。

  1. 嵌入式固件:PlatformIO与Arduino框架 我强烈推荐使用 PlatformIO 作为开发环境,而不是传统的Arduino IDE。PlatformIO基于VS Code,代码管理、库依赖管理、串口调试都非常专业。ESP32的固件核心任务包括:初始化RFID读卡器、监听卡片事件、将读取到的UID发送到Firebase进行验证、根据验证结果控制继电器开关、在LCD上显示信息。这里会用到几个关键的库: MFRC522.h 用于操作RFID, FirebaseESP32.h 用于与Firebase通信, LiquidCrystal_I2C.h 用于驱动LCD。

  2. 云端后台:Firebase Realtime Database Firebase 是谷歌提供的一站式后端服务,我们主要用到它的 Realtime Database(实时数据库) 。它的优势在于与移动端和Web端集成极其简单,并且是实时同步的。我们在数据库中设计两个核心“表”:

    • authorized_cards : 存储已授权的卡片UID列表。当ESP32刷到一张卡,它会查询这个列表,判断卡是否合法。
    • access_logs : 存储所有的开门记录,包括卡UID、时间戳、访问结果(成功/失败)。这个列表会不断追加,供App查询历史。 使用Firebase避免了自建服务器的麻烦,但其免费配额对于个人项目完全够用。安全方面,务必在Firebase控制台设置好数据库规则,限制只有经过认证的请求才能读写,防止数据被恶意篡改。
  3. 移动端应用:Flutter跨平台开发 Flutter 用于开发管理App。为什么用Flutter?因为它一套代码可以同时编译出iOS和Android应用,开发效率高。这个App的功能主要包括:

    • 卡片注册 :管理员可以通过App输入一个新卡片的UID,将其添加到Firebase的 authorized_cards 列表中。
    • 访问历史查看 :以列表形式展示 access_logs 中的所有记录,方便追溯。
    • 远程开关锁 (可选增强功能):通过App向Firebase发送一个指令,ESP32监听这个指令变化,从而远程控制锁的开关。 Flutter通过 firebase_database 插件与Firebase进行交互,构建一个直观的管理界面。

3. 硬件电路搭建与核心细节剖析

有了设计图,接下来就是动手把各个零件连接起来。这一步看似是“依葫芦画瓢”,但很多细节决定了项目是“一次点亮”还是“反复调试”。我会结合原理图和实际接线图,把每个连接背后的道理讲清楚。

3.1 核心电路连接详解

下图清晰地展示了ESP32与所有外设的连接关系。我们以ESP32 DEVKIT V1的典型引脚布局为例进行说明:

此处为示意图描述,实际制作请务必对照模块引脚定义

  • RFID RC522 (SPI接口) :

    • SDA (SS) -> ESP32的 GPIO5 (可自定义,但代码中需对应)
    • SCK -> GPIO18
    • MOSI -> GPIO23
    • MISO -> GPIO19
    • IRQ -> 悬空或接指定引脚(本项目未用中断)
    • GND -> GND
    • RST -> GPIO22
    • 3.3V -> 3.3V 原理 :RC522通过SPI总线与ESP32通信。SPI是一种高速全双工通信协议,需要主设备(ESP32)提供时钟(SCK),并通过MOSI(主出从入)和MISO(主入从出)交换数据。 SDA 是片选引脚,用于在多个SPI设备时选择RC522。 RST 是复位引脚。
  • LCD 1602 with I2C :

    • SDA -> ESP32的 GPIO21 (默认I2C SDA)
    • SCL -> GPIO22 (默认I2C SCL)
    • VCC -> 5V 3.3V (视模块支持电压而定,通常接5V更亮)
    • GND -> GND 原理 :I2C是另一种通信协议,只需两根线(数据线SDA和时钟线SCL),可以挂载多个设备。I2C模块上通常有一个电位器,用于调节LCD对比度,务必旋转它直到屏幕字符清晰显示。
  • 继电器模块 :

    • IN (信号输入) -> ESP32的 GPIO4 (可自定义)
    • VCC -> 5V
    • GND -> GND
    • COM (公共端) -> 接电磁锁电源正极(如9V电池正极)
    • NO (常开端) -> 接电磁锁线缆的一端
    • 电磁锁线缆的另一端 -> 接电磁锁电源负极(9V电池负极) 原理 :当ESP32给 GPIO4 输出**低电平(0V) 时,继电器线圈不工作, COM NO 断开,电磁锁断电上锁。当输出 高电平(3.3V)**时,继电器线圈吸合, COM NO 接通,电磁锁通电开锁。这里实现了3.3V弱电控制9V/12V强电的安全隔离。
  • 电源部分 :

    • ESP32供电 :将LM2596降压模块的输出调整为 5V ,连接到ESP32的 VIN 引脚和任意一个 5V 引脚。LM2596的输入接一块9V电池。
    • 电磁锁供电 :另一块9V电池直接连接继电器和电磁锁的回路(如上所述)。
    • 共地 至关重要! 必须将LM2596输出的GND、ESP32的GND、继电器模块的GND以及为电磁锁供电的9V电池的负极,全部用导线连接在一起。这是所有电路工作的公共参考点。

3.2 焊接与组装注意事项

如果使用面包板进行原型验证,可以免焊接。但如果想做成一个稳定的、可长期使用的原型,焊接是必须的。这里有几个血泪教训:

注意 :焊接前务必断开所有电源!尤其是电池!

  1. PCB vs 万用板 :原项目提到了设计PCB并蚀刻。对于个人爱好者,我更推荐使用 洞洞板(万用板) 。布局灵活,修改方便。可以先在面包板上验证成功,再将元件布局复制到洞洞板上焊接。
  2. 焊接顺序 :先焊接高度最低的元件,如电阻、IC座,再焊接较高的元件,如电容、接线端子。ESP32和LCD的I2C模块建议使用 排母 ,这样可以将它们插拔,方便调试和更换。
  3. 电源走线 :给ESP32、继电器、电磁锁供电的电源线,要选用足够粗的导线(如AWG22或更粗),特别是电磁锁的线路,瞬间电流较大,细线会产生压降导致锁无力,甚至发热。
  4. 抗干扰处理
    • 在LM2596的输入和输出端,靠近芯片引脚的地方,并联一个 100μF的电解电容 和一个 0.1μF的瓷片电容 ,用于滤除电源噪声。这对ESP32的Wi-Fi稳定工作至关重要。
    • 继电器线圈在通断瞬间会产生很高的反向电动势,可能损坏ESP32的GPIO口。虽然大部分继电器模块已经内置了 续流二极管 进行保护,但如果你是自己用分立继电器搭建,一定记得在线圈两端反向并联一个二极管(如1N4007)。

4. 嵌入式固件开发与关键代码解析

硬件连接妥当后,我们开始编写ESP32的“大脑”——固件程序。我将使用PlatformIO进行开发,并逐模块解析核心代码逻辑。

4.1 开发环境搭建与库管理

首先,在VS Code中安装PlatformIO插件。新建一个项目,选择Board为“Espressif ESP32 Dev Module”,Framework选择“Arduino”。PlatformIO的强大之处在于其 platformio.ini 配置文件,它清晰地管理了所有依赖。

[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200

; 库依赖
lib_deps = 
    miguelbalboa/MFRC522@^1.4.10
    firebase-esp-client/Firebase ESP32 Client@^4.4.7
    johnrickman/LiquidCrystal_I2C@^1.1.2

这样,PlatformIO会自动下载和管理这三个核心库,无需手动安装。

4.2 核心逻辑代码实现

以下是主程序 src/main.cpp 的核心片段与解析:

#include <Arduino.h>
#include <SPI.h>
#include <MFRC522.h>
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <WiFi.h>
#include <FirebaseESP32.h>

// 1. 引脚定义
#define SS_PIN 5
#define RST_PIN 22
#define RELAY_PIN 4
#define I2C_ADDR 0x27 // LCD I2C地址,常见为0x27或0x3F

// 2. 对象初始化
MFRC522 mfrc522(SS_PIN, RST_PIN);
LiquidCrystal_I2C lcd(I2C_ADDR, 16, 2); // 16列2行
FirebaseData fbdo;
FirebaseAuth auth;
FirebaseConfig config;

// 3. WiFi和Firebase配置(务必替换成你自己的)
const char* WIFI_SSID = "Your_WiFi_SSID";
const char* WIFI_PASS = "Your_WiFi_Password";
const char* FIREBASE_HOST = "your-project-id.firebaseio.com";
const char* FIREBASE_KEY = "Your_Firebase_Database_Secret";

// 4. 全局变量
String lastCardUID = ""; // 记录上一次刷的卡,防重复触发
unsigned long lastReadTime = 0;
const unsigned long DEBOUNCE_DELAY = 2000; // 防抖延时2秒

void setup() {
  Serial.begin(115200);
  lcd.init();
  lcd.backlight();
  lcd.print("System Booting...");

  SPI.begin();
  mfrc522.PCD_Init();
  pinMode(RELAY_PIN, OUTPUT);
  digitalWrite(RELAY_PIN, LOW); // 初始化继电器为断开(锁闭)

  // 连接WiFi
  lcd.clear();
  lcd.print("Connecting WiFi");
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  lcd.clear();
  lcd.print("WiFi Connected!");
  delay(1000);

  // 初始化Firebase
  config.host = FIREBASE_HOST;
  config.signer.tokens.legacy_token = FIREBASE_KEY; // 注意:新项目建议使用更安全的方式
  Firebase.begin(&config, &auth);
  Firebase.reconnectWiFi(true);

  lcd.clear();
  lcd.print("Ready to Scan");
  lcd.setCursor(0, 1);
  lcd.print("Card");
}

void loop() {
  // 5. 检测是否有新卡片
  if (!mfrc522.PICC_IsNewCardPresent() || !mfrc522.PICC_ReadCardSerial()) {
    delay(50); // 降低CPU占用
    return;
  }

  // 6. 防抖处理:防止同一张卡在传感器前晃动导致多次触发
  if (millis() - lastReadTime < DEBOUNCE_DELAY) {
    return;
  }
  lastReadTime = millis();

  // 7. 读取卡片UID并转换为字符串
  String cardUID = "";
  for (byte i = 0; i < mfrc522.uid.size; i++) {
    cardUID += String(mfrc522.uid.uidByte[i] < 0x10 ? "0" : "");
    cardUID += String(mfrc522.uid.uidByte[i], HEX);
  }
  cardUID.toUpperCase();
  Serial.println("Card UID: " + cardUID);

  // 8. 显示卡片信息
  lcd.clear();
  lcd.print("UID:");
  lcd.setCursor(0, 1);
  lcd.print(cardUID.substring(0, 16)); // 显示前16位

  // 9. 查询Firebase授权列表
  String path = "/authorized_cards/" + cardUID;
  if (Firebase.getInt(fbdo, path)) {
    if (fbdo.dataType() == "int" && fbdo.intData() == 1) { // 假设授权值为1
      // 卡已授权
      lcd.clear();
      lcd.print("Access Granted!");
      unlockDoor();
      logAccess(cardUID, true); // 记录成功日志
    } else {
      // 卡未授权或数据格式不对
      accessDenied(cardUID);
    }
  } else {
    // Firebase查询失败(网络问题或卡不存在)
    Serial.println("Firebase query failed: " + fbdo.errorReason());
    lcd.clear();
    lcd.print("System Error");
    delay(2000);
    lcd.clear();
    lcd.print("Ready to Scan");
  }

  // 10. 让RC522停止读卡,准备下一次读取
  mfrc522.PICC_HaltA();
  delay(1000); // 给用户反馈信息显示时间
  lcd.clear();
  lcd.print("Ready to Scan");
  lcd.setCursor(0, 1);
  lcd.print("Card");
}

void unlockDoor() {
  digitalWrite(RELAY_PIN, HIGH); // 继电器吸合,开锁
  delay(3000); // 保持开锁状态3秒,模拟门打开时间
  digitalWrite(RELAY_PIN, LOW); // 继电器断开,上锁
}

void accessDenied(String uid) {
  lcd.clear();
  lcd.print("Access Denied!");
  logAccess(uid, false); // 记录失败日志
  delay(2000);
}

void logAccess(String uid, bool success) {
  String logPath = "/access_logs";
  FirebaseJson json;
  json.set("uid", uid);
  json.set("timestamp", String(millis())); // 实际应用中应使用NTP获取标准时间
  json.set("success", success);
  if (Firebase.pushJSON(fbdo, logPath, json)) {
    Serial.println("Log pushed: " + fbdo.pushName());
  } else {
    Serial.println("Log push failed: " + fbdo.errorReason());
  }
}

代码关键点解析:

  • 防抖机制 (Debouncing) :这是工业控制中常见的技术。 DEBOUNCE_DELAY (这里设为2秒)确保了同一张卡即使放在读卡器上不动,也不会在2秒内被重复读取和验证,避免了继电器频繁动作和日志爆炸式增长。
  • UID格式化 :从RC522读出的UID是字节数组,我们将其转换为十六进制字符串,并统一为大写,便于在数据库中进行字符串匹配。
  • Firebase查询逻辑 :我们在数据库中预设了一个 authorized_cards 节点,下面以卡UID为键名。查询路径就是 /authorized_cards/卡UID字符串 。如果查询到该路径下存在值且为1(或其他预设的授权标志),则授权通过。这种结构查询效率很高。
  • 错误处理 :对 Firebase.getInt 的调用进行了结果判断,包括网络超时、路径不存在等情况,并在LCD和串口给出了提示,增强了系统的健壮性。
  • 开锁时长控制 unlockDoor 函数中,开锁后延迟3秒再闭锁,模拟了人推门进入的时间。这个时间可以根据实际门的机械结构进行调整。

5. Flutter管理应用开发详解

有了ESP32端,我们还需要一个方便的管理工具。用Flutter开发一个跨平台App是最佳选择。这里我侧重讲解与硬件项目联动的核心部分,即与Firebase的交互。

5.1 项目初始化与Firebase配置

首先,使用 flutter create smart_lock_admin 创建新项目。然后,在Firebase控制台创建新项目,并分别注册Android和iOS应用(如果都需要),下载对应的配置文件( google-services.json GoogleService-Info.plist )放到Flutter项目的相应目录。

pubspec.yaml 中添加依赖:

dependencies:
  flutter:
    sdk: flutter
  firebase_core: ^2.24.2 # Firebase核心库
  firebase_database: ^10.2.2 # Realtime Database
  provider: ^6.1.1 # 状态管理(可选,但推荐)
  intl: ^0.18.1 # 日期格式化

运行 flutter pub get 安装依赖。

5.2 核心页面与功能实现

我们主要构建两个页面: CardManagementPage (卡片管理)和 AccessLogPage (访问日志)。

1. 卡片管理页面 ( card_management_page.dart ) 核心逻辑:

这个页面负责展示已授权卡片列表和添加新卡片。

import 'package:flutter/material.dart';
import 'package:firebase_database/firebase_database.dart';

class CardManagementPage extends StatefulWidget {
  @override
  _CardManagementPageState createState() => _CardManagementPageState();
}

class _CardManagementPageState extends State<CardManagementPage> {
  final DatabaseReference _cardsRef = FirebaseDatabase.instance.ref('authorized_cards');
  List<String> _authorizedCards = [];
  final TextEditingController _newCardController = TextEditingController();

  @override
  void initState() {
    super.initState();
    _loadCards();
  }

  // 监听Firebase中authorized_cards节点的变化
  void _loadCards() {
    _cardsRef.onValue.listen((DatabaseEvent event) {
      final data = event.snapshot.value as Map<dynamic, dynamic>?;
      if (data != null) {
        setState(() {
          // 将Map的键(即卡UID)转换为列表
          _authorizedCards = data.keys.cast<String>().toList();
        });
      } else {
        setState(() {
          _authorizedCards = [];
        });
      }
    }, onError: (error) {
      print('Failed to load cards: $error');
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('加载卡片列表失败')),
      );
    });
  }

  // 添加新卡片
  void _addNewCard() {
    String newUid = _newCardController.text.trim().toUpperCase();
    if (newUid.isEmpty) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('请输入有效的卡UID')),
      );
      return;
    }
    // 检查格式(简单的十六进制长度检查)
    if (!RegExp(r'^[0-9A-F]{8,}$').hasMatch(newUid)) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('UID格式不正确')),
      );
      return;
    }

    // 写入Firebase,值为1表示已授权
    _cardsRef.child(newUid).set(1).then((_) {
      _newCardController.clear();
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('卡片 $newUid 添加成功!')),
      );
    }).catchError((error) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('添加失败: $error')),
      );
    });
  }

  // 删除卡片
  void _removeCard(String uid) {
    showDialog(
      context: context,
      builder: (ctx) => AlertDialog(
        title: Text('确认删除'),
        content: Text('确定要删除卡片 $uid 吗?'),
        actions: [
          TextButton(onPressed: () => Navigator.pop(ctx), child: Text('取消')),
          TextButton(
            onPressed: () {
              _cardsRef.child(uid).remove().then((_) {
                Navigator.pop(ctx);
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text('卡片已删除')),
                );
              });
            },
            child: Text('删除', style: TextStyle(color: Colors.red)),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('卡片管理')),
      body: Column(
        children: [
          // 添加卡片区域
          Padding(
            padding: const EdgeInsets.all(16.0),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _newCardController,
                    decoration: InputDecoration(
                      labelText: '输入新卡UID',
                      border: OutlineInputBorder(),
                      hintText: '例如:A1B2C3D4',
                    ),
                  ),
                ),
                SizedBox(width: 10),
                ElevatedButton.icon(
                  onPressed: _addNewCard,
                  icon: Icon(Icons.add),
                  label: Text('添加'),
                ),
              ],
            ),
          ),
          Divider(),
          // 卡片列表
          Expanded(
            child: _authorizedCards.isEmpty
                ? Center(child: Text('暂无授权卡片'))
                : ListView.builder(
                    itemCount: _authorizedCards.length,
                    itemBuilder: (ctx, index) {
                      String uid = _authorizedCards[index];
                      return ListTile(
                        leading: Icon(Icons.credit_card, color: Colors.blue),
                        title: Text(uid, style: TextStyle(fontFamily: 'monospace')),
                        trailing: IconButton(
                          icon: Icon(Icons.delete, color: Colors.grey),
                          onPressed: () => _removeCard(uid),
                        ),
                        onTap: () {
                          // 可以点击查看详情或复制UID
                          Clipboard.setData(ClipboardData(text: uid));
                          ScaffoldMessenger.of(context).showSnackBar(
                            SnackBar(content: Text('UID已复制到剪贴板')),
                          );
                        },
                      );
                    },
                  ),
          ),
        ],
      ),
    );
  }
}

2. 访问日志页面 ( access_log_page.dart ) 核心逻辑:

这个页面以时间倒序展示所有的开门尝试记录。

import 'package:flutter/material.dart';
import 'package:firebase_database/firebase_database.dart';
import 'package:intl/intl.dart';

class AccessLog {
  final String uid;
  final int timestamp;
  final bool success;
  final String? key; // Firebase推送生成的唯一键

  AccessLog({required this.uid, required this.timestamp, required this.success, this.key});

  String get formattedTime {
    var date = DateTime.fromMillisecondsSinceEpoch(timestamp);
    return DateFormat('yyyy-MM-dd HH:mm:ss').format(date);
  }
}

class AccessLogPage extends StatefulWidget {
  @override
  _AccessLogPageState createState() => _AccessLogPageState();
}

class _AccessLogPageState extends State<AccessLogPage> {
  final DatabaseReference _logsRef = FirebaseDatabase.instance.ref('access_logs');
  List<AccessLog> _logs = [];

  @override
  void initState() {
    super.initState();
    _loadLogs();
  }

  void _loadLogs() {
    // 监听logs节点,并按时间戳排序
    _logsRef.orderByChild('timestamp').onValue.listen((DatabaseEvent event) {
      final data = event.snapshot.value as Map<dynamic, dynamic>?;
      List<AccessLog> loadedLogs = [];
      if (data != null) {
        data.forEach((key, value) {
          final logData = value as Map<dynamic, dynamic>;
          loadedLogs.add(AccessLog(
            key: key.toString(),
            uid: logData['uid']?.toString() ?? 'N/A',
            timestamp: int.tryParse(logData['timestamp']?.toString() ?? '0') ?? 0,
            success: logData['success'] == true,
          ));
        });
        // 按时间戳倒序排列(最新的在最前面)
        loadedLogs.sort((a, b) => b.timestamp.compareTo(a.timestamp));
      }
      setState(() {
        _logs = loadedLogs;
      });
    }, onError: (error) {
      print('Failed to load logs: $error');
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('访问日志'),
        actions: [
          IconButton(
            icon: Icon(Icons.delete_sweep),
            onPressed: _logs.isEmpty ? null : _confirmClearLogs,
            tooltip: '清空日志',
          ),
        ],
      ),
      body: _logs.isEmpty
          ? Center(child: Text('暂无访问记录'))
          : ListView.builder(
              itemCount: _logs.length,
              itemBuilder: (ctx, index) {
                final log = _logs[index];
                return Card(
                  margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                  child: ListTile(
                    leading: Icon(
                      log.success ? Icons.lock_open : Icons.block,
                      color: log.success ? Colors.green : Colors.red,
                    ),
                    title: Text(log.uid, style: TextStyle(fontFamily: 'monospace')),
                    subtitle: Text(log.formattedTime),
                    trailing: Text(log.success ? '成功' : '失败', style: TextStyle(
                      color: log.success ? Colors.green : Colors.red,
                      fontWeight: FontWeight.bold,
                    )),
                  ),
                );
              },
            ),
    );
  }

  void _confirmClearLogs() {
    showDialog(
      context: context,
      builder: (ctx) => AlertDialog(
        title: Text('清空所有日志?'),
        content: Text('此操作不可撤销,将删除所有访问记录。'),
        actions: [
          TextButton(onPressed: () => Navigator.pop(ctx), child: Text('取消')),
          TextButton(
            onPressed: () {
              _logsRef.remove().then((_) {
                Navigator.pop(ctx);
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text('日志已清空')),
                );
              });
            },
            child: Text('清空', style: TextStyle(color: Colors.red)),
          ),
        ],
      ),
    );
  }
}

Flutter开发要点:

  • 实时监听 :使用 onValue.listen 监听Firebase数据节点的变化。一旦ESP32推送了新日志或管理员增删了卡片,UI会自动更新,无需手动刷新。
  • 数据排序 :Firebase Realtime Database查询时可以使用 orderByChild 对数据进行排序。在日志页面,我们按 timestamp 排序,并在客户端反转列表以实现“最新在前”的显示。
  • 错误处理与用户反馈 :所有Firebase操作( set , remove , onValue )都应有 catchError onError 回调,并通过 SnackBar Dialog 告知用户,提升应用健壮性。
  • UID格式验证 :在添加卡片时,前端做了简单的正则表达式验证,确保输入的是十六进制格式的字符串,避免了无效数据写入数据库。

6. 系统集成、调试与故障排查实录

当硬件、固件、App都分别完成后,真正的挑战在于让它们协同工作。这个阶段会遇到大部分问题,也是积累经验的关键。

6.1 上电前检查清单

在接通电源前,务必逐项核对:

  1. 电源极性 :ESP32的VIN、5V引脚,LCD、继电器模块的VCC,LM2596的输入输出,电池的正负极是否全部接对?接反极易烧毁芯片。
  2. 共地 :所有模块的GND是否都连接到了公共的电源地?这是电路工作的基础。
  3. 引脚冲突 :检查是否有GPIO引脚被重复定义或占用。例如,ESP32的某些引脚在启动时有特殊功能(如GPIO0、GPIO2、GPIO15),尽量避免使用。
  4. 焊接质量 :检查洞洞板或PCB上是否有虚焊、短路(焊锡桥接)或冷焊点。用万用表通断档仔细检查关键连接。

6.2 分模块调试流程

不要一次性上传所有代码。采用分步调试法:

  1. 基础测试 :先上传一个最简单的Blink程序(让板载LED闪烁),确认ESP32本身和开发环境没问题。
  2. 串口打印 :在 setup() 函数开头就启动 Serial.begin(115200) ,并在各个关键节点(如连接WiFi前、后,初始化Firebase后)打印状态信息。这是最有效的调试手段。
  3. 单独测试RFID :注释掉WiFi和Firebase代码,只保留RFID和LCD部分。上传后,打开串口监视器,看刷卡时能否正确打印出UID并在LCD上显示。
  4. 单独测试WiFi与Firebase :注释掉RFID读卡逻辑,在 setup() 中连接WiFi和Firebase后,尝试写入一个测试数据到数据库,看是否成功。
  5. 测试继电器 :写一个简单程序,让控制继电器的GPIO口周期性高低电平变化,听继电器是否有清晰的“咔嗒”声,并用万用表测量其输出端是否通断。
  6. 集成测试 :将以上所有功能整合,进行端到端测试。刷一张未授权的卡,观察LCD提示和数据库日志;然后在App中添加该卡UID,再次刷卡,观察门锁动作和日志记录。

6.3 常见问题与解决方案速查表

以下是我在多次实践中遇到的一些典型问题及解决方法:

问题现象 可能原因 排查步骤与解决方案
ESP32无法连接WiFi 1. SSID/密码错误。
2. WiFi信号弱。
3. 路由器设置了MAC过滤或仅允许特定设备。
1. 检查代码中的 WIFI_SSID WIFI_PASS ,确保无空格和大小写错误。
2. 将ESP32靠近路由器,或打印 WiFi.RSSI() 查看信号强度。
3. 检查路由器后台,将ESP32的MAC地址加入白名单,或暂时关闭MAC过滤。
Firebase连接失败 1. 数据库URL或密钥错误。
2. 数据库规则过于严格。
3. 网络时间未同步(SSL证书验证需要)。
1. 核对 FIREBASE_HOST FIREBASE_KEY 。密钥在Firebase控制台“项目设置-服务账户-数据库密钥”获取。
2. 暂时将数据库规则改为读写全开放测试: {“rules”: {“.read”: true, “.write”: true}}
3. 在代码中添加NTP时间同步功能,使用 config.time_t 设置。
RFID读不到卡 1. 接线错误(SPI引脚接错)。
2. 卡片类型不支持(RC522支持Mifare S50, S70等)。
3. 模块或卡片损坏。
4. 电源供电不足。
1. 用万用表检查SPI四根线(SCK, MOSI, MISO, SDA)是否连通。
2. 确保使用的是13.56MHz的Mifare卡。
3. 尝试更换模块或卡片。
4. 确保RC522的3.3V引脚电压稳定,可尝试单独为其供电。
继电器不动作 1. 控制引脚定义错误或电平不对。
2. 继电器模块供电不足。
3. 继电器模块高低电平触发模式设置错误。
1. 用 digitalWrite(RELAY_PIN, HIGH/LOW) 测试,并用万用表测量该引脚电压是否变化。
2. 确保继电器VCC接5V,且电源能提供足够电流(>100mA)。
3. 有些继电器模块有跳线帽选择高电平或低电平触发,需根据代码设置调整。
电磁锁力度小或不动 1. 电源电压/电流不足。
2. 继电器触点接触不良或负载能力不够。
3. 电磁锁本身故障。
1. 用万用表测量电磁锁两端在触发时的电压,应接近电源电压(如9V)。如果压降大,说明导线太细或电源内阻大。
2. 尝试用继电器直接控制一个小灯泡,看是否能正常开关。确认继电器额定电流大于电磁锁工作电流。
3. 直接给电磁锁接上额定电压,看是否动作。
Flutter App无法读取数据 1. Firebase配置未正确集成到Flutter项目。
2. 数据库规则禁止读取。
3. 网络权限未开启(Android)。
1. 检查 google-services.json 文件是否放在 android/app/ 目录下,且 build.gradle 文件已正确配置。
2. 检查Firebase数据库规则。
3. 对于Android,在 android/app/src/main/AndroidManifest.xml 中添加网络权限: <uses-permission android:name=”android.permission.INTERNET” />
系统运行一段时间后死机 1. 电源不稳定或容量不足。
2. 代码中有内存泄漏(如未及时释放字符串)。
3. WiFi连接断开未重连。
1. 使用稳压电源或容量更大的电池(如18650锂电池组)测试。
2. 检查代码,避免在循环中不断创建String对象。使用 String.reserve() 预分配空间。
3. 在 loop() 中检查 WiFi.status() ,如果断开则尝试重连。Firebase库的 Firebase.reconnectWiFi(true) 已提供一定帮助。

6.4 安全与优化建议

完成基本功能后,可以考虑以下增强措施:

  1. 提升安全性

    • 禁用Firebase旧版令牌 :示例代码中使用了 legacy_token ,在新版Firebase中应迁移至更安全的身份验证方式,如使用Firebase Admin SDK生成自定义令牌,或在ESP32上实现Email/Password匿名登录。
    • 双向认证 :不仅ESP32查询Firebase,也可以在Firebase中设置一个 commands 节点。App写入开锁指令,ESP32监听该节点变化来执行远程开锁,实现双向通信。
    • 本地UID白名单缓存 :为了防止网络中断时门锁完全失效,可以在ESP32的SPIFFS或Preferences中存储一份已授权UID的加密缓存。网络正常时与云端同步,断网时使用本地缓存验证(需注意缓存更新问题)。
  2. 优化用户体验

    • 声光提示 :增加一个蜂鸣器和不同颜色的LED,用于区分“刷卡成功”、“卡无效”、“系统错误”等状态,提供更丰富的反馈。
    • 低功耗设计 :如果使用电池供电,可以启用ESP32的深度睡眠模式。当没有刷卡事件时,让ESP32进入睡眠,仅由RC522的中断引脚来唤醒它,可以极大延长续航。
    • OTA升级 :实现ESP32的空中升级功能,这样以后修复Bug或增加新功能,无需再通过USB线连接电脑刷写固件。
  3. 结构封装

    • 使用3D打印或亚克力板制作一个美观的外壳,将电路板、电池妥善固定,并留出LCD屏幕、读卡区域和复位按钮的开口,让原型机看起来更专业、更耐用。

这个项目从构思到实现,贯穿了物联网系统的典型开发流程。它不仅仅是一个门锁,更是一个可扩展的框架。你可以很容易地将RFID模块替换为指纹模块、人脸识别摄像头,或者将电磁锁的控制信号用于打开车库门、控制电灯开关。希望这份详尽的记录,能帮你少走弯路,顺利打造出属于自己的智能物联设备。

更多推荐