Arduino 小白的 DIY 空气质量检测仪(2)- 甲醛模块、程序入口、目录结构

72 阅读6分钟

今日份

在这里插入图片描述

关闭门窗,开启空调新风低档位,睡一个晚上,可以看出,除了甲醛和TVOC值得关心,二氧化碳浓度也挺重要的咧。

关闭门窗,是为了隔绝噪音和光线。 新风低档位,是因为中高档噪音有点大。

实测过,连新风都不开,二氧化碳数值大概在1800+ppm,接近2000。 实测过,最有效降低二氧化碳,是开启卫身间的排气扇,就是挺吵的。

代码Go

过程中,不会有太详细的理论,毕竟小白我也不知道理解的准不准确哈,理论可以参考附近的大神文章系列。

目录结构

- arduino-air-monitor        项目目录
  - arduino-air-monitor.ino  程序入口
  - src
    - docs                   一些传感器文档
    - sgp_40                 TVOC 模块 sgp40 的官方算法
  - m_HCHO.h                 甲醛 模块逻辑
  - m_TVOC.h                 TVOC 模块逻辑
  - m_CO2.h                  二氧化碳 模块逻辑
  - m_Temperature_Tumidity.h 温湿度 模块逻辑
  - m_UV.h                   UV 紫外钱 模块逻辑
  - DFRobot_SGP40.h          TVOC 模块 sgp40 库 改版
  - DFRobot_SGP40.cpp        TVOC 模块 sgp40 库 改版
  - clock.h                  时钟逻辑
  - display.h                OLED 显示器逻辑
  - buttons.h                按钮逻辑

根据 Arduino IDE 官方说明和特点,根目录文件会直接显示在 IDE 的 Tabs 中:

在这里插入图片描述

而且,其它代码需放在 src 目录下,才可以方便通过 #include "src/xxx/xxx.h" 引入。

程序入口

以下是基本结构:

#include "display.h"
#include "clock.h"
#include "buttons.h"

#include "m_Temperature_Tumidity.h"
#include "m_UV.h"
#include "m_TVOC.h"
#include "m_CO2.h"
#include "m_HCHO.h"

// ...略

// Last process time
long lastProcessSecond = 0;

void setup() {
  Serial.begin(115200);

  Buttons::Btn_1.init();

  Display::OLED.init();
  Clock::RTC.init();

  Module::TVOC.init();
  Module::HCHO.init();
  Module::CO2.init();
}

void process(bool display) {
  // ...略

  Module::HCHO.read();

  // 打印信息
  char nowStr[20] = "";
  now.tostr(nowStr);
  Serial.println(nowStr);
  Serial.print("Temperature:");
  Serial.print(Module::Temperature.getValue());
  Serial.println(Module::Temperature.unit);
  Serial.print("Humidity:");
  Serial.print(Module::Humidity.getValue());
  Serial.println(Module::Humidity.unit);
  Serial.print("UV:");
  Serial.print(Module::UV.getValue());
  Serial.println(Module::UV.unit);
  Serial.print("CO2:");
  Serial.print(Module::CO2.getValue());
  Serial.println(Module::CO2.unit);
  Serial.print("TVOC:");
  Serial.print(Module::TVOC.getValue());
  Serial.println(Module::TVOC.unit);
  Serial.print("HCHO:");
  Serial.print(Module::HCHO.getValue());
  Serial.println(Module::HCHO.unit);

  if (display) {
    // OLED 显示逻辑
  	// ...略
  }
}

bool oledDisplay = true;

void loop() {
  Buttons::Btn_1.loop();

  bool clicked = Buttons::Btn_1.getValue();

  if (clicked) {
    oledDisplay = !oledDisplay;
  }

  if (oledDisplay) {
    // OLED 显示
  	// ...略
  } else {
    // OLED 不显示
    // ...略
  }

  // ...略

  // process each second
  if (millis() / 1000 - lastProcessSecond >= 1) {
    Serial.println();

    Serial.print("loop:");
    Serial.print(millis() / 1000);
    Serial.println("s");

    process(oledDisplay);

    lastProcessSecond = millis() / 1000;
  }
}

// 后面定义了一些 OLED 显示逻辑,可以理解成 UI 层的事情
// ...略

