Arduino 小白的 DIY 空气质量检测仪(5)- OLED显示模块、按钮模块

最终章

这一章把剩下的OLED显示模块、按钮模块分享一下,当前这个离线无存储的版本,基本告一段落。

如果后续能进化成🈶存储、联网版本,就再开一个小系列分享一下。

逐个分析

display.h

cpp 复制代码
#include <Arduino.h>
#include <Wire.h>

// OLED 0.96 库
#include <ssd1306.h>

// OLED 0.96
// 接口:GND->GND、VDD->VCC(5V)、SCK->SCK/A5、SDA->SDA/A4
// 协议:I2C
// 地址:0x3C

namespace SSD_1306 {
unsigned int width = 128;
unsigned int lineHeight = 8;
unsigned int charMax = 24;
}

namespace Display {

struct _OLED {
  void init() {
    // 初始化OLED
    Wire.begin();
    ssd1306_128x64_i2c_init();
    ssd1306_setFixedFont(ssd1306xled_font6x8);
    ssd1306_clearScreen();
  }

  void printRaw(unsigned int left, unsigned int top, char* str, unsigned int style = STYLE_NORMAL) {
    ssd1306_printFixed(left, top, str, style);
  }

  void printNRaw(unsigned int left, unsigned int top, char* str, unsigned int style = STYLE_NORMAL) {
    ssd1306_printFixedN(left, top, str, style, 1);
  }

  void print(char* str, unsigned int left, unsigned int top) {
    printRaw(left, SSD_1306::lineHeight * top, str);
  }

  void printRight(char* str, int top) {
    uint16_t left = getLeft(str);
    print(str, left, top);
  }

  void drawBuffer(unsigned int left, unsigned int top, uint8_t* buffer) {
    ssd1306_drawBuffer(left, top, 3, 8, buffer);
  }

  void drawLine(unsigned int x1, unsigned int y1, unsigned int x2, unsigned int y2) {
    ssd1306_drawLine(x1, y1, x2, y2);
  }

  void clearBlock(unsigned int left, unsigned int top, unsigned int w, unsigned int h) {
    ssd1306_clearBlock(left, top, w, h);
  }

  void clearBlockCenter(unsigned int left, unsigned int right, unsigned int top) {
    clearBlock(left, top, SSD_1306::width - left - right, SSD_1306::lineHeight);
  }

  unsigned int getTextSize(char* str) {
    return ssd1306_getTextSize(str, 0);
  }

  unsigned int getLeft(char* str) {
    int w = getTextSize(str);
    return SSD_1306::width - w;
  }

  void clearScreen() {
    ssd1306_clearScreen();
  }
} OLED;

}

通讯方式是 I2C

这个 OLED 模块,分辨率只有 128x64,一行文字占 8 个像素的高度,一行大概可以容纳 24 个字母。

支持这个模块的库很多,有的依赖了别的库、有的带开屏广告、有的。。。最后我选了 ssd1306.h 感觉比较顺手,不过它的 API 的命名比较简单粗暴。

初始化、字体设置、清屏 很好理解,而绘制文字 ssd1306_printFixed 和 ssd1306_printFixedN 的区别,也只是 ssd1306_printFixedN 多一个放大倍数的参数输入,1 就是放大一倍。设计理念是基于 8 像素这个行高的。

比较重要的是,绘制有个特点,内容更新是需要考虑"清空"的,而这个"清空"多数时候是局部的,例如:

假如,第一秒数值是 1234,显示如下:

bash 复制代码
1234

第二秒数值是 234,如果不进行"清空",显示将如下:

bash 复制代码
2344

改变的字母"234"区域更新了,但是原来未改变的"4"依然显示,因此,是需要清空"4"这个区域的,才能变成:

bash 复制代码
234

此库提供一个相应的方法:

cpp 复制代码
void ssd1306_clearBlock(uint8_t x, uint8_t y, uint8_t w, uint8_t h){}

x、y 是开始位置,w、h 是处理范围。

问题来了,我如何知道从哪个像素开始"清空"呢?那将需要另外一个 API:

