【NodeJs-5天学习】第三天实战篇② ——基于物联网的WiFi自动打卡考勤系统

面向读者群体

  • ❤️ 电子物联网专业同学,想针对硬件功能构造简单的服务器,不需要学习专业的服务器开发知识 ❤️
  • ❤️ 业余爱好物联网开发者,有简单技术基础,想针对硬件功能构造简单的服务器❤️

技术要求

  • HTMLCSSJavaScript基础更好,当然也没事,就直接运行实例代码学习

专栏介绍

  • 通过简短5天时间的渐进式学习NodeJs,可以了解到基本的服务开发概念,同时可以学习到npm、内置核心API(FS文件系统操作、HTTP服务器、Express框架等等),最终能够完成基本的web开发,而且能够部署到公网访问。

学习交流群

  • NodeJs物联网五天入门学习之旅(搜索:729040020

🙏 此博客均由博主单独编写,不存在任何商业团队运营,如发现错误,请留言轰炸哦!及时修正!感谢支持!🎉 欢迎关注 🔎点赞 👍收藏 ⭐️留言📝

1. 前言

在学习ESP8266 WiFi探针时,我们了解通过Probe Request帧可以获取到无线设备(手机、手提电脑等)的MAC地址。

MAC地址可以简单理解为无线网卡地址。每一块无线网卡出厂时都会由厂家分配全球唯一的MAC地址,用来表示它的唯一性

现代社会上,基本上人手一部智能手机,自带wifi功能。只要我们打开了WiFi功能,我们就可以通过自动捕获手机发出的 802.11 帧 来获取到对应的手机MAC地址。

当我们在后台服务器上预先配置好 MAC地址与用户信息的关联关系(比如用户名字、用户工号、学生编号等),并且把捕获到的MAC地址上传到后台服务器进行对比,我们就可以完成自动考勤或者无线点名功能。这整个过程都是无感知、全自动。

这里的服务器就可以用到我们NodeJs的Express服务器。

当然,如果你想骗过考勤系统或者点名系统,那么建议你学习我的混杂模式篇,自己伪装一个proberequest管理帧,实现不在场证据(请不要告诉你的老师)。

2.实现思路

实现三部曲

  • esp8266开启混杂Sniffer模式
  • 捕获管理帧和数据帧,解析出MAC地址
  • 上传到NodeJs服务器,后台服务器会对mac地址和用户信息进行比对,然后发送到班级飞书群里面。

具体帧的含义请参考

这里涉及到的知识点:

2.1 NodeJs服务器代码

在这里插入图片描述

2.1.1 对接Express服务器
  • index.js
// 1、导入所需插件模块
const express = require("express")
const {getIPAdress} = require('./utils/utils.js')
const bodyParser = require('body-parser')
const {router} = require('./router/router.js')

// 2、创建web服务器
let app = express()
const port = 8266 // 端口号                 
const myHost = getIPAdress();

// 3、注册中间件,app.use 函数用于注册全局中间件 (局部中间件?)

/***
 * Express(npm ls 包名 参考版本号) 内置了几个常用的中间件:
 * - express.static 快速托管静态资源的中间件,比如 HTML文件、图片、CSS等
 * - express.json 解析JSON格式的请求体数据 (post请求:application/json)
 * - express.urlencoded 解析 URL-encoded 格式的请求体数据(表单 application/x-www-form-urlencoded)
 * 
*/

// 3.1 预处理中间件

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));

app.use(function(req, res, next){
    // url地址栏出现中文则浏览器会进行iso8859-1编码,解决方案使用decode解码
    console.log('解码之后' + decodeURI(req.url));
    console.log('URL:' + req.url);
    console.log(req.body);
    next()
})

// 3.2 路由中间件
app.use(router)

// 3.3 错误级别中间件(专门用于捕获整个项目发生的异常错误,防止项目奔溃),必须注册在所有路由之后
app.use((err, req, res, next) => {
    console.log('出现异常:' + err.message)
    res.send('Error: 服务器异常,请耐心等待!')
})