这里特别说一说: 由于 loop(),是使了劲的不停快速连续调用。 但是,不能使了劲的不停请求传感器,它们扛不住。 可是,显示时间又希望每次 loop 更新一次。 这里就不适合直接用 delay了。 所以这里定义了一个 lastProcessSecond,配合 millis() 获得启动时长(秒),计算每秒间隔,每秒才执行一次 process 方法,更新一次各传感器的值。

模块定义

设计

上面 “m_” 开头的代码问题,我定义为各种传感器的入口。 因为它们有类似的行为和步骤,抽离出来后,程序入口可以清爽许多,程序入口只需要关心传感器的初始化、更新、读取值即可。 基本结构如下:

// 必要依赖
#include <Arduino.h>

// 其它依赖
#include <XXX.h>
#include "src/YYY.h"

// 宏定义
// - 引脚定义
#define _Pin_AAA 1
#define _Pin_BBB A2
// - I2C 地址定义
#define _Address_CCC 0Xff
// 其它定义
// ...

// “模块”
namespace Module {

// 初始化一些库的实例
// SoftI2C SoftWire = //...
// DFRobot_SGP40 _m_sgp_40(&SoftWire);
// ...

	struct _指标名 {
	    // 初始化逻辑(放在程序入口 setup 中)
		void init() {
			// ...
		}
		// 读取(放在程序入口 loop 中)
		void read() {
		   // ...
		}
		// 读取值
		int getValue() {
		   // ...
		}
		// 其它方法逻辑
		// ...
		// 单位名
		char unit[6] = "ddd";
	} 指标名;

}

试过用类/对象的方式进行定义抽离,但发现依赖的一些库,它需要 setup 之外顶层进行实例化,再次用类/对象包装意义不大,后来感觉用命名空间和结构体简单包装一下也挺好。

使用

以下是基本使用思路:

// ...略
#include "m_指标名.h"
// ...略

void setup() {
	Serial.begin(115200);
	
	// ...略
	
	Module::指标名.init();
	
	// ...略
}

void loop() {
	// ...略
	
	Module::指标名.read();
	
	// ...略
	
	// 实际上,读取传感器放在每秒执行的 process 中(上面“程序入口”提到)
   	Serial.print("指标名:");
  	Serial.print(Module::指标名.getValue());
  	Serial.println(Module::指标名.unit);
	
	// ...略
}

逐个分析

m_HCHO.h

#include <Arduino.h>
#include <SoftwareSerial.h>

// 基于WZ Library(由于该库使用了Stream.h,出现无法解释的异常,因此简单重写之)

// 甲醛传感器
// 型号:WZ-S
// 接口:5V->VCC(5V)、G->GND、R->D9(RX)、T->D8(TX)
// 协议:UART

#define _Pin_HCHO_RX 8
#define _Pin_HCHO_TX 9
#define _Frame_Start_HCHO 0Xff  // 帧 起始符
#define _Frame_Len_HCHO 9       // 帧长度