cpp 复制代码
lcduint_t ssd1306_getTextSize(const char *text, lcduint_t *height){}

它可以通过字符串的内容,计算字符串所需占用的宽高,这里返回值就是宽,输入的第二参数是高(本项目只需要宽,高都以默认 8 像素计算)。

在本项目中,一行将显示 2 个传感器数值,也就是说需要左右各自对齐贴边:

这里"清空"的区域就要考虑左右两个数值的字符串宽度了,就是说,每次更新数值的时候,需要"清空"的区域大概是:

举个例子,本项目中,最后一行显示,最外层的方法是:

cpp 复制代码
// arduino-air-monitor.ino

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

  if (display) {
    // ...略
    Display::OLED.clearBlockCenter(printCO2(Module::CO2.getValue(), 7, false), printHum(Module::Humidity.getValue(), 7, true), 7);
  }
}

CO2 的显示方法 printCO2:

cpp 复制代码
unsigned int printCO2(unsigned int value, unsigned int row, bool isRight) {
  char str[SSD_1306::charMax] = "";
  strcat(str, "CO2:");

  char numStr[SSD_1306::charMax] = "";
  itoa(value, numStr, 10);
  strcat(str, numStr);
  strcat(str, "ppm");

  if (isRight) {
    Display::OLED.printRight(str, row);
  } else {
    Display::OLED.print(str, 0, row);
  }

  Serial.println(str);

  return Display::OLED.getTextSize(str);
}

湿度的显示方法 printHum:

cpp 复制代码
unsigned int printHum(float value, unsigned int row, bool isRight) {
  char str[SSD_1306::charMax] = "";
  strcat(str, "Hum:");

  char numStr[SSD_1306::charMax] = "";
  dtostrf(value, 1, 1, numStr);
  strcat(str, numStr);
  strcat(str, "%");

  if (isRight) {
    Display::OLED.printRight(str, row);
  } else {
    Display::OLED.print(str, 0, row);
  }

  Serial.println(str);

  return Display::OLED.getTextSize(str);
}

这里设计思路,是每个数值的显示方法,最后都会返回字符串占用的宽度,用于计算中间"清空"区域。

cpp 复制代码
  void clearBlockCenter(unsigned int left, unsigned int right, unsigned int top) {
    clearBlock(left, top, SSD_1306::width - left - right, SSD_1306::lineHeight);
  }
  void clearBlock(unsigned int left, unsigned int top, unsigned int w, unsigned int h) {
    ssd1306_clearBlock(left, top, w, h);
  }

关于靠右显示,也是利用 ssd1306_getTextSize,用 128 显示宽度减去字符串的宽度,就是靠右显示的起始位置了。

最后,说说 2 个要自己实现的显示字符:"立方"和"度",是不支持此类特殊字符的:

这个时候,就需要利用此库绘制位图:

cpp 复制代码
unsigned int printPower3(unsigned int left, unsigned int top) {
  // 绘制立方"³"符号
  // 位图方向:从左往右、从下往上
  // 0 0 0
  // 0 0 0
  // 0 0 0
  // 1 1 1
  // 0 0 1
  // 1 1 1
  // 0 0 1
  // 1 1 1
  // 第一列 00010101 -> 0x15
  // 第二列 00010101 -> 0x15
  // 第三列 00011111 -> 0x1F
  // js转换示例:parseInt('00011111',2) -> (31).toString(16) -> 1f
  uint8_t buffer[3] = { 0x15, 0x15, 0x1F };
  Display::OLED.drawBuffer(left, top, buffer);

  return 4;
}

unsigned int printDeg(unsigned int left, unsigned int top) {
  // 绘制"°"符号
  // 位图方向:从左往右、从下往上
  // 0 0 0
  // 0 0 0
  // 0 0 0
  // 0 0 0
  // 0 0 0
  // 0 1 0
  // 1 0 1
  // 0 1 0
  uint8_t buffer[3] = { 0x02, 0x05, 0x02 };
  Display::OLED.drawBuffer(left, top, buffer);

  return 4;
}

请看注释,实际上就是:在格子中填0/1,1 就是代表像素的亮。

