基于BLE与5GHz WiFi的实时考勤系统:ESP32网关与低功耗工卡设计 1. 项目概述一个基于BLE与5GHz WiFi的考勤系统最近在捣鼓一个挺有意思的硬件项目核心是用蓝牙低功耗BLE来做人员签到然后通过5GHz WiFi把数据实时推送到一个网页仪表盘上。项目代号叫“NU87DK”听起来有点神秘其实就是我手头几块开发板的型号组合。这个项目的初衷很简单就是想摆脱传统打卡机那种笨重、布线麻烦、数据孤岛的问题做一个部署灵活、数据实时可视化的轻量级考勤方案。它特别适合用在一些中小型会议室、创客空间、小型工作室或者实验室里。你想想在这些地方拉网线、装固定打卡机多麻烦用这个方案只需要在入口处贴一个火柴盒大小的BLE信标员工或成员佩戴一个廉价的BLE手环或工卡走进门自动就完成签到了数据秒同步到办公室任何一台电脑的浏览器上管理员一眼就能看到谁在、谁迟到。整个系统的核心就三块BLE终端负责广播身份、WiFi网关负责接收并转发数据、Web Dashboard负责展示和管理。我选用了ESP32系列芯片作为主控因为它天生就同时集成了BLE和WiFi一颗芯片搞定两种无线通信性价比和开发便利性都没得说。2. 系统核心架构与通信链路设计2.1 为什么是BLE 5GHz WiFi这个组合是我反复权衡后的选择。先说说BLE它功耗极低一颗纽扣电池能让一个信标工作一两年非常适合给员工卡或手环供电。它采用广播机制终端设备工卡不断向外广播自己的唯一ID网关ESP32在监听范围内就能扫描到实现无接触打卡。相比RFIDBLE的识别距离更灵活几米到几十米可调且智能手机普遍支持为未来扩展手机打卡留了可能。再说5GHz WiFi。传统的2.4GHz频段实在太拥挤了蓝牙、微波炉、各种智能家居设备都在这个频段打架干扰严重可能导致考勤数据上传延迟甚至丢失。对于考勤这种需要可靠、实时上传数据的场景干扰是不可接受的。5GHz频段相对干净很多信道多干扰少能提供更稳定、高速的数据回传链路确保打卡记录能立刻送到服务器。虽然5GHz的穿墙能力稍弱但通常考勤点如门口和路由器之间很少有承重墙阻隔所以这不是大问题。ESP32支持802.11n在5GHz频段下完全够用。整个数据流是这样跑的BLE终端每张工卡内嵌一个BLE芯片如Nordic nRF52832被配置为不可连接的广播者Non-connectable Broadcaster周期性地例如每秒1次广播一个包含唯一“员工ID”的数据包。这样做既省电又安全网关无法与它建立连接避免了被恶意连接篡改的风险。WiFi网关ESP32网关设备运行两个核心任务。一是扫描周围的BLE广播当解析到合法的员工ID时记录时间戳和RSSI信号强度可用于粗略测距。二是通过5GHz WiFi使用MQTT或HTTP POST协议将这条打卡记录员工ID、时间戳、网关设备号发送到后端的服务器或云服务。数据后端与Dashboard服务器可以用简单的Python Flask SQLite也可以用云服务如AWS IoT Core接收并存储数据。Web Dashboard我用Vue.js Element UI写的通过WebSocket或定期轮询API从服务器获取最新数据实时更新显示在场人员列表、考勤统计图表等。2.2 硬件选型与核心电路设计主控芯片没太多悬念ESP32-S3是我的首选。相比经典的ESP32S3系列外设更多、蓝牙5.0支持更好而且价格差不多。它直接集成了2.4GHz WiFi和BLE 5.0一颗芯片充当网关主脑无需额外的无线模块。对于BLE终端工卡为了极致低功耗和微型化我选择了nRF52832的模块。它的功耗可以做到微安级通过编程让其大部分时间深度睡眠仅定时唤醒广播一颗CR2032纽扣电池预期寿命能超过18个月。电路设计上关键点在于天线匹配和电源滤波。nRF52832对电源纹波非常敏感必须在电源引脚就近放置一个1μF和一个0.1μF的陶瓷电容进行去耦。天线部分最好使用模块厂提供的PCB天线或外接陶瓷天线并严格按照数据手册设计π型匹配网络用矢量网络分析仪调试到最佳状态这是保证广播距离和稳定性的基础。网关部分ESP32-S3的电路相对成熟。需要注意两点一是电源虽然USB供电方便但考虑到可能长期插电工作建议使用AMS1117-3.3等LDO芯片提供稳定3.3V电压并在输入输出端加足够大的电解电容如100μF和陶瓷电容0.1μF缓冲。二是Flash和PSRAM如果Dashboard逻辑复杂或需要存储大量本地日志建议选择搭载8MB Flash和2MB PSRAM的型号比如ESP32-S3-WROOM-1-N8R2。注意ESP32的WiFi和蓝牙共用天线虽然内部有开关切换但在高强度并发数据时可能互相干扰。在软件上要合理错开BLE扫描和WiFi数据发送的时序比如扫描200ms停止50ms用于WiFi通信。3. 固件开发从BLE广播到WiFi上传3.1 BLE终端固件极简广播与功耗控制终端固件的唯一任务就是安全、省电地广播ID。我使用nRF5 SDK进行开发。核心代码逻辑非常简单就是一个主循环里配置广播数据、进入广播模式、然后深度睡眠。// 伪代码示例基于nRF5 SDK #include nrf_sdh.h #include ble_advdata.h static ble_gap_adv_params_t m_adv_params; // 广播参数 static uint8_t m_adv_handle BLE_GAP_ADV_SET_HANDLE_NOT_SET; static uint8_t m_encrypted_employee_id[16]; // 加密后的员工ID void broadcast_init() { // 1. 配置广播数据 ble_advdata_manuf_data_t manuf_specific_data; manuf_specific_data.company_identifier 0xFFFF; // 自定义公司标识 manuf_specific_data.data.p_data m_encrypted_employee_id; manuf_specific_data.data.size sizeof(m_encrypted_employee_id); ble_advdata_t advdata; memset(advdata, 0, sizeof(advdata)); advdata.name_type BLE_ADVDATA_NO_NAME; // 不广播设备名省电且安全 advdata.include_appearance false; advdata.flags BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE; advdata.p_manuf_specific_data manuf_specific_data; // 2. 设置广播参数低频、低功率延长电池寿命 memset(m_adv_params, 0, sizeof(m_adv_params)); m_adv_params.properties.type BLE_GAP_ADV_TYPE_NONCONNECTABLE_NONSCANNABLE_UNDIRECTED; m_adv_params.interval MSEC_TO_UNITS(1024, UNIT_0_625_MS); // 广播间隔约1秒 m_adv_params.duration 0; // 持续广播直到被停止 m_adv_params.primary_phy BLE_GAP_PHY_1MBPS; m_adv_params.p_peer_addr NULL; m_adv_params.filter_policy BLE_GAP_ADV_FP_ANY; m_adv_params.tx_power 0; // 0dBm发射功率兼顾距离与功耗 } void main_loop() { broadcast_init(); // 启动广播 sd_ble_gap_adv_start(m_adv_handle, APP_BLE_CONN_CFG_TAG); while (1) { // 3. 进入系统OFF模式深度睡眠由RTC定时器唤醒 sd_power_system_off(); // 唤醒后系统会复位重新执行main_loop再次广播。 // 更优的做法是使用RTC唤醒后继续执行这里为简化示意。 } }功耗控制的关键在于广播间隔和发射功率。广播间隔从几百毫秒到几秒不等间隔越长越省电但网关扫描到的概率会降低可能造成漏检。经过实测在人员正常步行速度通过门口约1-2米宽的识别区时1秒的广播间隔配合0dBm发射功率漏检率可以控制在0.1%以下而平均电流可以低至15μA左右电池寿命非常可观。3.2 WiFi网关固件ESP32双无线协程调度网关的固件是核心它需要同时管理BLE扫描和WiFi/MQTT通信。我使用Arduino框架开发因为它对ESP32的支持好库丰富。但Arduino的loop()模型不适合处理多任务所以我采用了FreeRTOS任务在Arduino中即xTaskCreate来分别处理BLE和WiFi。// 伪代码示例基于Arduino框架和ESP32 BLE库 #include BLEDevice.h #include BLEUtils.h #include BLEScan.h #include WiFi.h #include PubSubClient.h // MQTT客户端库 // 全局变量 BLEScan* pBLEScan; WiFiClient espClient; PubSubClient mqttClient(espClient); QueueHandle_t attendanceQueue; // FreeRTOS队列用于任务间传递考勤数据 // BLE扫描任务 void Task_BLEScan(void *pvParameters) { BLEScanResults foundDevices; for (;;) { // 启动一次持续2秒的主动扫描 foundDevices pBLEScan-start(2, false); // 解析扫描结果 for (int i 0; i foundDevices.getCount(); i) { BLEAdvertisedDevice device foundDevices.getDevice(i); if (device.haveManufacturerData()) { std::string manuData device.getManufacturerData(); // 检查公司标识符并解密数据提取员工ID uint32_t companyId *(uint32_t*)manuData.data(); if (companyId 0xFFFF) { std::string encryptedId manuData.substr(4); // 假设前4字节是公司ID std::string employeeId decrypt(encryptedId); // 解密函数 int rssi device.getRSSI(); // 构造数据包放入队列 AttendanceRecord record {employeeId, millis(), rssi}; xQueueSend(attendanceQueue, record, portMAX_DELAY); } } } pBLEScan-clearResults(); // 清除结果释放内存 vTaskDelay(100 / portTICK_PERIOD_MS); // 短暂延迟避免CPU占用率100% } } // WiFi MQTT任务 void Task_WiFiMQTT(void *pvParameters) { connectToWiFi(Your_5GHz_SSID, Your_Password); // 连接到5GHz网络 mqttClient.setServer(mqtt.broker.address, 1883); mqttClient.connect(ESP32_Gateway_NU87DK); AttendanceRecord record; for (;;) { // 从队列中接收考勤记录 if (xQueueReceive(attendanceQueue, record, 100 / portTICK_PERIOD_MS) pdTRUE) { // 构造MQTT消息JSON格式 String payload {\id\:\ String(record.employeeId.c_str()) \,\time\: String(record.timestamp) ,\gateway\:\NU87DK\,\rssi\: String(record.rssi) }; // 发布到主题如 attendance/record if (mqttClient.connected()) { mqttClient.publish(attendance/record, payload.c_str()); } else { // 重连逻辑 mqttClient.connect(ESP32_Gateway_NU87DK); } } mqttClient.loop(); // 维持MQTT心跳 vTaskDelay(10 / portTICK_PERIOD_MS); } } void setup() { Serial.begin(115200); attendanceQueue xQueueCreate(20, sizeof(AttendanceRecord)); // 创建队列 // 初始化BLE扫描 BLEDevice::init(); pBLEScan BLEDevice::getScan(); pBLEScan-setActiveScan(false); // 被动扫描更省电对网关而言 pBLEScan-setInterval(100); // 扫描间隔 pBLEScan-setWindow(99); // 扫描窗口应略小于间隔 // 创建任务 xTaskCreate(Task_BLEScan, BLE Scan, 4096, NULL, 1, NULL); xTaskCreate(Task_WiFiMQTT, WiFi MQTT, 4096, NULL, 1, NULL); } void loop() { // 主loop为空任务由FreeRTOS调度 vTaskDelay(1000 / portTICK_PERIOD_MS); }关键点解析双任务通信使用FreeRTOS队列attendanceQueue是线程安全的BLE扫描任务快速将识别到的记录放入队列WiFi任务按自己的节奏从队列取出并发送避免了因网络延迟阻塞BLE扫描。BLE扫描参数setActiveScan(false)设为被动扫描网关只接收广播包而不发送扫描请求这样对终端功耗无影响。扫描窗口99ms略小于间隔100ms是为了留出时间给CPU处理其他任务如WiFi。WiFi稳定性代码中包含了简单的MQTT重连逻辑。在实际项目中还需要加入WiFi断开重连、看门狗复位等更健壮的机制。4. 5GHz WiFi网络配置与优化要让ESP32稳定工作在5GHz频段需要一些特定的配置。首先你的无线路由器必须支持并开启5GHz SSID。在Arduino的WiFi库中连接时无需特别指定频段ESP32会自动选择。但为了确保最佳性能可以进行一些优化。首选在代码中指定WiFi信道和带宽。虽然ESP32会自动选择但在复杂环境中固定信道可以减少扫描和切换带来的延迟。void connectToWiFi(const char* ssid, const char* password) { WiFi.mode(WIFI_STA); // 可选设置WiFi参数连接指定信道的5GHz网络例如信道36 // wifi_config_t wifi_config; // strcpy((char*)wifi_config.sta.ssid, ssid); // strcpy((char*)wifi_config.sta.password, password); // wifi_config.sta.channel 36; // 5GHz低频段信道 // wifi_config.sta.bw WIFI_BW_HT20; // 20MHz带宽兼容性最好 // esp_wifi_set_config(WIFI_IF_STA, wifi_config); WiFi.begin(ssid, password); while (WiFi.status() ! WL_CONNECTED) { delay(500); Serial.print(.); } Serial.println(\nConnected to 5GHz WiFi!); Serial.print(IP address: ); Serial.println(WiFi.localIP()); Serial.print(Channel: ); Serial.println(WiFi.channel()); // 打印实际连接的信道 }其次注意天线摆放。ESP32开发板上的PCB天线对方向敏感。尽量让天线所在平面通常是开发板没有元件的那一面朝向路由器方向并远离大的金属物体和强干扰源如USB 3.0接口、显示器。最后进行简单的网络质量测试。可以在ESP32上运行一个持续Ping服务器的小程序监控延迟和丢包率。如果发现不稳定尝试在路由器后台将5GHz信道固定在36、40、44、48这些DFS信道以外的频段如149、153、157、161这些信道通常干扰更少。实操心得我曾遇到ESP32连接5GHz后频繁断线的问题最后发现是路由器开启了“WiFi兼容模式”混合802.11b/g/n/ac。将路由器的5GHz网络模式强制设置为“802.11n/ac only”后连接稳定性大幅提升。这是因为一些老的兼容性协议可能会引起不必要的协商和切换。5. Web Dashboard开发实时数据可视化Dashboard的目标是清晰、实时地展示考勤状态。我采用前后端分离架构后端用Python的FastAPI提供REST API前端用Vue3 Vite Element Plus构建单页面应用。5.1 后端API设计FastAPI后端主要负责三件事接收MQTT消息通过像paho-mqtt这样的客户端、存储到数据库SQLite或PostgreSQL、提供HTTP API给前端查询。# main.py (FastAPI 后端示例) from fastapi import FastAPI, WebSocket, WebSocketDisconnect from pydantic import BaseModel import sqlite3 import json import asyncio import paho.mqtt.client as mqtt from contextlib import asynccontextmanager app FastAPI() # 内存中的连接管理器用于WebSocket广播 class ConnectionManager: def __init__(self): self.active_connections: list[WebSocket] [] async def connect(self, websocket: WebSocket): await websocket.accept() self.active_connections.append(websocket) def disconnect(self, websocket: WebSocket): self.active_connections.remove(websocket) async def broadcast(self, message: str): for connection in self.active_connections: try: await connection.send_text(message) except: pass manager ConnectionManager() # MQTT回调函数 def on_mqtt_message(client, userdata, msg): record json.loads(msg.payload.decode()) # 1. 存入数据库 conn sqlite3.connect(attendance.db) c conn.cursor() c.execute(INSERT INTO records (employee_id, timestamp, gateway) VALUES (?, ?, ?), (record[id], record[time], record[gateway])) conn.commit() conn.close() # 2. 通过WebSocket广播新记录 asyncio.run(manager.broadcast(json.dumps({type: new_record, data: record}))) # 启动时连接MQTT asynccontextmanager async def lifespan(app: FastAPI): mqtt_client mqtt.Client() mqtt_client.on_message on_mqtt_message mqtt_client.connect(localhost, 1883, 60) # 假设MQTT broker在本地 mqtt_client.subscribe(attendance/record) mqtt_client.loop_start() yield mqtt_client.loop_stop() app FastAPI(lifespanlifespan) # HTTP API端点 app.get(/api/today_records) def get_today_records(): conn sqlite3.connect(attendance.db) conn.row_factory sqlite3.Row # 返回字典形式 c conn.cursor() # 查询今天的所有记录假设timestamp是Unix毫秒时间戳 c.execute(SELECT * FROM records WHERE date(timestamp/1000, unixepoch) date(now) ORDER BY timestamp DESC) rows c.fetchall() conn.close() return [dict(row) for row in rows] app.get(/api/current_present) def get_current_present(): # 获取当前在场人员例如过去10分钟内有记录且尚未签退的 conn sqlite3.connect(attendance.db) conn.row_factory sqlite3.Row c conn.cursor() ten_min_ago int(time.time() * 1000) - 10*60*1000 # 更复杂的逻辑可能需要一个“签退”信号或判断最后一条记录的时间 c.execute(SELECT employee_id, MAX(timestamp) as last_seen FROM records WHERE timestamp ? GROUP BY employee_id, (ten_min_ago,)) rows c.fetchall() conn.close() return [dict(row) for row in rows] # WebSocket端点用于前端实时推送 app.websocket(/ws) async def websocket_endpoint(websocket: WebSocket): await manager.connect(websocket) try: while True: # 保持连接等待广播消息 data await websocket.receive_text() # 可以处理前端发来的指令这里简单忽略 except WebSocketDisconnect: manager.disconnect(websocket)5.2 前端DashboardVue3前端页面核心是两部分一个实时更新的在场人员列表和一个显示每日考勤趋势的图表。!-- CurrentAttendance.vue 组件示例 -- template div classdashboard el-card classbox-card template #header div classcard-header span当前在场人员 ({{ presentList.length }})/span el-button typeprimary clickconnectWebSocket :loadingconnecting {{ wsConnected ? 已连接 : 连接实时数据 }} /el-button /div /template el-table :datapresentList stylewidth: 100% height400 el-table-column propemployee_id label工号 width180 / el-table-column propname label姓名 width180 template #defaultscope {{ getNameById(scope.row.employee_id) }} /template /el-table-column el-table-column proplast_seen label最后打卡时间 template #defaultscope {{ formatTime(scope.row.last_seen) }} /template /el-table-column el-table-column propgateway label签到位置 / /el-table /el-card el-card classbox-card template #header div classcard-header span今日考勤统计/span /div /template div idattendanceChart stylewidth: 100%; height: 300px;/div /el-card /div /template script setup import { ref, onMounted, onUnmounted } from vue import * as echarts from echarts import { ElMessage } from element-plus const presentList ref([]) const wsConnected ref(false) const connecting ref(false) let socket null let chartInstance null // 初始化图表 const initChart () { const chartDom document.getElementById(attendanceChart) chartInstance echarts.init(chartDom) const option { xAxis: { type: category, data: [] }, yAxis: { type: value }, series: [{ data: [], type: line }], tooltip: { trigger: axis } } chartInstance.setOption(option) updateChartData() } // 更新图表数据从后端API获取 const updateChartData async () { const res await fetch(/api/today_hourly_stats) // 假设有按小时统计的API const data await res.json() chartInstance.setOption({ xAxis: { data: data.hours }, series: [{ data: data.counts }] }) } // 连接WebSocket const connectWebSocket () { if (wsConnected.value) return connecting.value true socket new WebSocket(ws://${window.location.host}/ws) socket.onopen () { wsConnected.value true connecting.value false ElMessage.success(实时数据连接成功) } socket.onmessage (event) { const msg JSON.parse(event.data) if (msg.type new_record) { // 收到新打卡记录更新在场列表 fetchCurrentPresent() // 可以添加一个通知或动画效果 ElMessage.info(员工 ${msg.data.id} 已签到) } } socket.onclose () { wsConnected.value false ElMessage.warning(实时连接断开) } } // 从后端获取当前在场人员列表 const fetchCurrentPresent async () { const res await fetch(/api/current_present) presentList.value await res.json() } // 工具函数 const getNameById (id) { // 这里应该有一个员工ID到姓名的映射可以从另一个API获取或本地存储 const nameMap { 001: 张三, 002: 李四 } return nameMap[id] || id } const formatTime (timestamp) { return new Date(timestamp).toLocaleTimeString() } onMounted(() { fetchCurrentPresent() initChart() // 页面加载后自动尝试连接WebSocket connectWebSocket() }) onUnmounted(() { if (socket) socket.close() if (chartInstance) chartInstance.dispose() }) /script这个前端页面通过WebSocket与后端保持长连接一旦后端收到新的MQTT考勤记录并广播前端列表就会实时更新无需用户手动刷新。图表则通过定时调用API或由WebSocket消息触发更新。6. 系统集成、部署与实测调优6.1 硬件组装与外壳设计网关部分我将ESP32-S3开发板、一个RGB LED状态指示灯、一个复位按钮集成在一块洞洞板上并放入一个3D打印的外壳中。外壳侧面开孔用于天线和USB电源线正面开窗露出LED。LED状态设计为慢闪蓝色表示正在连接WiFi快闪绿色表示正在发送数据常亮白色表示系统正常红色表示错误。BLE终端工卡为了便携我直接选用了市面上封装好的nRF52832纽扣电池模块尺寸和一张门禁卡差不多。用激光切割机切了两片亚克力板将模块夹在中间四角用螺丝固定再套上一个硅胶套成品非常轻便。6.2 软件部署与配置后端服务部署在一台旧笔记本或树莓派上安装Python环境。将FastAPI后端代码、MQTT broker如Mosquitto都部署在这台机器上。使用systemd或supervisor将这两个服务设为开机自启。数据库初始化运行一个Python脚本创建SQLite数据库和表结构。前端构建与托管在开发机运行npm run build将生成的dist文件夹内的文件复制到后端的静态文件目录如./static。在FastAPI中配置静态文件服务这样访问服务器IP就能打开Dashboard。网关配置将编译好的ESP32固件通过USB线烧录。首次上电网关会尝试连接预设的WiFi。如果需要在不同地点部署可以增加一个“配网模式”如长按按钮进入AP模式用手机配网这里为了简化我直接写死了SSID和密码。6.3 现场实测与参数调优我将网关放在公司入口的鞋柜上通电。给几个同事分发了BLE工卡。第一轮测试发现偶尔有漏检。用手机BLE扫描工具检查发现工卡广播信号良好。问题出在网关的BLE扫描窗口设置上。我原本设置的100ms间隔和99ms窗口几乎是在连续扫描但ESP32的WiFi通信可能会短暂占用射频资源导致恰好错过某个广播包。调优我将扫描策略改为间歇性扫描。设置扫描周期为500ms其中300ms用于扫描200ms用于WiFi处理和休眠。这样既给了WiFi充足的时间又因为扫描窗口足够长300ms远大于广播间隔1秒能保证至少捕捉到一次广播。修改后在门口停留1秒以上的测试中漏检率降为0。第二轮测试发现Dashboard上偶尔会出现“幽灵”签到人已离开但状态未及时清除。这是因为我的“在场”逻辑只是简单地判断“最近10分钟内有记录”。调优我引入了基于RSSI的简单区域判断。在网关代码中只有当RSSI强度大于某个阈值如-65dBm时才认为人员真正进入了考勤区域门口否则忽略。同时在Dashboard的“在场”判断逻辑中加入了“连续未检测到”的规则如果某个ID在连续3个扫描周期约1.5秒内未被任何网关检测到且最后一次检测的RSSI小于阈值则将其从在场列表中移除。这样就避免了人已走远但状态还保留的情况。功耗实测BLE工卡平均工作电流约18μACR2032电池容量220mAh理论续航超过1年符合预期。ESP32网关在5GHz WiFi连接下平均电流约80mA使用一个5V/2A的USB适配器供电长期运行稳定。7. 常见问题与故障排查实录在实际部署和测试中我遇到了不少坑这里总结一下希望能帮你避开。问题一ESP32网关频繁重启或WiFi断开。现象系统运行一段时间后日志显示WiFi断开有时甚至自动重启。排查首先检查电源。使用万用表测量ESP32的3.3V引脚在大电流发送数据时电压是否跌落到3.0V以下。劣质USB线或电源适配器内阻过大会导致压降。检查代码是否有内存泄漏。在Arduino中频繁的String操作、未释放的BLE扫描结果都可能导致堆内存耗尽。检查是否启用了看门狗Watchdog。ESP32的硬件看门狗默认开启如果某个任务阻塞时间过长会导致看门狗复位。解决更换为带电源指示灯的优质USB线和5V/2A以上的适配器。在ESP32的Vin和GND之间并联一个470μF的电解电容以缓冲瞬时大电流需求。在代码中将BLE扫描结果的处理尽快完成并立即调用pBLEScan-clearResults()释放内存。避免使用String改用char数组或std::string。在长时间循环或可能阻塞的地方如网络请求加入vTaskDelay或yield()喂一下看门狗。也可以使用esp_task_wdt_init()配置软件看门狗超时时间。问题二BLE工卡识别距离不稳定时远时近。现象同一张卡在相同位置有时能识别有时不能识别距离波动大。排查环境干扰。2.4GHz频段非常拥挤WiFi、USB 3.0、微波炉都可能干扰BLE。天线性能差或匹配不佳。这是最常见的原因尤其是自己焊接的PCB天线或使用劣质模块。人体遮挡。BLE信号2.4GHz容易被人体主要是水分吸收。工卡放在口袋和拿在手里信号强度差别很大。解决为网关选择一个相对干净的位置远离路由器、电脑主机等。如果可能在代码中让ESP32的WiFi和BLE工作在时分复用的不同信道。这是最关键的一步。如果使用模块选择带有屏蔽罩和认证天线的型号。如果自己设计天线务必用矢量网络分析仪VNA调试匹配电路通常是π型网络中的电容电感值使天线在2.4GHz频段的回波损耗S11小于-10dB。没有VNA的话可以购买已经匹配好的天线模块。在算法上做补偿。不要用一个固定的RSSI阈值来判断是否“在场”而是采用动态阈值或多次采样取平均。在部署时让人员在典型位置如门口进行多次测试记录一个合理的RSSI范围。问题三Dashboard页面打开慢或实时更新有延迟。现象网页加载时间长WebSocket消息收到后界面更新慢。排查后端服务器性能。如果用的是树莓派Zero或配置很低的VPS同时处理MQTT、数据库和Web请求可能力不从心。前端资源过大。如果引入了过多未压缩的JavaScript库或者图表库如ECharts按需引入没做好。网络延迟。前端、后端、MQTT broker不在同一个内网公网传输带来延迟。解决优化后端代码。使用异步数据库驱动如aiosqlite确保WebSocket广播不会阻塞主线程。对于考勤这种读多写少的场景可以考虑使用Redis缓存最新的在场列表。前端构建时开启代码压缩和分包。对于Vue和Element Plus使用按需导入。ECharts也使用按需引入。将整个系统部署在同一个局域网内。MQTT broker、后端API、前端页面最好由同一台主机提供服务最大限度减少网络跳转。如果必须公网访问可以考虑使用更高效的协议如MQTT over WebSocket直接让前端连接MQTT broker。问题四BLE工卡ID被伪造或重放攻击。风险如果有人嗅探到广播包复制其中的ID数据可以伪造打卡。解决在广播数据中加入动态加密或认证。一个简单有效的方法是使用滚动码Rolling Code。工卡和网关共享一个密钥和一个计数器。每次广播工卡将计数器值和员工ID一起用密钥加密如AES-128然后将密文和计数器值明文或加密一起广播。网关收到后用相同的密钥解密并验证计数器值是否在可接受的窗口内防止重放。计数器每次广播后递增。这样即使截获一次广播数据也无法用于下一次伪造。当然这增加了终端和网关的计算开销需要根据芯片性能权衡。这个“NU87DK”项目从构思到稳定运行花了大概一个多月的时间大部分时间都耗在调试无线信号的稳定性和优化功耗上。硬件项目就是这样软件逻辑可能一天就写完了但让它在真实环境里可靠地跑起来需要反复的测试和打磨。现在这套系统已经在我们的创客空间运行了小半年识别准确率很高管理起来也非常方便算是达到了最初的设想。如果你也想做一个希望这篇超详细的记录能帮你省下不少摸索的时间。