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

相关推荐
冰羽IOX13 小时前
CH340G上传程序到ESP8266-01(S)模块
arduino·esp8266·ch340g·usb to ttl
小仇学长15 小时前
嵌入式八股文面试题(一)C语言部分
c语言·c++·面试·嵌入式·八股文
沐欣工作室_lvyiyi18 小时前
基于单片机的智能家居设计(论文+源码)
stm32·单片机·嵌入式硬件·物联网·智能家居·指纹识别
网易独家音乐人Mike Zhou19 小时前
【STM32】HAL库USB虚拟U盘MSC配置及采用自带的Flash作为文件系统
stm32·单片机·mcu·性能优化·嵌入式·iot·flash
智驾1 天前
Node.js与嵌入式开发:打破界限的创新结合
node.js·嵌入式
千汇数据的老司机1 天前
物联网领域的MQTT协议,优势和应用场景
物联网·mqtt·物联网通信协议
7yewh2 天前
工业级 激光测距 飞行时间法TOF 相位法 多频调制的本质!
arm开发·驱动开发·mcu·物联网·射频工程·材料工程
神一样的老师2 天前
跨组织环境下 MQTT 桥接架构的评估
物联网
云山工作室2 天前
基于物联网的火灾报警器设计与实现(论文+源码)
物联网·毕业设计·毕设
Ching·2 天前
物联网 STM32【源代码形式-ESP8266透传】连接OneNet IOT从云产品开发到底层MQTT实现,APP控制 【保姆级零基础搭建】
stm32·嵌入式硬件·物联网