namespace Module {

SoftwareSerial _serial_hcho(_Pin_HCHO_RX, _Pin_HCHO_TX);

namespace WZ_S {

// 帧数据
byte buffer[_Frame_Len_HCHO] = {};

// 帧数据组装完成
bool buffer_done = false;

// 上一次的值
int lastData = 0;
// 值
int data = 0;

void passiveMode() {
  // 0xFF, 0x01, 0x78, 0x41, 0x00, 0x00, 0x00, 0x00, 0x46
  _serial_hcho.write((int)0xFF);
  _serial_hcho.write((int)0x01);
  _serial_hcho.write((int)0x78);
  _serial_hcho.write((int)0x41);
  _serial_hcho.write((int)0x00);
  _serial_hcho.write((int)0x00);
  _serial_hcho.write((int)0x00);
  _serial_hcho.write((int)0x00);
  _serial_hcho.write((int)0x46);
}

void requestRead() {
  // 0xFF, 0x01, 0x86, 0x00, 0x00, 0x00, 0x00, 0x00, 0x79
  _serial_hcho.write((int)0xFF);
  _serial_hcho.write((int)0x01);
  _serial_hcho.write((int)0x86);
  _serial_hcho.write((int)0x00);
  _serial_hcho.write((int)0x00);
  _serial_hcho.write((int)0x00);
  _serial_hcho.write((int)0x00);
  _serial_hcho.write((int)0x00);
  _serial_hcho.write((int)0x79);
}

void Calc() {
  unsigned int timeout = 0;

  // 位序号
  int count = 0;

  // 起始
  bool flag_start = false;
  // 结束
  bool flag_end = false;

  // 当前位
  byte byte_val = 0x00;

  // 容错10次
  while (flag_end == false && timeout < _Frame_Len_HCHO * 10) {
    timeout++;

    // 串口可用
    if (_serial_hcho.available() > 0) {
      // 按位读数
      char int_val = (char)_serial_hcho.read();
      byte_val = (byte)int_val;

      // 帧数据组装
      buffer[count] = byte_val;

      // 起始符
      if (byte_val == _Frame_Start_HCHO) {
        // 更新 位序号
        count = 0;
        // 起始
        flag_start = true;
      }

      // 下一位
      count++;

      // 起始
      if (flag_start) {
        // 读取长度
        if (count >= _Frame_Len_HCHO) {
          // 重置
          count = 0;

          // 成功/完成
          buffer_done = true;
          flag_end = true;
        }
      }
    }
  }
}

bool Check() {
  uint8_t sum = 0;

  // 1~7位求和
  for (unsigned int i = 1; i < _Frame_Len_HCHO - 1; i++) {
    sum += buffer[i];
  }

  sum = (~sum) + 1;

  return sum == buffer[_Frame_Len_HCHO - 1];
}

void Read() {
  _serial_hcho.listen();
  // 串口可用
  if (_serial_hcho.isListening()) {
    unsigned int timeout = 0;
    // 容错10次
    while (buffer_done == false && timeout < 10)  //等待数据接收一帧完整
    {
      timeout++;
      // 计算
      Calc();
    }
    // 重置
    buffer_done = false;

    if (Check()) {
      // 更新数值
      data = buffer[2] * 256 + buffer[3];  // 数据定义:高八位+低八位

      // 容错:超过量程
      if (data < 0 || data > 10000) {
        data = lastData;
      }

      lastData = data;
    }
  }
  // _serial_hcho.end();
}

}

struct _HCHO {
  void init() {
    _serial_hcho.begin(9600);
    WZ_S::passiveMode();
  }

  void read() {
    WZ_S::requestRead();
    WZ_S::Read();
  }

  int getValue() {
    return WZ_S::data;
  }

  char unit[6] = "ug\/m";
} HCHO;

}

通讯方式是 UART

WZ Library 使用更高级的 Stream 库,这里改成自己实现读写数据帧的方式,命令和读写参数是参考 WZ Library 和 文档所说的。

文档关键内容:

【数据帧信息】

停止位:可以理解成下一个数据帧的起始符,代码中的算法是当作起始符查找的。 数据帧长度是 1+8 = 9 位。

#define _Frame_Len_HCHO 9

在这里插入图片描述

【被动模式】

这里选择使用它的被动模式,也是文档称呼的“问答式”。 启动默认是主动模式,需要发送下面的指令切换。

void passiveMode() {
	// ...
}

在这里插入图片描述

【请求读取】

被动模式下,需要发送下面的命令,要求它返回数据。

void Read() {
	// ...
}

在这里插入图片描述

【读取解读】

这里选择单位是 ug/m3,所以应该提取2、3位的数据(高位、低位)。

void Calc() {
	// ...
}

在这里插入图片描述 【校验】

根据文档算法进行校验。

void Check() {
	// ...
}

在这里插入图片描述

小白思路: 发送、接收命令都是以 N个字节的数据帧通讯的(这里是 9 个)。 读取数值的基本步骤是:

  • 通过串口 read() 方法,不停读取设备发送的字节数据。
  • 找到起始符,开始组装数据帧。
  • 组装完成后,进行校验。
  • 校验成功,根据数据帧的2、3位按文档公式计算数值。

未完待续。。。

多多支持其它文章 juejin.cn/user/155656…

又或者请我喝杯奶茶😍 vue3-zoom-drag