传感器与可信数据 / 传感器应用发布 内容更新

ESP32 做 CO₂ 桌面提醒器:用 SCD41 看通风变化

固定SCD41真实CO₂模块与OLED,等待data-ready、显式临时关闭ASC,保留稳定观察/陈旧状态和完整迟滞提醒。

读完能做什么
固定SCD41真实CO₂模块与OLED,等待data-ready、显式临时关闭ASC,保留稳定观察/陈旧状态和完整迟滞提醒。
开始之前
按文中真实前置完成桥USB、模块核对及基本数据/HA集成,再制作本例。
所需器材
S3 DevKitC-1 v1.1 N8R8、Adafruit SCD41 PID5190默认跳线、Adafruit SSD1306 PID326 2020后自动复位128×64、WP7113ID普通红LED/330Ω±5%0.25W、短I²C线、可选SCD41支路SDA低压SPST开关
版本组合
框架:Arduino-ESP32 3.3.12 / CLI1.5.1驱动:Sensirion I2C SCD4x1.1.0 / Core0.7.3显示:SSD1306 2.5.17 / GFX1.12.4 / BusIO1.17.4手册:SCD4x v1.7 April2025
适用范围

官方ESP32-S3-DevKitC-1 v1.1 / WROOM-1-N8R8,Flash8MB、OPI PSRAM;PID5190 SCD41 + PID326 0x3D指定修订;GPIO8/9 100kHz、GPIO4普通LED;桥UART0/115200。

这篇验证到了哪一步

资料核对已通过
docs/esp32-evidence/timer-repairs.md#s03;计时修复与固定来源核查,原器件/接线范围保留。验证日期:
构建 / 配置已通过
docs/esp32-evidence/timer-repairs-builds.json;本次计时修复实际编译与当前源/CPP/日志/产物SHA。历史原始记录另存,未替换。对象:ESP32-S3-DevKitC-1 v1.1 N8R8版本:Arduino-ESP32 3.3.12 / CLI1.5.1验证日期:
仿真不适用
docs/esp32-evidence/timer-repairs-host.json;相同头文件及实际.ino/YAML lambda的合成时间检查,不是芯片/器件/调度仿真或49天实跑。
本站实板待验证
无本站烧录、真实传感器/ADC/实体灯或HA运行;observations.csv为空。

构建通过只说明指定环境中的代码或配置通过检查;实物功能、功耗和稳定性以对应的实板记录为准。

01 / 传感器应用

先选清楚SCD41,CO₂与eCO₂不能混用

SCD41用光声光学方法直接测CO₂,和从VOC推算eCO₂的模块不是同一种输出。本例固定Adafruit PID5190的SCD41;同为0x62地址的SCD40/SCD43不能只改标题就共用精度说明。先核产品SKU和板/芯片标记,程序序列号只帮助追踪器件,不把地址扫描当型号鉴定。

成品是桌面趋势与自定阈值提醒:OLED给CO₂、T/RH、状态与样本年龄,普通LED给提醒/故障。本文没有本站环境曲线或烧录结果,也不把它叫CO报警器。0–40000ppm输出范围不等于该范围都有指定精度;厂家v1.7对SCD41按下表分段,表的25°C、50%RH、1013mbar、周期测量与传感器3.3V等条件也必须一起看。

CO₂范围SCD41手册1.7测量精度
400–1000ppm±(50ppm + 2.5%读数)
1001–2000ppm±(50ppm + 3%读数)
2001–5000ppm±(40ppm + 5%读数)
做到这里,你应该看到
能区分输出范围、分段精度、模块SKU与整机已校准四件事。

参考:[1] Sensirion SCD4x datasheet1.7:性能/供电/周期/补偿/ASC与FRC[4] Adafruit PID5190产品和丝印修订

02 / 传感器应用

同一总线接两块具体模块,先留出供电余量

