最终章
这一章把剩下的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