计算的逻辑,可以参考 printPower3 的注释:

上面说的"位图方向:从左往右、从下往上",不是很严谨,其实这里只是使用该 API 得出的特点(我也不是很明白为何颠倒过来了,等哪位大神可以解答一下最好),位图按维基百科应该下面那样才符合直觉:

最后,我使用下来,发现使用 String 类型会出现各种无法解释的异常乱码,个人建议这里使用 C 风格的字符串。

上边基本上就是我遇到的一些比较值得注意的坑吧。

buttons.h

cpp 复制代码
// buttons.h

#include <Arduino.h>

#define _Pin_Btn_1 12

namespace Buttons {

enum Status {
  Ready = 0,
  Down = 1,
  Up = 2
};

struct _Btn_1 {
  Status status = Ready;

  void init() {
    pinMode(_Pin_Btn_1, INPUT_PULLUP);
  }

  void loop() {
    if (status == Ready && digitalRead(_Pin_Btn_1) == LOW) {
      status = Down;
    }

    if (status != Ready && digitalRead(_Pin_Btn_1) == HIGH) {
      status = Up;
    }
  }

  bool getValue() {
    bool result = status == Up;

    if (result) {
      status = Ready;
    }
    return result;
  }
} Btn_1;

}

按网页开发的直觉,按钮不就是用 digitalRead 得到该按钮引脚如果低电平,就知道按了,就 if 一下去干一件事情就可以了吗?

实际上,在这里,"点击"是需要自己处理按钮的状态的,我抽象成 Ready 等待(HIGH)、Down 按下中(LOW)、Up 释放中(HIGH),可以看出来 Ready 和 Up 都是 HIGH,这应该如何区分?

该模块里面,我也定义了一个 loop 方法,就意味着要放在 程序入口 的 loop 方法中。

cpp 复制代码
// arduino-air-monitor.ino

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

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

  if (clicked) {
    oledDisplay = !oledDisplay;
  }

  // oledDisplay 就是通过按钮切换的一个 true/false 状态

  // ...略
}

流程图表达:

可以看出,只要按下不动,就会变成且持续是 Down 状态,放手释放的时候,就会变成且持续是 Up 状态。

那什么时候才会变回 Ready 进行下一次"点击"识别?是读取是否"点击"了的时候:

cpp 复制代码
// buttons.h

  bool getValue() {
    bool result = status == Up;

    if (result) {
      status = Ready;
    }
    return result;
  }
cpp 复制代码
// arduino-air-monitor.ino

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

这样子,就可以在持续不断的 loop 中,识别出"点击"的操作,毕竟"点击"是由"按下"和"释放"两个动作构成的,类似网页中 click 约等于 mousedown + mouseup。

好羡慕 PCB 设计、3D 打印 的大神们,如果拥有这两块的能力,做成真正的成品该多好呀~~~~

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

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

项目完整代码仓库在这 arduino-air-monitor

相关推荐
Pocker_Spades_A1 小时前
通义灵码在跨领域应用拓展之物联网篇
python·物联网·阿里云·pycharm·云计算
DS小龙哥6 小时前
基于物联网的冻保鲜运输智能控制系统
物联网
Xi_er_8 小时前
密钥管理系统在数据安全解决方案中的重要性
运维·数据仓库·物联网·web安全·前端框架·智慧城市·安全架构
武汉海翎光电16 小时前
电化学气体传感器在物联网中的精彩表现
物联网
硬件技术我知道1 天前
产品 防尘防水IP等级 划分与实验方法
网络·人工智能·嵌入式硬件·物联网·计算机视觉·硬件工程·智慧城市
电子小子洋酱1 天前
ESP32移植Openharmony外设篇(7)土壤湿度传感器YL-69
单片机·物联网·华为·harmonyos·鸿蒙
每天一杯美式2 天前
IoT-多功能裂缝计
网络·人工智能·物联网
DS小龙哥2 天前
基于物联网疫苗冷链物流监测系统设计
物联网·struts·servlet
千千道2 天前
linux的线程同步(条件变量和锁)
linux·arm开发·驱动开发·物联网·arm