// 4、启动web服务器
app.listen(port,() => {
    console.log("express 服务器启动成功 http://"+ myHost +":" + port);
});
2.1.2 对接Mac地址处理
// 1、导入所需插件模块
const express = require("express")
const fs = require('fs')
const time = require('../utils/time.js')
const alarmFeishu = require('../alarm/alarm_feishu.js')

// 2、创建路由对象
const router = express.Router();

// 用户配置信息
var fileName = './config/三年一班.json';
var config = JSON.parse(fs.readFileSync(fileName));
var configMap = new Map()
config.forEach(element => {
  var key = element.mac
  var value = element.name
  configMap.set(key, value)
})

console.log(configMap)

// 3、挂载具体的路由
// 配置add URL请求处理
// 参数1:客户端请求的URL地址
// 参数2:请求对应的处理函数
//        req:请求对象(包含与请求相关属性方法)
//        res:响应对象(包含与响应相关属性方法)

router.post('/api/add/check', (req, res) => {
    var body = req.body
    var datas = body.datas
    var name = ''
    var date = time.getCurrentDate()
    var fileName = './storage/fs/' + date + '_打卡记录.txt';

    if (datas){
      datas.forEach(element => {
        var value = element.value
        name = configMap.get(value)

        if (name) {
          var exist = fs.existsSync(fileName)
          var content = time.getCurrentDateTime() + ' ' + name + '\n'
          alarmFeishu.sendText(`${name} 打卡了!`)
          if (exist) {
            fs.appendFile(fileName, content ,function (err, fd){
              if (err) {
                  return console.error(err);
              }
              console.log("文件追加成功!");
            });
          } else {
            fs.writeFile(fileName, content, {flag: 'a'}, function(err){
              if(err){
                  return console.log(err);
              }else {
                  console.log("写入成功");
              }
            });
          }
          console.log(`${name} 打卡了!`)
          // throw new Error('模拟项目抛出错误!')
        } else {
          console.log(`${value} 无法在配置表中找到!`)
        }
      });
    }
    res.send("OK")
})

// 4、向外导出路由对象
module.exports = {
    router
}

首先会把用户配置信息映射为一个map对象

[{
	"name": "霍师傅",
	"mac": "B0:E1:7E:70:25:CD"
}, {
	"name": "华师傅",
	"mac": "78:DA:07:04:5D:18"
}, {
	"name": "陈师傅",
	"mac": "30:FC:68:19:52:A4"
}, {
	"name": "叶师傅",
	"mac": "F4:EE:14:0E:4C:14"
}, {
	"name": "张师傅",
	"mac": "94:B9:7E:1A:42:F9"
}, {
	"name": "滑师傅",
	"mac": "1C:60:DE:AE:D9:06"
}]

有数据过来的时候需要匹配map,然后把数据写入文件里面。

var fileName = './storage/fs/' + date + '_打卡记录.txt';

打卡记录以天隔开。

2.1.3 对接飞书群处理
// 1、导入所需插件模块
const request = require('request')

/******** 飞书自定义机器人相关配置信息 ***********/
// 官方文档:https://open.feishu.cn/document/ukTMukTMukTM/ucTM5YjL3ETO24yNxkjN

// token,从机器人链接得到,替换为自己的
const tokenHouge = `052e7e00-7455-437d-838f-xxxxxx`

const sendMsgUrl = `https://open.feishu.cn/open-apis/bot/v2/hook/${tokenHouge}`
/******** 飞书自定义机器人相关配置信息 ***********/

/**
 * 真正发送消息
 * "{\"msg_type\":\"text\",\"content\":{\"text\":\"<at user_id=\\\"all\\\">所有人</at> %s\"}}"
*/
function sendMessage(content){
  const requestData = {
    msg_type: "text",
    content: {
      text: `${content}`
    }
  }

  request({
    url: sendMsgUrl,
    method: "POST",
    json: true,
    headers: {
        "content-type": "application/json",
    },
    body: requestData
  }, function(error, response, body) {
    console.log(body)
    if (!error && response.statusCode == 200) {
    }
  }); 
}

