第一屏要回答“现在读到了什么,失败时显示什么”
串口温湿度计已经能告诉你一条样本是否有效,现在把它放到独立可看的OLED上。屏幕不能只显示两个数字:传感器断开后继续展示上一条数据,会让读者误以为房间仍是那个温湿度。本篇把有效性与数值一起送到画面,读失败立即改为横线和SENSOR ERROR。
这次作品包含正常页和诊断页,按钮在两页间切换。参考板仍是官方DevKitC-1 v1.1 N8R8,传感器沿用S01的Adafruit PID4885。没有本站实板的温湿度、断线或画面结果,所有检查都必须保存实际记录才能勾选。
- 做到这里,你应该看到
- 确认屏的控制器、128×64分辨率、PCB修订和0x3D地址;不能只说“0.96寸OLED”。
参考:[1] 固定DevKitC-1 v1.1文档:排针与供电[2] Adafruit PID326:分辨率、地址与2020/2024修订[5] Sensirion I2C SHT4x 1.1.2:高精度测量与错误返回[9] Adafruit SHT40:供电、上拉与0x44
固定两个模块,共用SDA/SCL但不混淆地址
OLED只支持本例指定的Adafruit PID326:2020年8月之后STEMMA QT、默认I2C且自动复位的修订;2024年只改丝印。RST不接,地址默认0x3D。旧SPI默认修订、SH1106与匿名128×32不能直接用这个驱动和构造参数。
两模块VIN都接3V3,地共用,GPIO8为SDA、GPIO9为SCL,100kHz。先分别扫描0x3D和0x44,再合接。模块自带上拉,合接后电阻会并联;不要无依据再并上拉。信号与电源必须在3.3V域,先用短线,距离和稳定性要在实际布置中复验。
常开按钮接GPIO5与GND,内部上拉,四脚按钮先断电辨认两组。需要制造传感器故障时,断电预装一个低压开关在SHT40的SDA支路,运行中只操作开关。不要切公共SDA后要求屏仍能报告新故障。
| DevKitC-1 v1.1 | OLED PID326 | SHT40 PID4885 |
|---|---|---|
| 3V3 | VIN | VIN |
| GND | GND | GND |
| GPIO8(J1-12) | Data/SDA | SDA |
| GPIO9(J1-15) | Clk/SCL | SCL |
| 留空 | RST(自动复位) | 3Vo(电源输出) |
参考:[1] 固定DevKitC-1 v1.1文档:排针与供电[2] Adafruit PID326:分辨率、地址与2020/2024修订[3] Adafruit:128×64 OLED的I2C跳线与自动复位[9] Adafruit SHT40:供电、上拉与0x44
1024字节缓冲和整帧发送,是两个不同成本
128×64单色像素至少需要128×64÷8=1024字节帧缓冲,此外还有对象、队列和任务栈。clearDisplay、print、drawLine主要改RAM,调用display之后才把缓冲发送到OLED。不是每写一个字符就自动显示。
固定SSD1306 2.5.17的display函数返回void,逐包I2C错误没有作为返回值传给调用方。begin返回true也不能证明像素真的亮起;本例先单独探测地址,再记录allocated与frames_submitted,但仍需要眼看/拍照确认画面。
100kHz时只算1024字节及ACK的9位时钟量级就约92毫秒,另有命令和分段开销。这个计算说明为什么不能把整帧发送称为立即返回,并不声称测到了这块屏的耗时。程序用last_tx_ms保存实际调用持续时间,留待实板填记录。
参考:[2] Adafruit PID326:分辨率、地址与2020/2024修订[4] SSD1306 2.5.17:begin分配缓冲与display同步发送实现
一个任务独占总线,主循环只处理输入和消息
所有Wire、SHT40采样和屏幕函数放在唯一I2C worker中,避免多个任务同时操作总线或帧缓冲。主loop每轮扫描按钮,30毫秒稳定下降沿产生页号,通过一个单槽队列发送给worker;队列只保留最新页状态,不保证每个中间页都形成一帧动画。
worker每2秒采样、每500毫秒提交画面。每次读入使用新的NAN变量,错误或非有限值就把valid清掉、数值改为NAN;5秒没有新的有效样本也不再当成当前值。OLED失去ACK后标记未初始化,ACK恢复时重新初始化,复用已有缓冲。
后台任务让屏幕等待不占住同一个loop,但它不是硬实时承诺。任务创建和队列分配都检查返回值;分配失败时输入仍继续,串口报告UI不可用。Serial输出与系统调度也会影响按钮,必须同时记录max_input_gap_ms与30次实际按键。
参考:[4] SSD1306 2.5.17:begin分配缓冲与display同步发送实现[5] Sensirion I2C SHT4x 1.1.2:高精度测量与错误返回[8] IDF5.5.5 / S3:任务、字节单位栈与队列
锁住五个库,再编译完整应用
依赖固定Sensirion Core0.7.3、Sensirion I2C SHT4x1.1.2、Adafruit BusIO1.17.4、GFX1.12.4与SSD1306 2.5.17。它沿用S01的Sensirion驱动,不混入名字相近的Adafruit SHT4x。按下方命令逐个安装固定版本,并用--no-deps阻止自动补出另一套未记录版本。
arduino-cli lib install --no-deps 'Sensirion Core@0.7.3'
arduino-cli lib install --no-deps 'Sensirion I2C SHT4x@1.1.2'
arduino-cli lib install --no-deps 'Adafruit BusIO@1.17.4'
arduino-cli lib install --no-deps 'Adafruit GFX Library@1.12.4'
arduino-cli lib install --no-deps 'Adafruit SSD1306@2.5.17'参考:[4] SSD1306 2.5.17:begin分配缓冲与display同步发送实现[5] Sensirion I2C SHT4x 1.1.2:高精度测量与错误返回[6] GFX 1.12.4:版本与BusIO依赖[7] BusIO 1.17.4:版本与依赖
完整程序:有效状态与绘图走同一条路径
下载包含此完整.ino、sketch.yaml、README、MIT许可与空白observations.csv。图形默认字体用ASCII状态词,避免在第一屏引入中文字体依赖;这不限制后续中文教程另选字库。sensor_error为驱动原码,-1/-2是本例总线失败/非有限结果的本地标记。
// SPDX-License-Identifier: MIT
// D01: DevKitC-1 v1.1 N8R8; Adafruit PID326 + PID4885, 3.3V I2C.
#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <SensirionI2cSht4x.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <freertos/task.h>
#include <math.h>
constexpr uint8_t SDA_PIN = 8, SCL_PIN = 9, BUTTON_PIN = 5;
constexpr uint8_t OLED_ADDRESS = 0x3D;
constexpr uint32_t SAMPLE_MS = 2000, FRAME_MS = 500, STALE_MS = 5000;
Adafruit_SSD1306 screen(128, 64, &Wire, -1, 100000UL, 100000UL);
SensirionI2cSht4x sensor;
struct Snapshot {
bool busReady, screenAck, screenReady, valid;
uint8_t page;
int16_t sensorError;
uint32_t samples, frames, lastGoodAt, frameDuration;
float temperature, humidity;
};
void drawFrame(const Snapshot &s, uint32_t now);
QueueHandle_t pageQueue = nullptr, snapshotQueue = nullptr;
bool uiTaskReady = false;
uint8_t probe(uint8_t address) {
Wire.beginTransmission(address);
return Wire.endTransmission(true);
}
void drawFrame(const Snapshot &s, uint32_t now) {
screen.clearDisplay();
screen.setTextColor(SSD1306_WHITE);
screen.setTextSize(1);
screen.setCursor(0, 0);
screen.print(s.page == 0 ? "D01 SHT40" : "D01 DIAGNOSTICS");
screen.drawLine(0, 10, 127, 10, SSD1306_WHITE);
screen.setCursor(0, 15);
if (!s.valid) {
screen.println(s.sensorError != 0 ? "SENSOR ERROR" : "STALE / NO SAMPLE");
screen.println("T: -- C");
screen.println("RH: -- %");
} else if (s.page == 0) {
screen.print("T: "); screen.print(s.temperature, 1); screen.println(" C");
screen.print("RH: "); screen.print(s.humidity, 1); screen.println(" %");
screen.print("age: "); screen.print(uint32_t(now - s.lastGoodAt)); screen.println(" ms");
} else {
screen.print("samples: "); screen.println(s.samples);
screen.print("frames: "); screen.println(s.frames);
screen.print("last TX: "); screen.print(s.frameDuration); screen.println(" ms");
}
screen.setCursor(0, 56);
screen.print("button GPIO5: page "); screen.print(s.page);
screen.display(); // synchronous I2C, isolated in this worker task
}
void i2cUiTask(void *) {
Snapshot s{};
s.temperature = s.humidity = NAN;
s.busReady = Wire.begin(SDA_PIN, SCL_PIN, 100000);
Wire.setTimeOut(20);
sensor.begin(Wire, SHT40_I2C_ADDR_44);
uint32_t sampledAt = millis() - SAMPLE_MS;
uint32_t framedAt = millis() - FRAME_MS, busTriedAt = millis();
for (;;) {
uint32_t now = millis();
uint8_t page;
if (xQueueReceive(pageQueue, &page, 0) == pdTRUE) s.page = page;
if (!s.busReady && uint32_t(now - busTriedAt) >= SAMPLE_MS) {
busTriedAt = now;
s.busReady = Wire.begin(SDA_PIN, SCL_PIN, 100000);
Wire.setTimeOut(20);
}
if (uint32_t(now - sampledAt) >= SAMPLE_MS) {
sampledAt = now;
++s.samples;
float temperature = NAN, humidityRaw = NAN;
s.sensorError = s.busReady ? sensor.measureHighPrecision(temperature, humidityRaw) : -1;
s.valid = s.sensorError == 0 && isfinite(temperature) && isfinite(humidityRaw);
if (s.sensorError == 0 && !s.valid) s.sensorError = -2;
if (s.valid) {
s.temperature = temperature;
s.humidity = fminf(100.0f, fmaxf(0.0f, humidityRaw));
s.lastGoodAt = millis();
} else {
s.temperature = s.humidity = NAN; // no stale number rendered as current
}
}
now = millis();
if (s.valid && uint32_t(now - s.lastGoodAt) > STALE_MS) s.valid = false;
if (uint32_t(now - framedAt) >= FRAME_MS) {
framedAt = now;
s.screenAck = s.busReady && probe(OLED_ADDRESS) == 0;
if (!s.screenAck) s.screenReady = false;
if (s.screenAck && !s.screenReady) {
// PID326 auto-reset revision: no reset pin. Wire is already configured.
s.screenReady = screen.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS, false, false);
}
if (s.screenAck && s.screenReady) {
const uint32_t startedAt = millis();
drawFrame(s, startedAt);
s.frameDuration = millis() - startedAt;
++s.frames; // submitted, not optically verified or I2C payload-ack verified
}
xQueueOverwrite(snapshotQueue, &s);
}
vTaskDelay(pdMS_TO_TICKS(5));
}
}
void setup() {
Serial.begin(115200);
pinMode(BUTTON_PIN, INPUT_PULLUP);
pageQueue = xQueueCreate(1, sizeof(uint8_t));
snapshotQueue = xQueueCreate(1, sizeof(Snapshot));
if (pageQueue && snapshotQueue)
uiTaskReady = xTaskCreate(i2cUiTask, "d01-i2c", 8192, nullptr, 1, nullptr) == pdPASS;
Serial.printf("D01 core=3.3.12 task=%u OLED=128x64@0x3D SDA=8 SCL=9 100kHz\n", uiTaskReady);
}
void loop() {
static bool initialized = false, candidate, stable;
static uint8_t page = 0;
static uint32_t changedAt = 0, presses = 0, reportedAt = 0, lastLoopAt = 0, maxGap = 0;
const uint32_t now = millis();
const bool raw = digitalRead(BUTTON_PIN);
if (!initialized) {
initialized = true; candidate = stable = raw; changedAt = lastLoopAt = now;
}
const uint32_t gap = now - lastLoopAt;
lastLoopAt = now;
if (gap > maxGap) maxGap = gap;
if (raw != candidate) { candidate = raw; changedAt = now; }
if (candidate != stable && uint32_t(now - changedAt) >= 30) {
stable = candidate;
if (stable == LOW) {
page ^= 1;
if (uiTaskReady) xQueueOverwrite(pageQueue, &page);
Serial.printf("BUTTON press=%lu accepted=%lu page=%u task=%u\n",
(unsigned long)++presses, (unsigned long)now, page, uiTaskReady);
}
}
if (uint32_t(now - reportedAt) >= 2000) {
reportedAt = now;
Snapshot s{};
if (uiTaskReady && xQueuePeek(snapshotQueue, &s, 0) == pdTRUE) {
Serial.printf("UI bus=%u oled_ack=%u allocated=%u valid=%u sensor_error=%d page=%u samples=%lu frames_submitted=%lu last_tx_ms=%lu max_input_gap_ms=%lu\n",
s.busReady, s.screenAck, s.screenReady, s.valid, s.sensorError, s.page,
(unsigned long)s.samples, (unsigned long)s.frames, (unsigned long)s.frameDuration, (unsigned long)maxGap);
} else Serial.printf("UI unavailable task=%u; input still runs\n", uiTaskReady);
maxGap = 0;
}
delay(1);
}
参考:[4] SSD1306 2.5.17:begin分配缓冲与display同步发送实现[5] Sensirion I2C SHT4x 1.1.2:高精度测量与错误返回[8] IDF5.5.5 / S3:任务、字节单位栈与队列
从编译到三组观察,逐项留证
按F05固定CLI/core,在仓库根目录执行下面命令,PORT替换为实际桥接串口。Flash8MB/QIO、OPI PSRAM、8M with spiffs,CDC关闭。profile没有默认启用;显式--profile可另走隔离重建,首次会另装工具链。
- 正常:记录至少10次传感器采样与屏幕照片,确认单位、温湿度与样本年龄;对照串口valid和OLED ACK,不用ACK替代读数/画面验证。
- 传感器失联:断开预装的SHT40 SDA支路开关,下一轮失败后画面必须显示横线和ERROR;闭合后有效读取恢复数值,记录时间与真实错误码。
- 输入:在正常与失联状态下各按30次并做长按,编号应一一对应,画面最终页号与最后一次按键一致;记录max_input_gap和last_tx。
- 错屏地址:实物保持0x3D,把程序临时改0x3C,确认oled_ack=0但按钮日志继续;恢复0x3D重新编译,留下恢复画面。
arduino-cli compile --fqbn 'esp32:esp32:esp32s3:FlashSize=8M,PSRAM=opi,FlashMode=qio,PartitionScheme=default_8MB,CDCOnBoot=default,USBMode=hwcdc' examples/esp32/esp32-oled-first-display
arduino-cli upload --fqbn 'esp32:esp32:esp32s3:FlashSize=8M,PSRAM=opi,FlashMode=qio,PartitionScheme=default_8MB,CDCOnBoot=default,USBMode=hwcdc' -p PORT --upload-property upload.speed=115200 examples/esp32/esp32-oled-first-display
arduino-cli monitor -p PORT --config baudrate=115200- 做到这里,你应该看到
- 源码构建、数据有效性、按钮事件和物理显示分别有证据;空CSV不计为实板验收。
屏空白与数据错误,沿不同证据恢复
屏空白时先核对控制器/分辨率/修订,再查供电、0x3D、SDA/SCL及task/allocated/ACK。allocated=0表示应先排内存问题;只有旧画面时frames_submitted也不能证明像素收到新帧,需检查总线和屏。
传感器ERROR先单独运行S01排供电与CRC读数,再合接总线。按键无日志先用P02排四脚按钮和电平,再看任务创建与loop间隔。整条SDA被拉低时屏也无法更新,靠串口保留故障,不让“无法显示错误”又被写成数据有效。
恢复默认地址、固定依赖与接线,断电重启,保留失败和恢复两段记录。不要把重装库、擦Flash或更换芯片作为第一步。下一篇让设备自己连WiFi,继续保留这种输入与后台等待分开的观察方法。
参考:[1] 固定DevKitC-1 v1.1文档:排针与供电[2] Adafruit PID326:分辨率、地址与2020/2024修订[4] SSD1306 2.5.17:begin分配缓冲与display同步发送实现[5] Sensirion I2C SHT4x 1.1.2:高精度测量与错误返回
资料来源与版本说明
固定厂商模块与库源码核对;allocated/ACK/提交帧与物理显示分开验收。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。