保持v1.1 N8R8与D01同一GPIO8/9;SCD41 PID5190和2020后STEMMA QT/自动复位的PID326 OLED都以VIN=3V3、GND共地连接。两模块已有上拉,用短线和100kHz,不先额外并联上拉;SCD41地址0x62,OLED0x3D,3Vo是输出而非输入,保持不接。OLED旧修订、SH1106或匿名屏不能套RST留空规则。SSD1306 2.5.17默认构造参数会在显示事务临时升到400kHz;本例与D01一致,显式传clkDuring=100000UL、clkAfter=100000UL,使显示事务及恢复时钟均为100kHz。

SCD4x在3.3V条件峰值最高205mA,不能按15–18mA平均值判断USB/板载供电余量。ESP32、屏幕和传感器共享供电时,下陷与噪声需要真实检查;默认Sensor Power跳线把传感器直接接到VIN;3Vo虽为板载稳压输出,却不是默认的传感器供电路径。保持默认跳线不剪改,原始Rev A左右丝印曾写反,须按官方errata/原理图核实。VIN=3.3V仍须检查传感器实际供电与数据表全部精度条件。只让进气孔接触环境空气,离开主控热源和直吹呼气/结露;不以5V拉高I²C来解决电流不足。

DevKit端点SCD41 / OLED
3V3 / J1-1两模块VIN
GND / J1-22两模块GND及LED阴极
GPIO8 / J1-12两模块SDA
GPIO9 / J1-15两模块SCL /100kHz
GPIO4 / J1-4330Ω→WP7113ID阳极
不接SCD41 3Vo;仅指定修订OLED的RST
做到这里,你应该看到
断电逐端追线,地址/修订/供电与表一致,只接一条桥USB。

参考:[1] Sensirion SCD4x datasheet1.7:性能/供电/周期/补偿/ASC与FRC[5] Adafruit SCD4x模块供电/上拉/跳线[6] 指定OLED PID326:修订、地址和引脚[7] 指定DevKitC-1 v1.1排针、USB及存储范围[8] WP7113ID普通红LED器件手册[10] SSD1306 2.5.17 I2C clock constructor defaults

03 / 传感器应用

固定完整库依赖,再编译与写入

下载包同时有完整.ino、co2_state.h、sketch.yaml、host_check、README与许可。CLI1.5.1/core3.3.12,Sensirion SCD4x1.1.0/Core0.7.3与Adafruit三库固定在profile;未设置default_profile,避免仅查看工程就隐式触发重复环境安装。新机器用显式profile,作者实际编译命令和版本记录另外保留。

先compile,再把PORT换成插拔确认的USB-to-UART桥口upload/monitor,115200。烧录前关闭旧监视器,必要时BOOT+EN进入下载;刷后松开BOOT再EN。编译成功、驱动加载和串口出现旧日志都不证明当前程序已在板上;要对照固件摘要、S03状态字段与新序列。

代码 / 配置
arduino-cli compile --profile s3-n8r8 .
arduino-cli upload --profile s3-n8r8 --port PORT .
arduino-cli monitor --port PORT --config baudrate=115200
做到这里,你应该看到
能从sketch.yaml列出全部依赖,当前构建和运行观察分别记录。

参考:[2] Sensirion Arduino SCD4x1.1.0:data-ready、read、stop等待与配置[3] Sensirion Core0.7.3:I²C帧接收与CRC拒绝[7] 指定DevKitC-1 v1.1排针、USB及存储范围[9] SSD1306库2.5.17初始化返回值与显示实现

04 / 传感器应用

周期采样先等data-ready,旧值必须有年龄

初始化先stop,库在成功命令后等待要求的500ms,再reinit、读序列号、在idle中临时关ASC并读回0,最后startPeriodicMeasurement。测量中只使用允许的ready/read等命令。每500ms查询一次ready,只有ready才read一次;读完缓冲清空,不用while永远等待,也不因1秒画屏幕就声称有1Hz新CO₂。库先处理帧与CRC,失败不接受为新样本。

程序拒绝0ppm、越输出范围、非有限T/RH或RH越界;通信/校验错误立即READ_ERROR并优先保留,直到新好样本清除;无待清除错误且已有样本时,超过15秒无新有效值才标STALE,尚无样本先WAIT_READY,启动超过15秒后才NO_SAMPLE。屏幕LAST和age保留上次成功值供排查,不当当前正常读数;故障闪灯与阈值提醒分开。OLED begin成功主要说明分配/初始化路径,不证明屏幕应答或像素,显示和SCD41问题应分别检查。