/***
 * 发送具体消息 
 */
function sendText(content) {
  sendMessage(content)
}

// 4、向外导出路由对象
module.exports = {
  sendText,
}

这里会构造出信息发送到飞书群。

alarmFeishu.sendText(`${name} 打卡了!`)

2.2 ESP8266代码

/**
 *  功能:ESP8266 自动考勤系统
 *  作者:单片机菜鸟
 *  时间:2022-08-06
 *  描述:
 *      1.OneNet平台端:创建Http协议的产品,创建设备
 *      2.开启混杂模式,收集MAC地址
 *      2.把获取的MAC地址上传到OneNet平台
 *      
 *  硬件材料:
 *   1、ESP8266-12 NodeMcu板子
 *
 */

// 导入必要的库
#include <ESP8266WiFi.h>        // 引入WiFi核心库
#include <ArduinoJson.h>
#include <ESP8266HTTPClient.h>  // 引入HttpClient库
#include <stdlib.h>             // 引入定时库
#include <Ticker.h>
#include "H_project.h"          // 上传服务相关
#include "H_80211Frame.h"       // 混杂模式相关定义库

void setup() {
  // put your setup code here, to run once:
  initSystem();
}

void loop() {
  //每1s切换一次信道 也就是每个信道的工作时间是1s
  if (millis() - hop_time >= 1000) {
    hop_time = millis();
    hop_channel++;

    if (hop_channel > 13) {
      isUploadMac = true;
      hop_channel = 1;
    }
    Serial.println(hop_channel);
    enable_promisc(hop_channel);
  }

  // 捕获完一轮之后上传一次 也就是 1-13信道
  if (isUploadMac){
    isUploadMac = false;
    if (unique_num > 0){
      upload_mac_to_server();
    } else {
      Serial.println("------------- No Match ------------------");
    }
    Serial.println("------------- END ------------------");
    Serial.println("------------- START ----------------");
  }
}

/**
 * 初始化系统
 */
void initSystem(void){
    Serial.begin (115200);
    Serial.println("\r\n\r\nStart ESP8266 自动考勤");
    Serial.print("Firmware Version:");
    Serial.println(VER);
    Serial.print("SDK Version:");
    Serial.println(ESP.getSdkVersion());
    wifi_station_set_auto_connect(0);//关闭自动连接
    ESP.wdtEnable(5000);
    pinMode(LED_BUILTIN, OUTPUT);

    hop_channel = 1;
    enable_promisc(hop_channel);
    Serial.println("------------- START ----------------");
}

/**
 * 连接到AP热点
 */
void connectToAP(void){
    int cnt = 0;
    WiFi.begin(ssid, password);
    while (WiFi.status() != WL_CONNECTED) {
          delay(500);
          cnt++;
          Serial.print(".");
          if(cnt>=40){
            cnt = 0;
            //重启系统
            delayRestart(1);
          }
    }
}

/*
*  WiFiTick
*  检查是否需要初始化WiFi
*  检查WiFi是否连接上
*  控制指示灯
*/
void wifiTick(){
  static bool ledTurnon = false;
   if ( WiFi.status() != WL_CONNECTED ) {
       if (millis() - lastWiFiCheckTick > 1000) {
         lastWiFiCheckTick = millis();
         ledState = !ledState; digitalWrite(LED_BUILTIN, ledState);
         ledTurnon = false;
       }
    }else{
       if (ledTurnon == false) {
             ledTurnon = true;
             digitalWrite(LED_BUILTIN, 0);
        }
    }
}

/*
  判断,解析抓取到的数据包
 */
