蓝板 SCD41 还是紫板 SCD40
本店这两块板从外观就能分清,不用烧程序:
| 板子颜色 | 芯片 | SKU | 板上丝印 | CO2 规格量程 |
|---|---|---|---|---|
| 蓝板 | SCD41 | 0456 | SCD41 CO2 IIC | 400 – 5000 ppm |
| 紫板 | SCD40 | 0455 | SCD40 CO2 IIC | 400 – 2000 ppm |
丝印在板子背面,字是反着看的。两块板的尺寸、引脚、板载元件和代码完全一样, 可以直接互换,只是量程规格和单次测量能力不同——怎么选见第 2 节。
实物图左上是背面(丝印 SCD41/SCD40 CO2 IIC,引脚字是反的),
右下是正面(引脚从左到右 GND / VCC / SCL / SDA)。
排针是单独附带的,需要自己焊。两块板都是 21.6 × 13.4 mm、2 克。
对着蓝板模块哈了一口气,实测 CO2 读数从 1114 ppm 一路涨到 1931 ppm
(涨幅约 820 ppm),确认这颗传感器不是返回固定值,是在真实检测气体浓度变化。
测试时读到的芯片序列号:921215073B57。
这次测试用的是自制 UI 固件(另有专门项目,不是下方第 6 节的下载代码), 上面短视频只做快速确认现象;完整教程发布后再补 B 站链接。第 6 节的官方例程和精简版代码本身尚未单独跑过, 请见各自的验证说明。
方法 2:看芯片壳上的激光刻字
新批号的传感器,型号直接激光刻在顶部金属壳上,旁边还有一个二维码(批次信息)。 拿到手先翻过来看一眼,比烧程序去试快得多。
SCD41 或 SCD40 字样,右下角是二维码。图为示意,实物刻字位置以手上模块为准。老批号可能没有刻字,或者刻字被模块的排针、外壳挡住看不清。 这种情况用下面两种软件方法识别,不用拆板子。
方法 3 / 4:用程序读出来
刻字看不清时,用 I2C 命令识别。建议两个都跑一遍:
| 方法 | 命令 | 判据 | 可靠性 |
|---|---|---|---|
| 3. 单次测量命令 最关键的软件判据 |
0x219D measure_single_shot |
命令被接受并能读出有效数据 → SCD41 命令无应答或读不出数据 → SCD40 |
高。SCD40 本身不支持这条命令 |
| 4. 变体寄存器 | 0x202F get_sensor_variant |
返回值高 4 位:0 → SCD40,1 → SCD41 | 仅较新固件支持,老固件读不到(待确认) |
下面的代码包里有一份 SCD4x_识别是40还是41.ino,
烧进 Arduino 或 ESP32、打开串口监视器(115200)就会自动跑完上面两个方法并直接给出结论。
见 第 6 节代码下载。
SCD40 与 SCD41 到底差在哪
两颗都是 Sensirion SCD4x 系列的光声式 NDIR CO2 传感器,同一个封装、同一套基础命令。真正的差别集中在量程规格和单次测量能力两点上。本店紫板(0455)用 SCD40,蓝板(0456)用 SCD41。
| 项目 | SCD40 | SCD41 |
|---|---|---|
| CO2 规格量程 | 400 – 2000 ppm | 400 – 5000 ppm |
| 单次测量 0x219D | 不支持 | 支持 |
| 低功耗间歇测量 | 受限 | 可用单次测量做低功耗 |
| I2C 地址 | 0x62 | 0x62 |
| 引脚 / 封装 | 完全相同,可直接互换 | |
| 周期测量周期 | 5 秒一组 | |
| 基础命令集 | 相同(启动 / 读数 / 停止 / 序列号 / 校准) | |
室内空气质量、新风联动、CO2 报警这类应用,日常浓度在 400–2000 ppm 之间,
SCD40 完全够用。
密闭空间、车内、发酵、温室、人多的会议室可能冲到 2000 ppm 以上,
或者需要低功耗电池供电,那就要 SCD41。
上表的量程按 Sensirion 官方规格整理。具体精度指标(如 ±(40 ppm + 5%)) 随批次和固件版本可能不同,需要写进方案文档时请查对应型号的最新数据手册, 不要直接引用本页数字。本页没有列出的参数一律待确认,不做推测。
引脚定义
两块板引脚完全相同,正面丝印从左到右依次是:
- GND地。所有主控接线都必须共地,这是最常见的疏漏。
- VCC电源正。芯片供电范围 2.4 – 5.5 V,3.3V 和 5V 都行;接和主控逻辑相同的那一路(UNO 接 5V,ESP32/STM32/树莓派接 3.3V)。详见第 4 节。
- SCLI2C 时钟线。
- SDAI2C 数据线。
- NC空脚,不接。
板子背面丝印顺序是 SDA / SCL / VCC / GND,和正面相反——那是从背面看的顺序。
背面同时印着芯片型号(SCD41 CO2 IIC 或 SCD40 CO2 IIC),可以用来区分两块板。
接线以正面丝印为准,或者直接对着焊盘旁边的字接。
两块板的板载元件相同,都带了:
- 10K 上拉电阻 × 2 —— 接在 SDA 和 SCL 上,上拉到 VCC。 所以不用你另外加上拉电阻,直接四根线接上去就行。
- 100nF 去耦电容 × 1 —— 给传感器供电滤波用。
板上没有 LDO 稳压和电平转换芯片,但并不需要:SCD4x 本身 2.4 – 5.5 V 都能供电, 而上拉电阻是接到 VCC 的——这正是「VCC 要接和主控逻辑相同那一路」的原因,详见第 4 节。
供电与电平:最容易踩的两个坑
SCD4x 是光声式传感器,每次测量要驱动光源,瞬间峰值电流可达约 205 mA。 这个数字比大多数 I2C 传感器高一个数量级,是很多「读数乱跳」「模块一会儿认得到一会儿认不到」的真正原因。
这也意味着:不是随便找个脚给电就行,要看那个脚能出多少电流。
按 Sensirion 数据手册,SCD4x 的供电范围是 2.4 – 5.5 V, 3.3V 和 5V 都在范围内,接哪个都能工作。
SCL / SDA 是开漏输出,I2C 的电平由上拉电阻接到哪一路电源决定。 所以有一条很省事的原则:
模块 VCC 接到和主控逻辑相同的那一路电源,I2C 电平自然就一致,不需要电平转换。
| 主控 | VCC 接 | 为什么 |
|---|---|---|
| Arduino UNO / Nano 5V 逻辑 |
5V | 电平和 UNO 一致,直接接 A4 / A5 即可; 而且 5V 脚电流够,见下方说明 |
| ESP32 / STM32 / 树莓派 3.3V 逻辑 |
3.3V | 同理,电平一致,直接接 |
ESP32 板上的输出本来就是 3.3V,没有 5V 可选,直接接 3V3。
Arduino UNO 上 5V 和 3.3V 两个输出脚都有,但给 SCD4x 供电要用 5V 那个:
| UNO 的脚 | 电流能力 | 结论 |
|---|---|---|
| 3.3V | 约 50 mA | 不要用。205 mA 峰值一来就掉压,表现为扫不到地址、读数乱跳 |
| 5V | USB 供电时整板约 500 mA | 用这个。电流够,电平也和 UNO 一致 |
3.3V 系统(ESP32 / STM32 / 树莓派)同样要注意 3.3V 那一路能不能扛住 205 mA 峰值。 如果同时还带屏幕、WiFi、电机,建议给 3.3V 加大电容,或者用外部电源单独供并共地。
四种主控接线图
下面四张图的连线一一对应、不交叉,照着接即可。以正面丝印为准,蓝板紫板接法完全一样。
代码下载
Sensirion 官方给四个平台都提供了现成的驱动和示例,由厂家维护、跟着固件更新, 比任何第三方教程都可靠,建议优先用这些。下面先列官方仓库,再放本站整理的精简版。
一、官方驱动与示例(推荐)
| 平台 | 官方仓库 / 包 | 怎么用 |
|---|---|---|
| Arduino / ESP32 | Sensirion/arduino-i2c-scd4x | IDE 库管理器搜 Sensirion I2C SCD4x 安装,示例在文件 → 示例 → Sensirion I2C SCD4x → exampleUsage |
| 树莓派(C) | Sensirion/raspberry-pi-i2c-scd4x | 下载解压后:cd example-usage && make && ./scd4x_i2c_example_usage |
| 树莓派(Python) | Sensirion/python-i2c-scd4xpip install sensirion-i2c-scd4x |
默认走 /dev/i2c-1,仓库 examples/ 里有完整示例 |
| STM32 / 其它单片机 | Sensirion/embedded-i2c-scd4x | 平台无关的 C 驱动,只要实现 sensirion_i2c_hal 里几个函数(用 HAL_I2C_Master_Transmit / Receive 对接即可) |
当前官方库的头文件是 SensirionI2cScd4x.h(小写的 c),
类名是 SensirionI2cScd4x。
网上不少老教程写的是 SensirionI2CScd4x.h(大写 C)和旧的三段式序列号接口,
那是旧版本的写法,照抄会编译不过。以库自带示例为准。
二、本站整理的打包下载
Arduino 那一份是官方示例原样收录(保留 Sensirion BSD-3 版权声明), 只在开头加了中文接线和供电注释。 其余几份是本站写的精简版:不依赖第三方库、直接发 I2C 命令, 方便你一步步看哪里不通——这几份尚未实机验证,请以实测为准, 正式项目建议用上面的官方驱动。
三、本期视频演示用的完整UI固件源码
上面几份是精简的I2C命令示例,这一份是本站B站视频里实际演示的完整固件—— ESP32-2432S028R(2.8寸黄屏CYD)接SCD41,四页触摸中文界面(实时读数/CO2趋势图/传感器信息/ 温度偏移说明),含型号判定(单次测量命令)、CRC-8校验、原始值换算这几段视频里讲过的代码。 直接在下面框里滚动查看,不用下载;需要烧到板子上验证的话,用乐鑫官方 ESP Launchpad 一键烧录;本站也留了个自制版当备选,功能没有官方全。
/*
ESP32-2432S028R(2.8寸黄屏CYD) + SCD41 CO2传感器 触摸UI演示(中文版)
接线:模块 VCC->3.3V,GND->GND,SDA->IO27,SCL->IO22
(跟 0911 ATECC608A / BMM150 / DPS310 项目同一路 CN1 I2C排针)
⚠ 别照抄资料网页上那份通用ESP32例程的 SDA=GPIO21——CYD板上21是屏幕背光
(TFT_BL=21),接SDA会跟背光打架。
屏幕/触摸引脚沿用 02_中文触摸效率时钟、0911 ATECC608A 已验证过的配置。
中文字体用 U8g2_for_TFT_eSPI + U8g2自带的文泉驿点阵字体(wqy16/wqy12),
不用额外生成字库文件,字库数据直接编译进固件。
交互:
- 单击:翻页(实时读数 -> CO2趋势 -> 芯片信息 -> 温度说明 -> 回实时读数)
- 长按2秒:重新扫描传感器(软复位)
(这里用2秒不用0911那边的5秒:0911的5秒是为了防止手滑触发Lock这种不可逆操作,
SCD41这个UI里没有任何不可逆动作,用2秒手感更顺。)
⚠ 本演示刻意不包含以下两类命令,要加得先单独评估:
· persist_settings (0x3615) —— 把温度偏移/海拔/ASC开关写进芯片EEPROM。
官方datasheet只保证EEPROM约2000次擦写寿命,演示程序反复调用会折寿。
(这些设置默认只存在RAM,掉电回默认值,不发这条不会写EEPROM。)
· perform_forced_recalibration (0x362F) —— 强制重校准,改写校准基准,
影响之后所有读数,且要在已知CO2浓度的环境里做才有意义。
*/
#include <SPI.h>
#include <TFT_eSPI.h>
#include <U8g2_for_TFT_eSPI.h>
#include <Wire.h>
#define I2C_SDA 27
#define I2C_SCL 22
#define TOUCH_CS 33
#define TOUCH_IRQ 36
#define HOLD_MS 2000UL
#define SCREEN_W 320
#define SCREEN_H 240
#define COL_BG TFT_BLACK
#define COL_TEXT TFT_WHITE
#define COL_DIM TFT_DARKGREY
#define COL_OK TFT_GREEN
#define COL_WARN TFT_RED
#define COL_ACCENT TFT_YELLOW
#define COL_ORANGE 0xFD20
#define COL_CYAN TFT_CYAN
#define COL_BAR_BG 0x2104 // 深灰
TFT_eSPI tft = TFT_eSPI();
U8g2_for_TFT_eSPI u8f;
// ---------------- SCD4x 命令与底层读写 ----------------
static const uint8_t SCD4X_ADDR = 0x62;
static const uint16_t CMD_STOP_PERIODIC = 0x3F86;
static const uint16_t CMD_GET_SERIAL = 0x3682;
static const uint16_t CMD_GET_VARIANT = 0x202F;
static const uint16_t CMD_SINGLE_SHOT = 0x219D; // 仅SCD41支持
static const uint16_t CMD_START_PERIODIC = 0x21B1;
static const uint16_t CMD_READ_MEASURE = 0xEC05;
static const uint16_t CMD_DATA_READY = 0xE4B8;
/* Sensirion CRC-8:多项式0x31,初始值0xFF。每2字节数据跟1字节CRC。 */
static uint8_t sensirionCrc(const uint8_t *data, uint8_t len) {
uint8_t crc = 0xFF;
for (uint8_t i = 0; i < len; i++) {
crc ^= data[i];
for (uint8_t b = 0; b < 8; b++) {
crc = (crc & 0x80) ? (uint8_t)((crc << 1) ^ 0x31) : (uint8_t)(crc << 1);
}
}
return crc;
}
static uint8_t sendCmd(uint16_t cmd) {
Wire.beginTransmission(SCD4X_ADDR);
Wire.write((uint8_t)(cmd >> 8));
Wire.write((uint8_t)(cmd & 0xFF));
return Wire.endTransmission();
}
static bool readWords(uint16_t *out, uint8_t count) {
uint8_t need = count * 3;
if (Wire.requestFrom((int)SCD4X_ADDR, (int)need) != need) return false;
for (uint8_t i = 0; i < count; i++) {
uint8_t b[2];
b[0] = Wire.read();
b[1] = Wire.read();
uint8_t c = Wire.read();
if (sensirionCrc(b, 2) != c) return false;
out[i] = ((uint16_t)b[0] << 8) | b[1];
}
return true;
}
// ---------------- 应用状态 ----------------
enum AppState {
STATE_DETECT,
STATE_ERROR,
STATE_LIVE, // 实时读数(主页)
STATE_TREND, // CO2趋势条形图
STATE_INFO, // 芯片信息
STATE_ABOUT, // 温度偏移/自发热说明
};
static AppState state = STATE_DETECT;
static String serialNumberStr = "-";
static String variantStr = "SCD4x";
static bool singleShotOk = false; // measure_single_shot是否被接受(SCD41判据)
static uint16_t co2Ppm = 0;
static float tempC = 0.0f;
static float humidity = 0.0f;
static bool haveData = false;
// CO2历史,趋势页用。5秒一个点,60个点约5分钟。
#define HIST_N 60
static uint16_t hist[HIST_N];
static uint8_t histCount = 0;
static uint8_t histHead = 0;
static void histPush(uint16_t v) {
hist[histHead] = v;
histHead = (histHead + 1) % HIST_N;
if (histCount < HIST_N) histCount++;
}
/* 取第i个历史点,i=0是最旧的 */
static uint16_t histAt(uint8_t i) {
uint8_t start = (uint8_t)((histHead + HIST_N - histCount) % HIST_N);
return hist[(start + i) % HIST_N];
}
// ---------------- 空气质量分级 ----------------
// 室内CO2常用参考档位:户外空气约400ppm,室内长期高于1000ppm就该通风了。
static const char *qualityText(uint16_t ppm) {
if (ppm < 600) return "优 · 空气清新";
if (ppm < 800) return "良 · 正常室内";
if (ppm < 1000) return "一般 · 建议通风";
if (ppm < 1500) return "偏差 · 该开窗了";
if (ppm < 2000) return "差 · 明显闷";
return "很差 · 尽快通风";
}
static uint16_t qualityColor(uint16_t ppm) {
if (ppm < 800) return COL_OK;
if (ppm < 1000) return COL_ACCENT;
if (ppm < 1500) return COL_ORANGE;
return COL_WARN;
}
// ---------------- 触摸:只用IRQ判断"有没有摁下",不做坐标映射 ----------------
// XPT2046在CS拉低期间,只要屏幕被摁着PENIRQ就一直是低电平——够用来做
// "单击/长按"这种不需要具体坐标的交互,省掉坐标标定(跟0911项目同一思路)。
static void touchInit() {
pinMode(TOUCH_CS, OUTPUT);
digitalWrite(TOUCH_CS, LOW);
pinMode(TOUCH_IRQ, INPUT);
}
static bool isTouchedNow() {
return digitalRead(TOUCH_IRQ) == LOW;
}
// ---------------- 绘制小工具 ----------------
static int cursorY;
static void resetCursor(int y) { cursorY = y; }
static void setPrimaryFont() { u8f.setFont(u8g2_font_wqy16_t_gb2312); } // 16px
static void setSecondaryFont() { u8f.setFont(u8g2_font_wqy12_t_gb2312); } // 12px
static void drawHeader(const char *title, uint16_t color) {
tft.fillScreen(COL_BG);
u8f.setForegroundColor(color);
setPrimaryFont();
u8f.drawUTF8(12, 26, title);
tft.drawFastHLine(0, 34, SCREEN_W, COL_DIM);
resetCursor(56);
}
// tier: 2=正文(16px),1=小字提示(12px)
static void printLine(const String &text, uint16_t color, uint8_t tier = 2) {
u8f.setForegroundColor(color);
if (tier >= 2) setPrimaryFont(); else setSecondaryFont();
u8f.drawUTF8(12, cursorY, text.c_str());
cursorY += (tier >= 2) ? 24 : 18;
}
static void printGap(int px) { cursorY += px; }
static void drawFooterHint(const char *text) {
u8f.setForegroundColor(COL_DIM);
setSecondaryFont();
u8f.drawUTF8(12, SCREEN_H - 8, text);
}
static void drawProgressBar(uint32_t elapsedMs) {
const int x = 12, y = SCREEN_H - 30, w = SCREEN_W - 24, h = 10;
uint32_t clamped = elapsedMs > HOLD_MS ? HOLD_MS : elapsedMs;
int fillW = (int)((uint32_t)w * clamped / HOLD_MS);
tft.fillRect(x, y, w, h, COL_BAR_BG);
if (fillW > 0) tft.fillRect(x, y, fillW, h, COL_ACCENT);
tft.drawRect(x, y, w, h, COL_DIM);
}
// ---------------- 各页面 ----------------
static void renderDetectPage() {
drawHeader("检测中...", COL_TEXT);
printLine("正在查找 SCD41", COL_TEXT);
}
static void renderErrorPage() {
drawHeader("未找到传感器", COL_WARN);
printLine("请检查接线:", COL_TEXT);
printLine("SDA=IO27 SCL=IO22", COL_TEXT);
printLine("VCC=3.3V 地址=0x62", COL_TEXT);
printGap(6);
printLine("VCC不要接5V,会打坏IO", COL_ACCENT, 1);
drawFooterHint("长按2秒重新扫描");
}
/* 实时读数页:只重画数值区,避免整屏刷新闪烁 */
static void renderLiveStatic() {
drawHeader("实时读数", COL_CYAN);
drawFooterHint("轻触:翻页 长按2秒:重新扫描");
}
static void renderLiveValues() {
// 清掉数值区(标题线以下、底部提示以上)
tft.fillRect(0, 38, SCREEN_W, SCREEN_H - 38 - 22, COL_BG);
if (!haveData) {
u8f.setForegroundColor(COL_DIM);
setPrimaryFont();
u8f.drawUTF8(12, 80, "等待第一组数据...");
u8f.setForegroundColor(COL_DIM);
setSecondaryFont();
u8f.drawUTF8(12, 104, "周期测量每5秒出一次");
return;
}
uint16_t qc = qualityColor(co2Ppm);
// 大字CO2数值:用TFT_eSPI内置7段字体(font 7),比点阵字体大得多,隔着镜头也看得清
tft.setTextColor(qc, COL_BG);
tft.setTextDatum(TL_DATUM);
tft.drawNumber(co2Ppm, 14, 46, 7);
// ppm单位 + CO2标签
u8f.setForegroundColor(COL_TEXT);
setPrimaryFont();
u8f.drawUTF8(200, 88, "ppm");
u8f.setForegroundColor(COL_DIM);
setSecondaryFont();
u8f.drawUTF8(200, 60, "CO2 二氧化碳");
// 空气质量条
const int barX = 12, barY = 104, barW = SCREEN_W - 24, barH = 12;
// 400ppm(户外)~2000ppm 映射成整条
int v = (int)co2Ppm - 400;
if (v < 0) v = 0;
int fill = (int)((long)barW * v / 1600);
if (fill > barW) fill = barW;
tft.fillRect(barX, barY, barW, barH, COL_BAR_BG);
if (fill > 0) tft.fillRect(barX, barY, fill, barH, qc);
tft.drawRect(barX, barY, barW, barH, COL_DIM);
u8f.setForegroundColor(qc);
setPrimaryFont();
u8f.drawUTF8(12, 142, qualityText(co2Ppm));
// 温湿度
char buf[48];
u8f.setForegroundColor(COL_TEXT);
setPrimaryFont();
snprintf(buf, sizeof(buf), "温度 %.1f C", tempC);
u8f.drawUTF8(12, 172, buf);
snprintf(buf, sizeof(buf), "湿度 %.1f %%", humidity);
u8f.drawUTF8(170, 172, buf);
u8f.setForegroundColor(COL_DIM);
setSecondaryFont();
u8f.drawUTF8(12, 192, "温度含自发热,偏高属正常,见第4页");
}
static void renderTrendPage() {
drawHeader("CO2 趋势 (近5分钟)", COL_CYAN);
const int gx = 12, gy = 46, gw = SCREEN_W - 24, gh = 130;
tft.drawRect(gx, gy, gw, gh, COL_DIM);
if (histCount < 2) {
u8f.setForegroundColor(COL_DIM);
setPrimaryFont();
u8f.drawUTF8(gx + 10, gy + 60, "数据还不够,等几个周期");
drawFooterHint("轻触:翻页 长按2秒:重新扫描");
return;
}
// 纵轴范围:跟着数据自适应,但至少留400~1000这个基本区间
uint16_t lo = 65535, hi = 0;
for (uint8_t i = 0; i < histCount; i++) {
uint16_t v = histAt(i);
if (v < lo) lo = v;
if (v > hi) hi = v;
}
if (lo > 400) lo = 400;
if (hi < 1000) hi = 1000;
uint16_t span = (uint16_t)(hi - lo);
if (span < 100) span = 100;
// 1000ppm参考线(室内建议通风的常用门槛)
int refY = gy + gh - (int)((long)gh * (1000 - lo) / span);
if (refY > gy && refY < gy + gh) {
for (int x = gx + 2; x < gx + gw - 2; x += 6) {
tft.drawPixel(x, refY, COL_ACCENT);
}
u8f.setForegroundColor(COL_ACCENT);
setSecondaryFont();
u8f.drawUTF8(gx + gw - 46, refY - 3, "1000");
}
// 条形图:每个历史点一根竖条
int barW = gw / HIST_N;
if (barW < 1) barW = 1;
for (uint8_t i = 0; i < histCount; i++) {
uint16_t v = histAt(i);
int h = (int)((long)(gh - 4) * (v - lo) / span);
if (h < 1) h = 1;
if (h > gh - 4) h = gh - 4;
int x = gx + 2 + i * barW;
tft.fillRect(x, gy + gh - 2 - h, barW > 1 ? barW - 1 : 1, h, qualityColor(v));
}
char buf[48];
u8f.setForegroundColor(COL_DIM);
setSecondaryFont();
snprintf(buf, sizeof(buf), "最低 %u 最高 %u 共 %u 个点", lo, hi, histCount);
u8f.drawUTF8(12, gy + gh + 16, buf);
drawFooterHint("轻触:翻页 长按2秒:重新扫描");
}
static void renderInfoPage() {
drawHeader("传感器信息", COL_OK);
printLine("型号:" + variantStr, COL_TEXT);
printLine("序列号:", COL_DIM, 1);
printLine(serialNumberStr, COL_TEXT);
printGap(4);
printLine("地址 0x62 I2C 100kHz", COL_TEXT, 1);
printGap(4);
if (singleShotOk) {
printLine("单次测量命令:支持", COL_OK, 1);
printLine("-> 判定为 SCD41", COL_OK, 1);
} else {
printLine("单次测量命令:不支持", COL_WARN, 1);
printLine("-> 判定为 SCD40", COL_WARN, 1);
}
drawFooterHint("轻触:翻页 长按2秒:重新扫描");
}
static void renderAboutPage() {
drawHeader("温度为什么偏高?", COL_ACCENT);
printLine("芯片自己会发热,测到的", COL_TEXT, 1);
printLine("温度比环境高,官方默认", COL_TEXT, 1);
printLine("扣掉4度作为温度偏移。", COL_TEXT, 1);
printGap(6);
printLine("装进外壳后热量更散不掉,", COL_TEXT, 1);
printLine("偏移值要按实际情况重调。", COL_TEXT, 1);
printGap(6);
printLine("本演示不写EEPROM:", COL_ACCENT, 1);
printLine("温度偏移/海拔/自校准开关", COL_DIM, 1);
printLine("默认只存RAM,掉电就回默认,", COL_DIM, 1);
printLine("不发persist_settings不烧写。", COL_DIM, 1);
drawFooterHint("轻触:回实时读数 长按2秒:重新扫描");
}
static void renderCurrentState() {
switch (state) {
case STATE_DETECT: renderDetectPage(); break;
case STATE_ERROR: renderErrorPage(); break;
case STATE_LIVE: renderLiveStatic(); renderLiveValues(); break;
case STATE_TREND: renderTrendPage(); break;
case STATE_INFO: renderInfoPage(); break;
case STATE_ABOUT: renderAboutPage(); break;
}
}
// ---------------- 触摸事件 -> 状态机 ----------------
static void onShortTap() {
switch (state) {
case STATE_LIVE: state = STATE_TREND; renderCurrentState(); break;
case STATE_TREND: state = STATE_INFO; renderCurrentState(); break;
case STATE_INFO: state = STATE_ABOUT; renderCurrentState(); break;
case STATE_ABOUT: state = STATE_LIVE; renderCurrentState(); break;
default: break; // 错误页只能长按重试
}
}
static void onLongPressComplete() {
ESP.restart();
}
// ---------------- 传感器初始化 ----------------
static bool sensorInit() {
Wire.beginTransmission(SCD4X_ADDR);
if (Wire.endTransmission() != 0) return false;
// 上电后可能还在跑周期测量,不停下来后面的命令会被忽略
sendCmd(CMD_STOP_PERIODIC);
delay(500);
uint16_t sn[3];
if (sendCmd(CMD_GET_SERIAL) == 0) {
delay(5);
if (readWords(sn, 3)) {
char buf[16];
snprintf(buf, sizeof(buf), "%04X%04X%04X", sn[0], sn[1], sn[2]);
serialNumberStr = String(buf);
}
}
// 方法A:变体寄存器(新固件才有)
if (sendCmd(CMD_GET_VARIANT) == 0) {
delay(5);
uint16_t v;
if (readWords(&v, 1)) {
uint8_t variant = (uint8_t)((v >> 12) & 0x0F);
if (variant == 0) variantStr = "SCD40";
else if (variant == 1) variantStr = "SCD41";
}
}
// 方法B:单次测量命令,只有SCD41支持——关键判据
if (sendCmd(CMD_SINGLE_SHOT) == 0) {
delay(5000);
uint16_t raw[3];
if (sendCmd(CMD_READ_MEASURE) == 0) {
delay(2);
if (readWords(raw, 3) && raw[0] != 0) {
singleShotOk = true;
variantStr = "SCD41";
}
}
}
if (sendCmd(CMD_START_PERIODIC) != 0) return false;
return true;
}
/* 有新数据就取一组,没有就直接返回。5秒出一次。 */
static bool sensorPoll() {
uint16_t ready = 0;
if (sendCmd(CMD_DATA_READY) != 0) return false;
delay(2);
if (!readWords(&ready, 1)) return false;
if ((ready & 0x07FF) == 0) return false; // 低11位为0=还没准备好
if (sendCmd(CMD_READ_MEASURE) != 0) return false;
delay(2);
uint16_t raw[3];
if (!readWords(raw, 3)) return false;
if (raw[0] == 0) return false;
co2Ppm = raw[0];
tempC = -45.0f + 175.0f * (float)raw[1] / 65535.0f;
humidity = 100.0f * (float)raw[2] / 65535.0f;
haveData = true;
histPush(co2Ppm);
Serial.printf("CO2: %u ppm\t温度: %.1f C\t湿度: %.1f %%RH\n",
co2Ppm, tempC, humidity);
return true;
}
void setup() {
Serial.begin(115200);
delay(300);
tft.init();
tft.setRotation(1);
tft.fillScreen(COL_BG);
u8f.begin(tft);
u8f.setFontMode(1); // 透明背景
u8f.setFontDirection(0);
touchInit();
Wire.begin(I2C_SDA, I2C_SCL);
Wire.setClock(100000); // SCD4x建议100kHz,别拉到400kHz
renderDetectPage();
if (!sensorInit()) {
Serial.println("SCD41 not found");
state = STATE_ERROR;
renderCurrentState();
return;
}
Serial.print("S/N: "); Serial.println(serialNumberStr);
Serial.print("Variant: "); Serial.println(variantStr);
state = STATE_LIVE;
renderCurrentState();
}
void loop() {
static uint32_t lastPoll = 0;
static uint32_t lastSample = 0;
static bool wasDown = false;
static uint32_t pressStartMs = 0;
static bool longFired = false;
static uint32_t lastActionMs = 0;
static uint8_t pollFailCount = 0; // 连续几次sensorPoll()失败,用来判定"传感器被拔了"
uint32_t now = millis();
// 每5秒取一次传感器数据(周期测量的出数节奏)
if (state != STATE_ERROR && state != STATE_DETECT && now - lastSample >= 5000) {
lastSample = now;
if (sensorPoll()) {
pollFailCount = 0;
// 只有当前停在会变的页面才重画,其它页面等翻回去时自然是新数据
if (state == STATE_LIVE) renderLiveValues();
if (state == STATE_TREND) renderTrendPage();
} else {
// sensorPoll()失败原因有两种:①传感器拔掉了(I2C直接无应答);②数据还没准备好
// (正常现象,比如刚开机头几秒)。用连续失败次数区分,别偶尔一次没读到就误判:
// 连续3次(约15秒,覆盖3个测量周期)都拿不到数据才真的当"传感器不在了"处理,
// 不然刚好卡在数据未就绪的那一瞬间会被错误地判成"拔了"。
// 之前这里什么都不做,画面就一直停在拔传感器前最后一次的旧读数上,看起来
// 像"没传感器也在正常出数"——这是真实bug,不是设计如此。
pollFailCount++;
if (pollFailCount >= 3 && (state == STATE_LIVE || state == STATE_TREND ||
state == STATE_INFO || state == STATE_ABOUT)) {
haveData = false;
state = STATE_ERROR;
renderCurrentState();
}
}
}
if (now - lastPoll < 20) return;
lastPoll = now;
bool down = isTouchedNow();
if (down && !wasDown) {
pressStartMs = now;
longFired = false;
}
if (down) {
uint32_t held = now - pressStartMs;
if (held > 300) drawProgressBar(held); // 摁过300ms才显示进度条,避免单击闪一下
if (!longFired && held >= HOLD_MS) {
longFired = true;
lastActionMs = now;
onLongPressComplete();
}
}
if (!down && wasDown) {
if (!longFired && (now - lastActionMs > 250)) {
lastActionMs = now;
onShortTap();
} else if (!longFired) {
renderCurrentState(); // 抹掉没走完的进度条
}
}
wasDown = down;
}
原始文件:下载 main.ino
(依赖 TFT_eSPI、U8g2_for_TFT_eSPI 两个库,PlatformIO/Arduino IDE库管理器搜名字装)
使用要点
- 周期测量每 5 秒出一组数据。启动后第一组数据要等约 5 秒,不是立刻就有。
- 发配置类命令前必须先停:发
stop_periodic_measurement(0x3F86)并等 500 ms,否则命令会被忽略。这是新手最常见的错误。 - 读数为 0 表示该帧无效,程序里应当丢弃,不要显示成"CO2 浓度为 0"。
- 刚上电前几分钟读数会漂,属于正常现象,需要预热。
- 每个数据字后面跟 1 字节 CRC(多项式
0x31,初始值0xFF)。CRC 校验不过通常是杜邦线松动、线太长或缺上拉电阻。 - I2C 速度用 100 kHz,不要拉到 400 kHz。
| 命令 | 值 | 说明 |
|---|---|---|
| start_periodic_measurement | 0x21B1 | 启动周期测量 |
| read_measurement | 0xEC05 | 读 CO2 / 温度 / 湿度 |
| stop_periodic_measurement | 0x3F86 | 停止,之后等 500 ms |
| get_serial_number | 0x3682 | 读序列号,用来确认通信正常 |
| get_data_ready_status | 0xE4B8 | 低 11 位非 0 表示数据就绪 |
| measure_single_shot | 0x219D | 单次测量,仅 SCD41 支持 |
换算公式(来自数据手册):
| 物理量 | 换算 |
|---|---|
| CO2 | 原始值直接就是 ppm |
| 温度 (°C) | -45 + 175 × raw / 65535 |
| 湿度 (%RH) | 100 × raw / 65535 |
排查流程
- 先跑 I2C 扫描,确认总线上能看到
0x62。树莓派用i2cdetect -y 1,Arduino / ESP32 用任意 I2C Scanner。 - 扫不到 0x62:检查 SDA / SCL 有没有接反、VCC 是否接对(UNO 接 5V,ESP32 / STM32 / 树莓派接 3.3V)、有没有共地。UNO 用户重点查供电是否够(见第 4 节)。
- 扫得到但读数失败:多半是 CRC 校验不过。板上已有 10K 上拉,所以重点查杜邦线是否松动、线是否过长、有没有干扰源。
- 能读但数值是 0:那一帧无效,正常现象,程序里丢弃即可。
- 数值一直不变或明显不合理:确认是否忘了先发停止命令就直接发配置命令;再确认预热时间是否足够。
- 怀疑型号不符:跑第 1 节的识别程序确认是 SCD40 还是 SCD41。
Support
需要技术支持时,把这几项一起发来
复制下面这段填一下发给客服,我们一次就能定位,不用来回问。
官方资料
- Sensirion SCD4x 产品页与数据手册:sensirion.com/products/catalog/SCD41
- Arduino 官方库:库管理器搜索
Sensirion I2C SCD4x - 树莓派 smbus2:pypi.org/project/smbus2
两块板的引脚顺序、尺寸、SKU 和板载元件(2 × 10K 上拉、100nF 去耦)来自实物与本店确认; 供电范围(2.4 – 5.5 V)、命令字、CRC 和换算公式来自 Sensirion 官方数据手册; Arduino 例程为官方原版,其余精简版尚未实机验证。