ESP8266 WiFi嗅探实现低成本人流计数:原理、代码与部署实战
1. 项目概述从“数人头”到智能感知几年前我在一个线下快闪店的项目里遇到了一个头疼的问题我们想知道店里到底来了多少人高峰期是什么时候哪些区域最受欢迎。当时试过人工计数、摄像头分析甚至考虑过昂贵的商业客流统计系统。人工不靠谱还费人力摄像头方案涉及隐私且部署复杂商业系统则成本高昂。直到我把目光投向了角落里吃灰的ESP8266开发板一个低成本、易部署、保护隐私的WiFi人流计数器方案逐渐清晰起来。这个“ESP8266 WiFi People Counter”项目核心就是利用ESP8266芯片的WiFi嗅探功能来统计附近开启WiFi功能的智能设备主要是手机的MAC地址数量从而间接估算人流量。它不连接任何网络不破解密码只是被动地监听周围设备发出的“探针请求”帧因此完全合法且不侵犯隐私。听起来是不是挺神奇的一块售价十几块钱的小板子加上一点代码就能实现原来需要专业设备才能完成的功能。这个项目非常适合门店经营者、活动组织者、创客爱好者或者任何想低成本了解空间内人流情况的人。它不能给你精确到个人的计数因为一人可能多设备或设备关闭WiFi但对于趋势分析、高峰时段判断、区域热度对比已经足够有用了。2. 核心原理与方案选型为什么是WiFi嗅探2.1 WiFi嗅探计数的工作原理要理解这个项目你得先知道你的手机在WiFi环境下是怎么“找网”的。即使你没有连接任何WiFi你的手机为了能快速发现并连接已知网络或展示可用网络列表会周期性地向外广播一种叫做“探针请求”的数据包。这个数据包里包含了手机无线网卡的唯一标识——MAC地址以及它曾经连接过的网络名称列表。ESP8266运行在监听模式时就像一个安静的收音机可以接收到空气中所有WiFi数据包包括这些“探针请求”。我们的程序就是让ESP8266专注监听这类数据包提取其中的MAC地址然后进行去重和统计。同一个手机在短时间内会发送多次请求我们通过时间窗口过滤确保在一定时间内比如5分钟同一个MAC地址只被计数一次以此来估算“新人”的到来。这里有一个关键点我们统计的是开启WiFi功能的智能设备而不是精确的“人”。这带来了方案的优缺点优点成本极低ESP8266模块、部署灵活USB供电即可、完全被动无干扰、保护个人隐私我们不关心是谁只关心有一个匿名设备出现过。缺点存在误差。一个人可能携带多台设备手机、手表、平板设备可能关闭WiFi少数老旧设备可能不频繁发送探针请求。因此它更适用于相对封闭空间内的趋势分析和相对数量比较而非绝对精准的计数。2.2 硬件选型为什么是ESP8266而非其他市面上能做WiFi嗅探的芯片不止ESP8266比如ESP32功能更强大。但在这个特定项目里ESP8266有几个无法替代的优势成本与功耗的极致平衡一个NodeMCU ESP8266开发板价格通常在15-25元人民币而ESP32要贵上一倍左右。对于只需要WiFi嗅探和简单数据上报的项目ESP8266的性能绰绰有余。在持续嗅探模式下ESP8266的功耗也经过优化搭配一个合适的电源方案如充电宝或5V适配器可以长时间稳定工作。成熟的生态与资料ESP8266面世已久其Arduino核心库非常稳定关于WiFi嗅探的示例代码和社区讨论极其丰富。这意味着你遇到的大部分问题几乎都能在网上找到解决方案开发调试门槛大大降低。够用的性能ESP8266的单核处理器和有限的内存对于完成“监听-过滤-去重-计数-上报”这条工作流来说完全足够。代码优化得当它可以轻松处理一个中小型空间内上百个设备的嗅探数据。注意ESP8266在监听模式下其本身的WiFi STA工作站或AP接入点功能是无法同时使用的。也就是说它不能一边嗅探一边作为一个设备连接你家路由器上网。数据上报需要通过其他方式比如串口输出或者周期性地切换回STA模式发送数据后再切回监听模式。这是方案设计时必须考虑的一点。2.3 软件架构设计思路一个完整可用的WiFi人流计数器不能只停留在串口打印数字。我设计的典型架构包含以下层次数据采集层运行在ESP8266上核心是WiFi嗅探程序。它负责抓包、过滤探针请求、提取MAC地址、基于时间戳去重并维护一个当前监测到的设备列表。数据暂存与聚合层ESP8266内存有限不能无限期存储所有MAC地址。通常我会设置一个滑动时间窗口例如30分钟只统计在这个窗口内出现过的独立设备。同时在本地累加“今日客流”等统计值。数据上报层这是将数据“送出去”的关键。有多种选择串口输出最简单通过USB线连接电脑在串口监视器查看实时数据。适合调试和固定点位短期监测。HTTP/MQTT上报ESP8266周期性地如每5分钟短暂切换到STA模式连接预设的WiFi将聚合好的统计数据以HTTP POST或MQTT协议发送到指定的服务器如自建的Node-RED、Home Assistant或云平台如ThingsBoard、Blynk。本地显示可以接一块OLED屏幕实时显示当前在线设备数和累计数。数据展示层服务器端接收数据存入数据库如InfluxDB、MySQL并通过可视化工具如Grafana、Home Assistant前端生成实时图表和历史报表。在本篇中我们将聚焦于最核心的数据采集层和数据上报层的基础实现让你先跑通整个流程。3. 硬件准备与开发环境搭建3.1 物料清单与连接这个项目的硬件简单到令人发指ESP8266开发板 x1推荐NodeMCU或Wemos D1 Mini因为它们自带USB转串口芯片方便编程和供电。USB数据线 x1用于供电和程序烧录。电脑 x1用于编写和上传代码。可选5V电源适配器或充电宝如需长期部署需要一个稳定的5V电源。无需任何焊接拿到板子插上线即可。3.2 开发环境配置基于Arduino IDE虽然ESP8266可以用多种方式开发但Arduino IDE对于初学者和快速原型开发是最友好的。安装Arduino IDE从Arduino官网下载并安装最新版。添加ESP8266开发板支持打开Arduino IDE点击“文件” - “首选项”。在“附加开发板管理器网址”中填入http://arduino.esp8266.com/stable/package_esp8266com_index.json点击“工具” - “开发板” - “开发板管理器”搜索“esp8266”找到并安装“esp8266 by ESP8266 Community”。安装必要的库本项目主要依赖ESP8266自带的WiFi功能无需额外库。但为了更好的网络操作可以安装ESP8266WiFi库通常已包含。选择开发板和端口将ESP8266用USB线连接电脑。在“工具” - “开发板”中选择你的板子型号如“NodeMCU 1.0”。在“工具” - “端口”中选择新出现的串口Windows下是COMx macOS/Linux下是/dev/cu.usbserial-xxx。3.3 基础代码框架解析在深入完整代码前我们先理解几个核心概念和代码片段设置ESP8266为监听模式#include ESP8266WiFi.h void setup() { Serial.begin(115200); WiFi.mode(WIFI_STA); // 先设置为工作站模式这是必要的 wifi_promiscuous_enable(0); // 确保先关闭混杂模式 WiFi.disconnect(); // 断开任何可能的连接 delay(100); // 设置混杂模式回调函数 wifi_set_promiscuous_rx_cb(packetSniffer); // 启用混杂模式以捕获所有数据包 wifi_promiscuous_enable(1); Serial.println(Started sniffing...); }wifi_set_promiscuous_rx_cb用于注册一个回调函数packetSniffer每当ESP8266收到一个WiFi数据包时这个函数就会被调用。wifi_promiscuous_enable(1)则是启动监听模式的开关。核心回调函数的结构void packetSniffer(uint8_t *buf, uint16_t len) { // 数据包结构复杂我们需要从中提取出MAC地址 // 一个简化版的探针请求判断逻辑 if (len 28) return; // 数据包太短不是我们关心的 uint8_t frameControl buf[0]; uint8_t frameType (frameControl 0x0C) 2; // 提取帧类型 uint8_t frameSubType (frameControl 0xF0) 4; // 提取帧子类型 // 判断是否为管理帧0x00中的探针请求0x04 if (frameType 0x00 frameSubType 0x04) { // 发送者MAC地址位于数据包的[10]到[15]字节 char macAddr[18]; snprintf(macAddr, sizeof(macAddr), %02X:%02X:%02X:%02X:%02X:%02X, buf[10], buf[11], buf[12], buf[13], buf[14], buf[15]); // 处理这个MAC地址去重、计数等 processNewMac(macAddr); } }这段代码是项目的灵魂。它检查每个数据包的帧类型和子类型筛选出“探针请求”然后从固定偏移量提取出发送设备的MAC地址。4. 完整实现与代码深度解析4.1 数据结构设计与去重逻辑直接每收到一个MAC地址就计数数据会严重重复。我们需要一个去重机制。一个简单有效的方法是使用“滑动时间窗口”。#include map #include string std::mapstd::string, unsigned long macHistory; // 存储MAC地址和最后一次看到的时间戳 const unsigned long MAC_TIMEOUT 300000; // 5分钟300秒 * 1000毫秒视为同一个“人”的超时时间 unsigned int uniqueCount 0; // 在当前时间窗口内的独立设备数 unsigned long totalCountToday 0; // 今日累计简易版重启会清零 void processNewMac(const String mac) { unsigned long currentTime millis(); auto it macHistory.find(mac); if (it macHistory.end()) { // 这是一个新的MAC地址 macHistory[mac] currentTime; uniqueCount; totalCountToday; Serial.printf([New Device] %s | Total in window: %d | Today: %d\n, mac.c_str(), uniqueCount, totalCountToday); } else { // MAC地址已存在检查是否超时 if (currentTime - it-second MAC_TIMEOUT) { // 超过5分钟再次出现视为一次新的访问 it-second currentTime; // 更新时间戳 uniqueCount; totalCountToday; Serial.printf([Returning Device] %s | Total in window: %d | Today: %d\n, mac.c_str(), uniqueCount, totalCountToday); } else { // 5分钟内重复出现忽略 it-second currentTime; // 仍然更新时间戳保持活跃 } } // 定期清理超时的MAC地址防止map无限膨胀重要 static unsigned long lastCleanup 0; if (currentTime - lastCleanup 60000) { // 每60秒清理一次 lastCleanup currentTime; cleanupOldMacs(currentTime); } } void cleanupOldMacs(unsigned long currentTime) { for (auto it macHistory.begin(); it ! macHistory.end(); ) { if (currentTime - it-second MAC_TIMEOUT) { // 这个地址已经超时从当前窗口计数中减去 uniqueCount--; it macHistory.erase(it); // 删除并获取下一个迭代器 } else { it; } } }这里使用了C的std::map来存储MAC地址和其最后一次出现的时间戳。processNewMac函数是核心逻辑新地址则加入并计数已存在地址则判断是否超时超时则视为一次新的访问并更新计数未超时则仅更新时间戳。cleanupOldMacs函数定期清理超时的记录并更新uniqueCount这样uniqueCount始终代表过去5分钟内活跃的唯一设备数这是一个非常有用的实时指标。4.2 数据上报策略STA模式间歇连接如前所述ESP8266在监听模式下无法联网。一个实用的策略是定时切换模式。#include ESP8266WiFi.h #include ESP8266HTTPClient.h const char* ssid Your_WiFi_SSID; const char* password Your_WiFi_Password; const char* serverUrl http://your-server.com/api/counter; // 你的数据接收端点 const unsigned long REPORTING_INTERVAL 300000; // 每5分钟上报一次 const unsigned long CONNECTION_TIMEOUT 30000; // 连接WiFi和服务器的最长等待时间 unsigned long lastReportTime 0; void reportData() { Serial.println(Attempting to switch to STA mode and report...); // 1. 关闭混杂模式停止嗅探 wifi_promiscuous_enable(0); delay(100); // 2. 切换到STA模式并连接WiFi WiFi.mode(WIFI_STA); WiFi.begin(ssid, password); unsigned long connectStart millis(); while (WiFi.status() ! WL_CONNECTED millis() - connectStart CONNECTION_TIMEOUT) { delay(500); Serial.print(.); } if (WiFi.status() WL_CONNECTED) { Serial.println(\nWiFi connected!); // 3. 准备并发送HTTP POST请求 HTTPClient http; http.begin(serverUrl); http.addHeader(Content-Type, application/json); // 构建JSON数据 String jsonPayload {; jsonPayload \device_id\:\ String(ESP.getChipId(), HEX) \,; jsonPayload \current_count\: String(uniqueCount) ,; jsonPayload \total_today\: String(totalCountToday); jsonPayload }; int httpCode http.POST(jsonPayload); if (httpCode 0) { String response http.getString(); Serial.printf(Report successful! HTTP Code: %d, Response: %s\n, httpCode, response.c_str()); } else { Serial.printf(Report failed! Error: %s\n, http.errorToString(httpCode).c_str()); } http.end(); // 4. 断开WiFi为切换回监听模式做准备 WiFi.disconnect(); delay(100); } else { Serial.println(\nWiFi connection failed. Skipping report.); } // 5. 重新启用监听模式 WiFi.mode(WIFI_OFF); delay(100); wifi_set_promiscuous_rx_cb(packetSniffer); wifi_promiscuous_enable(1); Serial.println(Switched back to sniffing mode.); } void loop() { unsigned long currentTime millis(); // 定时上报逻辑 if (currentTime - lastReportTime REPORTING_INTERVAL) { reportData(); lastReportTime currentTime; } // 其他循环任务如清理过期MAC已在回调函数中处理 delay(10); // 让出CPU时间 }reportData函数是关键。它先停止嗅探连接WiFi发送包含当前计数和累计数的JSON数据到服务器然后断开连接重新启动嗅探模式。这个过程通常很快几秒到十几秒取决于网络状况。在断开连接的短暂窗口期会丢失一些数据包但对于5分钟上报一次的频率来说这个误差是可接受的。4.3 整合后的完整示例代码将以上部分整合并增加一些健壮性处理得到如下可用于实际部署的代码框架#include ESP8266WiFi.h #include ESP8266HTTPClient.h #include map #include string // 配置区 const char* REPORT_SSID Your_SSID; const char* REPORT_PASSWORD Your_PASSWORD; const char* SERVER_URL http://192.168.1.100:8080/data; // 示例本地服务器地址 const unsigned long MAC_TIMEOUT 300000; // 5分钟 const unsigned long REPORT_INTERVAL 300000; // 5分钟 const unsigned long CLEANUP_INTERVAL 60000; // 1分钟 // 全局变量 std::mapstd::string, unsigned long deviceMap; unsigned int currentDevices 0; unsigned long totalSessions 0; unsigned long lastReportTime 0; unsigned long lastCleanupTime 0; // 嗅探回调函数 void packetSniffer(uint8_t *buf, uint16_t len) { if (len 28) return; uint8_t frameControl buf[0]; uint8_t frameType (frameControl 0x0C) 2; uint8_t frameSubType (frameControl 0xF0) 4; if (frameType 0x00 frameSubType 0x04) { char mac[18]; snprintf(mac, sizeof(mac), %02X:%02X:%02X:%02X:%02X:%02X, buf[10], buf[11], buf[12], buf[13], buf[14], buf[15]); processMacAddress(String(mac)); } } void processMacAddress(String macStr) { unsigned long now millis(); std::string mac macStr.c_str(); auto it deviceMap.find(mac); if (it deviceMap.end()) { // 新设备 deviceMap[mac] now; currentDevices; totalSessions; Serial.printf([NEW] MAC: %s | Current: %d | Total: %lu\n, macStr.c_str(), currentDevices, totalSessions); } else { if (now - it-second MAC_TIMEOUT) { // 设备返回超时后再次出现 it-second now; currentDevices; totalSessions; Serial.printf([RETURN] MAC: %s | Current: %d | Total: %lu\n, macStr.c_str(), currentDevices, totalSessions); } else { // 活跃设备更新最后看到时间 it-second now; } } } void cleanupOldEntries() { unsigned long now millis(); unsigned int removed 0; for (auto it deviceMap.begin(); it ! deviceMap.end(); ) { if (now - it-second MAC_TIMEOUT) { it deviceMap.erase(it); removed; } else { it; } } if (removed 0) { currentDevices deviceMap.size(); // 重新计算当前设备数 Serial.printf([CLEANUP] Removed %d old entries. Current devices: %d\n, removed, currentDevices); } } void sendDataToServer() { Serial.println( Switching to STA mode for reporting ); // 停止嗅探 wifi_promiscuous_enable(0); delay(200); WiFi.mode(WIFI_STA); WiFi.disconnect(); delay(200); WiFi.begin(REPORT_SSID, REPORT_PASSWORD); int retries 0; while (WiFi.status() ! WL_CONNECTED retries 20) { delay(500); Serial.print(.); retries; } if (WiFi.status() WL_CONNECTED) { WiFiClient client; HTTPClient http; if (http.begin(client, SERVER_URL)) { http.addHeader(Content-Type, application/json); String payload {; payload \sensor_id\:\ String(ESP.getChipId(), HEX) \,; payload \current\: String(currentDevices) ,; payload \total\: String(totalSessions) ,; payload \rssi\: String(WiFi.RSSI()); payload }; int httpCode http.POST(payload); if (httpCode 0) { Serial.printf(POST Success. Code: %d, Payload: %s\n, httpCode, payload.c_str()); } else { Serial.printf(POST Failed. Error: %s\n, http.errorToString(httpCode).c_str()); } http.end(); } else { Serial.println(HTTP begin failed.); } WiFi.disconnect(true); delay(100); } else { Serial.println(\nWiFi connection failed. Reporting aborted.); } // 恢复嗅探模式 WiFi.mode(WIFI_OFF); delay(200); wifi_set_promiscuous_rx_cb(packetSniffer); wifi_promiscuous_enable(1); Serial.println( Resumed sniffing mode \n); } void setup() { Serial.begin(115200); Serial.println(\n\nESP8266 WiFi People Counter Initializing...); // 初始化WiFi并进入监听模式 WiFi.mode(WIFI_STA); wifi_promiscuous_enable(0); WiFi.disconnect(); delay(100); wifi_set_promiscuous_rx_cb(packetSniffer); wifi_promiscuous_enable(1); Serial.println(Sniffer started. Waiting for probe requests...); lastReportTime millis(); lastCleanupTime millis(); } void loop() { unsigned long now millis(); // 定时清理过期MAC地址 if (now - lastCleanupTime CLEANUP_INTERVAL) { cleanupOldEntries(); lastCleanupTime now; } // 定时上报数据 if (now - lastReportTime REPORT_INTERVAL) { sendDataToServer(); lastReportTime now; } delay(50); // 主循环延迟 }5. 部署优化与实战经验5.1 天线优化与部署位置ESP8266的板载PCB天线性能有限部署位置极大影响计数效果。天线方向确保板载天线区域通常是一根蛇形走线朝向需要监测的区域中心避免被金属物体或厚墙遮挡。高度与角度部署在2-3米高度天线平面与地面平行信号覆盖范围最广。不要塞在角落或柜子里。干扰源远离大功率路由器、微波炉、蓝牙设备等强信号源避免同频干扰导致丢包。实测技巧上传一个简单的测试固件在串口监视器里观察不同位置下嗅探到的设备数量变化选择信号最稳定、数量最多的点。5.2 电源管理与长期运行长期部署必须考虑电源稳定性。电源选择优先使用5V/1A以上的手机充电头或移动电源。避免使用电脑USB口长期供电可能因电脑休眠而断电。功耗考量ESP8266在持续监听模式下电流约70mA。一个10000mAh的充电宝理论上可支持约140小时近6天。如需更长续航可考虑深度睡眠定时唤醒方案但会丢失唤醒期间的数据。看门狗与异常重启代码中应加入软件看门狗或定时重启逻辑防止程序跑飞。可以在loop()中检查运行时间超过24小时自动调用ESP.restart()。5.3 数据接收服务器搭建简易版数据上报需要有个地方接收。这里给出一个用Node.js Express搭建的极简服务器示例将数据打印到控制台并保存到文件。// server.js const express require(express); const fs require(fs).promises; const path require(path); const app express(); const port 8080; app.use(express.json()); app.post(/data, async (req, res) { const data req.body; const timestamp new Date().toISOString(); const logEntry ${timestamp} - Sensor: ${data.sensor_id}, Current: ${data.current}, Total: ${data.total}, RSSI: ${data.rssi}\n; console.log(logEntry.trim()); try { await fs.appendFile(path.join(__dirname, counter.log), logEntry); res.status(200).send(OK); } catch (err) { console.error(Write file error:, err); res.status(500).send(Server Error); } }); app.listen(port, () { console.log(Data receiver listening at http://localhost:${port}); });在电脑上运行node server.js并将ESP8266代码中的SERVER_URL改为http://你的电脑IP:8080/data即可看到数据源源不断地上报上来。5.4 校准与误差理解这个方案的数据是相对的需要校准来理解其意义。基准测试在已知确切人数的场景如小型会议进行测试对比系统计数与实际人数计算一个大致的比例系数例如系统计数20对应实际人数15系数0.75。关注趋势比起绝对数字更应关注“当前设备数”的变化曲线。上午10点比9点增长了200%这个信息比“现在有30个设备”更有价值。多节点部署在大型空间如商场楼层可以部署多个ESP8266通过比较各节点的信号强度RSSI来粗略定位设备活跃区域或者简单地将各节点计数相加需注意去重问题更复杂。6. 常见问题与排查技巧实录在实际部署中你肯定会遇到各种问题。下面是我踩过坑后总结的排查清单问题现象可能原因排查步骤与解决方案串口监视器看不到任何设备1. ESP8266未进入监听模式。2. 代码中频道Channel设置不对。3. 周围真的没有WiFi设备。1. 检查setup()中是否调用了wifi_promiscuous_enable(1)。2. 默认监听所有频道。可尝试用wifi_set_channel(X)锁定到附近路由器最多的频道如1, 6, 11。3. 用手机打开WiFi设置确认手机正在搜索网络。设备计数远低于预期1. 信号覆盖差。2. MAC地址随机化iOS 14/Android 10。3. 程序去重逻辑过于激进。1. 优化部署位置见5.1。2. 这是最大误差来源。现代手机会使用随机MAC发送探针。无法解决只能接受它降低了设备唯一性但趋势分析依然有效。3. 调整MAC_TIMEOUT从5分钟缩短到2-3分钟试试。ESP8266运行一段时间后重启或死机1. 内存泄漏std::map或String不当使用。2. 电源不稳定。3. 看门狗超时。1. 确保定期清理deviceMap。避免在回调函数中使用String拼接等动态内存操作改用字符数组。2. 更换质量更好的5V电源适配器。3. 在耗时操作如连接WiFi中适时调用yield()或delay(0)喂狗。WiFi连接上报经常失败1. 网络信号弱。2. 服务器地址或端口错误。3. 切换模式时间太短。1. 在reportData函数中增加WiFi信号强度RSSI判断过低则放弃本次上报。2. 用电脑浏览器先测试服务器URL是否能访问。3. 在WiFi.disconnect()和重新wifi_promiscuous_enable(1)之间增加更长的delay(500)。计数出现剧烈、不合理的跳变1. 有固定设备如智能插座、电视周期性发送大量探针请求。2. 多个ESP8266部署区域重叠重复计数。1. 在代码中增加“设备白名单”过滤功能忽略已知的固定设备MAC地址。2. 为不同节点设置不同的上报ID在后端根据位置进行去重较复杂。一个关键的实操心得在packetSniffer回调函数里千万不能做任何耗时或复杂的内存操作比如Serial.print、String操作、动态内存分配。这个函数每秒会被调用成千上万次任何一点延迟都会导致丢包甚至系统崩溃。我的做法是在回调函数里只做最简单的数据提取和拷贝将MAC地址存入一个环形缓冲区然后在loop()主循环里进行耗时的去重、打印和上报逻辑。这能极大提升系统的稳定性和抓包效率。最后这个项目的乐趣在于它的简洁与实用之间的平衡。它不是一个工业级的精准解决方案但它以极低的成本和门槛打开了一扇感知物理世界人流的窗户。你可以用它来优化小店员的排班评估展览不同区域的受欢迎程度或者只是简单地想知道家里一天到底来了多少“电子访客”。