void do_process(uint8_t *buf)
{
  ieee80211_mgmt_frame *mgmt = (ieee80211_mgmt_frame *)buf;
  uint8_t type = mgmt->ctl.type;
  uint8_t sub_type = mgmt->ctl.subtype;
  uint8_t sta_addr[6];
  unsigned long now = millis();
  int do_flag = 0;
  
  if (type == 0){
    // 管理帧
   /**
    * AssociationRequest = 0, // 关联请求
    * AssociationResponse,    // 连接响应
    * ReassociationRequest,   // 重连接请求
    * ReassociationResponse,  // 重连接联响应
    * ProbeRequest,           // 探测请求
    * ProbeResponse,          // 探测响应
    * Beacon,                 // 信标,被动扫描时AP 发出,notify
    * ATIM,                   // 通知传输指示消息
    * Disassociation,         // 解除连接,notify
    * Authentication,         // 身份验证
    * Deauthentication,       // 解除认证,notify
    * Reserved                // 保留,未使用
    */
    if (sub_type == ProbeRequest){
      // 获取MAC地址
      memcpy(sta_addr, mgmt->addr2, 6);
      if (is_normal_mac(sta_addr)){
         add_mac(now,sta_addr);
      }
    } 
  } else {
     //此情况下,addr1肯定为AP,sta为addr2,手机发出
     if (mgmt->ctl.from_ds == 0 && mgmt->ctl.to_ds == 1){
        memcpy(sta_addr, mgmt->addr2, 6);
        do_flag = 1;
     }

     //此情况下,addr2肯定为AP,如果addr3等于addr2,为路由发出,
     if (mgmt->ctl.from_ds == 1 && mgmt->ctl.to_ds == 0){
       memcpy(sta_addr, mgmt->addr1, 6);
       do_flag = 1;
     }

     if (mgmt->ctl.from_ds == 0 && mgmt->ctl.to_ds == 0){
       memcpy(sta_addr, mgmt->addr2, 6);
       do_flag = 1;
     }

     if (do_flag == 0){
      return ;
     }

     if (is_normal_mac(sta_addr)){
         add_mac(now,sta_addr);
     }
  } 
}

/**
 * 函数说明:解析抓取到的数据包
 * 参数:
 *   1. buf 收到的数据包
 *   2. len buf的长度
 */ 
static void promisc_cb(uint8_t * buf, uint16_t len){

  if (len == 12 || len < 10){
      return;
  }

  struct RxPacket * pkt = (struct RxPacket*) buf;
  do_process((uint8_t *)&pkt->buf);
}


/**
 * 函数说明:启用特定频道的混杂模式
 * 参数:
 *   1. channel 设置频道
 */
static void enable_promisc(int channel){
  WiFi.disconnect();
  WiFi.mode(WIFI_STA);
  wifi_set_channel(channel);  // 初始化为通道
  wifi_promiscuous_enable(0); // 先关闭混杂模式
  // 注册混杂模式下的接收数据的回调函数,每收到一包数据,都会进入注册的回调函数里面。
  wifi_set_promiscuous_rx_cb(promisc_cb);
  wifi_promiscuous_enable(1); // 开启混杂模式
}

/**
 * 函数说明:关闭混杂模式
 * 参数:
 *   1. channel 设置频道
 */
static void disable_promisc(int channel){
  wifi_promiscuous_enable(0);
}

/*
 *格式化打印mac
 */