做到这里,你应该看到
NOT_READY可以是正常等待;READ_ERROR/STALE不能用0ppm或无年龄旧值掩盖。

参考:[1] Sensirion SCD4x datasheet1.7:性能/供电/周期/补偿/ASC与FRC[2] Sensirion Arduino SCD4x1.1.0:data-ready、read、stop等待与配置[3] Sensirion Core0.7.3:I²C帧接收与CRC拒绝[9] SSD1306库2.5.17初始化返回值与显示实现

05 / 传感器应用

完整程序:显示、串口和本地提醒共用有效状态

首180秒SETTLING且不按CO₂常亮提醒,是本例的应用稳定观察策略,不是厂家统一预热/精度保证。厂家典型τ63%响应60秒也不是“60秒后校准完毕”。观察期后新读数≥1200开启提醒、≤1000关闭,中间保持;这些是演示工程阈值,不是法定空气质量或健康界限。

完整程序如下;配套头文件紧随下一节给出,下载包逐字相同。初始化失败每10秒重试,Wire事务超时50ms;运行时断线可由后续有效读取恢复,若模块掉电后回idle则EN重启。没有任何FRC、factory reset或persist_settings调用。

代码 / 配置
// SPDX-License-Identifier: MIT
#include <Arduino.h>
#include <Wire.h>
#include <SensirionI2cScd4x.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include "co2_state.h"
constexpr uint8_t LED_PIN=4;
SensirionI2cScd4x sensor;
Adafruit_SSD1306 screen(128,64,&Wire,-1,100000UL,100000UL);
s03::State state;
bool running=false,screen_ok=false;
uint64_t last_poll=0,last_screen=0,last_retry=0;
uint32_t sequence=0;
float last_t=NAN,last_rh=NAN;
static void report_error(const char* phase,int16_t error){
 char text[96];errorToString(error,text,sizeof(text));
 Serial.printf("ERROR phase=%s code=%d detail=%s; no valid new sample\n",phase,error,text);
 state.fail();
}
static bool start_sensor(){
 // This example never issues FRC, factory reset or persist_settings.
 int16_t error=sensor.stopPeriodicMeasurement(); // driver waits required 500ms
 if(error){report_error("stop",error);return false;}
 error=sensor.reinit();if(error){report_error("reinit",error);return false;}
 uint64_t serial=0;error=sensor.getSerialNumber(serial);
 if(error){report_error("serial",error);return false;}
 error=sensor.setAutomaticSelfCalibrationEnabled(0);
 if(error){report_error("ASC-off",error);return false;}
 uint16_t asc=1;error=sensor.getAutomaticSelfCalibrationEnabled(asc);
 if(error||asc!=0){if(error)report_error("ASC-readback",error);else state.fail();return false;}
 error=sensor.startPeriodicMeasurement();if(error){report_error("start",error);return false;}
 state.start(s03::now_ms());
 Serial.printf("START serial=%04lx%08lx ASC=0 RAM-only settle_ms=%lu first data requires ready\n",(unsigned long)(serial>>32),(unsigned long)serial,(unsigned long)s03::SETTLE_MS);
 return true;
}
void setup(){
 pinMode(LED_PIN,OUTPUT);digitalWrite(LED_PIN,LOW);Serial.begin(115200);
 Wire.begin(8,9,100000);Wire.setTimeOut(50);
 screen_ok=screen.begin(SSD1306_SWITCHCAPVCC,0x3D,true,false);
 if(screen_ok){screen.clearDisplay();screen.setTextColor(SSD1306_WHITE);screen.setTextSize(1);screen.setCursor(0,0);screen.println("S03 starting...");screen.display();}
 else Serial.println("OLED allocation/init failed; serial and local LED continue");
 sensor.begin(Wire,SCD41_I2C_ADDR_62);delay(30);
 running=start_sensor();last_retry=s03::now_ms();
}
void loop(){
 uint64_t now=s03::now_ms();
 if(!running&&(now-last_retry)>=10000){last_retry=now;running=start_sensor();}
 if(running&&(now-last_poll)>=500){
  last_poll=now;bool ready=false;int16_t error=sensor.getDataReadyStatus(ready);
  if(error)report_error("ready",error);
  else if(ready){
   uint16_t co2=0;float t=NAN,rh=NAN;error=sensor.readMeasurement(co2,t,rh);
   if(error)report_error("read",error);
   else if(state.accept(s03::now_ms(),co2,t,rh)){
    last_t=t;last_rh=rh;
    Serial.printf("sample,%lu,%llu,%u,%.2f,%.2f,%s,ASC_OFF\n",(unsigned long)++sequence,(unsigned long long)s03::now_ms(),co2,t,rh,state.label(s03::now_ms()));
   }else Serial.println("INVALID sample rejected");
  }
 }
 now=s03::now_ms();digitalWrite(LED_PIN,state.led(now)?HIGH:LOW);
 if((now-last_screen)>=1000){
  last_screen=now;Serial.printf("status,%llu,%s,age_ms=%llu; physical outputs unverified\n",(unsigned long long)now,state.label(now),state.seen?(unsigned long long)(now-state.last):0ULL);
  if(screen_ok){
   screen.clearDisplay();screen.setCursor(0,0);screen.printf("S03 %s\n",state.label(now));
   if(state.seen){screen.printf("%s CO2 %u ppm\n",state.fresh(now)?"FRESH":"LAST",state.ppm);screen.printf("T %.1f C RH %.1f%%\n",last_t,last_rh);screen.printf("age %llu s\n",(unsigned long long)((now-state.last)/1000));}
   else screen.println("CO2 -- ppm");
   screen.println("ASC off / RAM only");screen.println("demo on1200 off1000");screen.display();
  }
 }
 delay(5);
}

