先选清楚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产品和丝印修订
同一总线接两块具体模块,先留出供电余量
保持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-4 | 330Ω→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
固定完整库依赖,再编译与写入
下载包同时有完整.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初始化返回值与显示实现
周期采样先等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初始化返回值与显示实现
完整程序:显示、串口和本地提醒共用有效状态
首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
样本、稳定观察、迟滞与故障放在同一个模型
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与深睡重启边界
短演示不乱做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模块供电/上拉/跳线
最后记录通风趋势,再分开验两种断线
先固定位置、供电、人数,记录10分钟自然室内基线,再记录开窗后的10分钟;保存时间、有效新样本、age、状态与场景,不能拿合成曲线填CSV。断电预装SCD41单模块SDA支路SPST,有样本后只操作开关,观察错误/旧值年龄/故障灯;闭合等待真实新读数,必要时EN恢复。另做断电移除模块后冷启动,不能混称同一测试。
无数据先核SKU、供电、0x62与周期命令;CRC/总线错先恢复短线共地,不重校准掩盖通信问题。屏幕空白单查0x3D、自动复位修订与实际像素。本站仅实际构建和同源主机检查;没有这些物理动作或趋势结果,observations.csv保持空。
clang++ -std=c++17 -Wall -Wextra -Werror host_check.cpp -o /tmp/s03-host
/tmp/s03-host
clang++ -std=c++17 -DESP_PLATFORM -Itests/stubs tests/production_loop.cpp -o /tmp/s03-loop
/tmp/s03-loop- 做到这里,你应该看到
- 趋势、断线、恢复每项都有明确可记录字段,未执行项保持pending。
参考:[1] Sensirion SCD4x datasheet1.7:性能/供电/周期/补偿/ASC与FRC[2] Sensirion Arduino SCD4x1.1.0:data-ready、read、stop等待与配置[5] Adafruit SCD4x模块供电/上拉/跳线[6] 指定OLED PID326:修订、地址和引脚[7] 指定DevKitC-1 v1.1排针、USB及存储范围
资料来源与版本说明
实际读取固定版本源码/器件资料,构建与主机逻辑分别留证。本站未完成烧录、GPIO/ADC/光学/传感器或HA运行;电路名义值与软件命令不冒充实测。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。
- Sensirion SCD4x datasheet1.7:性能/供电/周期/补偿/ASC与FRC
- Sensirion Arduino SCD4x1.1.0:data-ready、read、stop等待与配置
- Sensirion Core0.7.3:I²C帧接收与CRC拒绝
- Adafruit PID5190产品和丝印修订
- Adafruit SCD4x模块供电/上拉/跳线
- 指定OLED PID326:修订、地址和引脚
- 指定DevKitC-1 v1.1排针、USB及存储范围
- WP7113ID普通红LED器件手册
- SSD1306库2.5.17初始化返回值与显示实现
- SSD1306 2.5.17 I2C clock constructor defaults
- ESP-IDF5.5.5:64位启动微秒时钟与接口
- ESP-IDF5.5.5:Obtaining Current Time与深睡重启边界