static void print_mac(const uint8_t * mac){
  char text[32];
  sprintf(text, "%02X:%02X:%02X:%02X:%02X:%02X",
          mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
  Serial.println(text);
}

/*
  *判断是否是普通mac
 */
static bool is_normal_mac(const uint8_t * mac)
{
  char text[32];
  char c;
  sprintf(text, "%02X:%02X:%02X:%02X:%02X:%02X",
          mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
  c = text[1];
  if (c == '0' || c == '4' || c == '8' || c == 'C') {
    return true;
  }

  return  false;
}

/*
  * 保存mac地址到已经上传列表
 */
static void add_upload_mac(const uint8_t *mac){
  for (int i = 0; i < unique_upload_num; i++) {
    if (memcmp(mac, upload_mac[i].mac, 6) == 0) {
      // 已经上传过
      return;
    }
  }

  if (unique_upload_num < UPLOAD_MAC){
     memcpy(&upload_mac[unique_upload_num].mac, mac, 6);
     unique_upload_num++;
  } else {
     Serial.println("unique_upload_num OVER MAX==========");
  }
}

/*
  *检测扫描到的mac是否已存在,
  *不存在,并且还有空间,添加保存 返回true
  *没空间返回false
  *已存在更新时间戳,返回false
 */
static bool add_mac(unsigned long now, const uint8_t *mac){
  int i;
  for (i = 0; i < unique_upload_num; i++) {
    if (memcmp(mac, upload_mac[i].mac, 6) == 0) {
      // 已经上传过
      return false;
    }
  }
  
  // 判断是否已经存在过
  for (i = 0; i < unique_num; i++) {
    if (memcmp(mac, unique_mac[i].mac, 6) == 0) {
      // 更新时间
      unique_mac[i].last_seen = now;
      return false;
    }
  }

  // 还有足够空间就添加进去
  if (unique_num < MAX_MAC) {
    Serial.print("New Mac: ");
    print_mac(mac);
    memcpy(&unique_mac[unique_num].mac, mac, 6);
    unique_mac[unique_num].last_seen = now;
    unique_num++;
    return true;
  } else {
    Serial.println("unique_num OVER MAX==========");
    // could not fit it
    return false;
  }
}

/*
 * 功能:mac生命周期检测,从列表清除长时间未检测到的mac
 * 参数:
 *   1. now 当前时间
 *   2. expire 过期间隔
 */
static void expire_mac(unsigned long now, unsigned long expire) {
  char text[32];
  int i;
  for (i = 0; i < unique_num; i++) {
    if ((now - unique_mac[i].last_seen) > expire) {
      // 过期之后 用数组的最后一个内容覆盖过期位置
      if (--unique_num > i) {
        sprintf(text, "%10d: ", now);
        Serial.print(text);
        print_mac(unique_mac[i].mac);
        sprintf(text, " expired: %d\n", unique_num);
        Serial.print(text);
        memcpy(&unique_mac[i], &unique_mac[unique_num], sizeof(mac_t));
      }
    }
  }
}

/*
 * 上传Mac地址
 */
static bool upload_mac_to_server(){
  uint8_t *mac;
  
  disable_promisc(hop_channel);

  DynamicJsonDocument doc(2048);
  //在 doc 对象中加入data数组
  JsonArray datas = doc.createNestedArray("datas");
  for(int i = 0;i < unique_num ; i ++){
      char text[32];
      JsonObject value = datas.createNestedObject();
      mac = unique_mac[i].mac;
      sprintf(text, "%02X:%02X:%02X:%02X:%02X:%02X",
          mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
      value["value"] = text;
  }
  
  String data;
  serializeJson(doc, data);
  serializeJsonPretty(doc, Serial); 

  connectToAP();
  retry = 0;
  Serial.println("Upload Start");
  while(!postToDeviceDataPoint(data)){
    retry ++;
    ESP.wdtFeed();
    if(retry == 20){
       retry = 0;
       delayRestart(1);
    }
  }
  Serial.println("Upload Success!"); 
  
  for (int i = 0; i < unique_num; i++){
    add_upload_mac(unique_mac[i].mac);      
    memset(&unique_mac[i], 0x00, sizeof(mac_t));
  }
  unique_num = 0;
  enable_promisc(hop_channel);
  return true;
}

2.3 测试效果

2.3.1 串口打印日志

在这里插入图片描述

2.3.2 NodeJs服务器打印的数据

在这里插入图片描述

2.3.3 txt文件存储的内容

在这里插入图片描述

2.3.4 飞书群信息展示

在这里插入图片描述

至此,一个简单的教室WiFi自动打卡考勤系统就可以了。

4.总结

篇②结合ESP8266来开发简单物联网应用——自动考勤系统,麻雀虽小五脏俱全,初学者需要理解文件系统、服务请求等等对应的知识点并加以实际应用。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

单片机菜鸟爱学习

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值