参考:[1] Sensirion SCD4x datasheet1.7:性能/供电/周期/补偿/ASC与FRC[2] Sensirion Arduino SCD4x1.1.0:data-ready、read、stop等待与配置[3] Sensirion Core0.7.3:I²C帧接收与CRC拒绝[9] SSD1306库2.5.17初始化返回值与显示实现[10] SSD1306 2.5.17 I2C clock constructor defaults

06 / 传感器应用

样本、稳定观察、迟滞与故障放在同一个模型

co2_state.h既进入真实固件,也供主机检查。启动时刻、样本时刻与轮询使用ESP-IDF5.5.5的esp_timer_get_time()完整64位微秒值,先转成uint64毫秒再保存;不是把已回绕的millis()扩宽。这样跨过2³²毫秒(约49.7天)后,旧样本仍为STALE,已经结束的180秒观察期不会重开。重启会清空RAM并重新观察,不跨重启保存计时。T/RH仍受器件自热和offset影响,本项目没有完成整机补偿。

同源主机检查保留非法样本、迟滞、错误与恢复,并注入超过一个完整32位周期的时间。另用依赖桩直接运行本页完整.ino,确认生产调用传入完整IDF时刻、陈旧值不复活、持续新样本不重入SETTLING。桩替代I²C/显示,仅验证所给输入下的代码,不是49天实跑、传感器CRC、电气或任务调度仿真。

代码 / 配置
// SPDX-License-Identifier: MIT
#pragma once
#include <stdint.h>
#include <math.h>
#ifdef ESP_PLATFORM
#include "esp_timer.h"
#endif
namespace s03 {
constexpr uint32_t SETTLE_MS=180000, STALE_MS=15000;
#ifdef ESP_PLATFORM
// Convert the full IDF boot-time value before any narrowing; resets on reboot.
inline uint64_t now_ms(){return static_cast<uint64_t>(esp_timer_get_time())/1000ULL;}
#endif
struct State {
 uint64_t started=0,last=0; uint16_t ppm=0; bool seen=false,error=false,remind=false;
 void start(uint64_t now){*this=State();started=now;}
 void fail(){error=true;remind=false;}
 bool settled(uint64_t now)const{return now>=started&&now-started>=SETTLE_MS;}
 bool accept(uint64_t now,uint16_t co2,float temperature,float rh){
  if(co2==0||co2>40000||!isfinite(temperature)||!isfinite(rh)||rh<0||rh>100){fail();return false;}
  ppm=co2;last=now;seen=true;error=false;
  if(settled(now)){if(ppm>=1200)remind=true;else if(ppm<=1000)remind=false;}
  return true;
 }
 bool fresh(uint64_t now)const{return seen&&!error&&now>=last&&now-last<=STALE_MS;}
 const char* label(uint64_t now)const{
  if(error)return "READ_ERROR";
  if(!seen)return now>=started&&now-started>STALE_MS?"NO_SAMPLE":"WAIT_READY";
  if(!fresh(now))return "STALE";
  if(!settled(now))return "SETTLING";
  return remind?"VENT_REMIND":"OBSERVE";
 }
 bool fault(uint64_t now)const{return error||(seen?!fresh(now):(now>=started&&now-started>STALE_MS));}
 bool led(uint64_t now)const{return fault(now)?((now/250)%2):fresh(now)&&settled(now)&&remind;}
};
}
做到这里,你应该看到
同一数据质量规则同时约束屏幕、串口与灯,主机输入不算实物运行。

参考:[1] Sensirion SCD4x datasheet1.7:性能/供电/周期/补偿/ASC与FRC[2] Sensirion Arduino SCD4x1.1.0:data-ready、read、stop等待与配置[3] Sensirion Core0.7.3:I²C帧接收与CRC拒绝[11] ESP-IDF5.5.5:64位启动微秒时钟与接口[12] ESP-IDF5.5.5:Obtaining Current Time与深睡重启边界

07 / 传感器应用

短演示不乱做ASC/FRC,长期使用先核条件

本例每次启动在RAM中关闭ASC并读回0,没有保存该设置。厂家v1.7的默认ASC要求基于累计运行、至少4小时连续运行段,并在累计一周后接触已知400ppm背景超过3分钟等假设;短桌面演示和长期高CO₂房间未必满足。关闭本例后重上电/按厂家reinit重载EEPROM,不声称抹除了用户以前保存过的校准历史。

FRC需要外部可信CO₂参考、同正常模式至少3分钟、正确供电,以及适用时预先给压力/海拔补偿;普通室外空气不能未经测量一律当400ppm。手册还说明装配/运输影响与相应条件,不提供“按一下设400”的恢复按钮。本站没有执行FRC,也没有写“已校准”。

做到这里,你应该看到
把预热/响应、应用观察期、ASC条件和FRC参考分别说明。

参考:[1] Sensirion SCD4x datasheet1.7:性能/供电/周期/补偿/ASC与FRC[2] Sensirion Arduino SCD4x1.1.0:data-ready、read、stop等待与配置[5] Adafruit SCD4x模块供电/上拉/跳线

BACK TO THE SOURCE

资料来源与版本说明

实际读取固定版本源码/器件资料,构建与主机逻辑分别留证。本站未完成烧录、GPIO/ADC/光学/传感器或HA运行;电路名义值与软件命令不冒充实测。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。

  1. Sensirion SCD4x datasheet1.7:性能/供电/周期/补偿/ASC与FRC
  2. Sensirion Arduino SCD4x1.1.0:data-ready、read、stop等待与配置
  3. Sensirion Core0.7.3:I²C帧接收与CRC拒绝
  4. Adafruit PID5190产品和丝印修订
  5. Adafruit SCD4x模块供电/上拉/跳线
  6. 指定OLED PID326:修订、地址和引脚
  7. 指定DevKitC-1 v1.1排针、USB及存储范围
  8. WP7113ID普通红LED器件手册
  9. SSD1306库2.5.17初始化返回值与显示实现
  10. SSD1306 2.5.17 I2C clock constructor defaults
  11. ESP-IDF5.5.5:64位启动微秒时钟与接口
  12. ESP-IDF5.5.5:Obtaining Current Time与深睡重启边界