扫描器只看到0x44,不能说明只接了一块
把两块同地址传感器接在一起,扫描器仍可能只列一次0x44。ACK说明这个地址有人应答,没有响应者数量字段;读数据时开漏线路可能组合两块的输出,也可能因内容相同而看似正常。因此一条CRC正确的温湿度记录不能独立证明来自唯一一块模块,更不能靠“读数有变化”验收双传感器。
本篇保留S09原任务,固定两块Adafruit PID4885 SHT40。它们的0x44不能由软件改成0x45;驱动begin里的地址只是主机寻址目标。其他SHT4x后缀可能出厂另配地址,但那不表示当前SKU有软件改址命令。成品采用S3两个硬件I²C控制器,让两个0x44分别拥有总线、驱动、串号和状态。
- 做到这里,你应该看到
- 能解释“扫描一个地址”与“唯一响应器件”为什么不同。
参考:[1] Adafruit PID4885:地址不可改、3V输出、已有10k上拉[2] Sensirion SHT4x V6.4:出厂地址料号、CRC、命令与换算[9] NXP UM10204 Rev7:开漏总线、ACK与寻址
先看器件能否选地址,再考虑第二总线或多路器
如果具体模块真的有ADDR焊盘/引脚,先按它的手册设置地址,再给驱动相应地址;如果是出厂地址选项,须选另一个完整料号,不能靠改常量变出硬件功能。本例固定两块PID4885,所以不走改地址这条路。
S3有两个硬件I²C控制器,core3.3.12提供Wire和Wire1,分别对应bus0/bus1。两块正好可各占一条;不能创建两个都指向0的TwoWire对象便声称分离。引脚不足、芯片只有一个控制器或器件更多时,才另选可寻址器件,或依据具体多路器手册设计通道选择;本下载只实现两总线方案,不把多路器留作含糊的必做步骤。
| 真实条件 | 本例的判断 |
|---|---|
| 模块手册允许ADDR选择 | 按真实引脚/焊盘设置并扫描确认 |
| 只有出厂地址后缀选项 | 换对应完整料号,不能软件变更 |
| 两块PID4885 + S3两控制器 | 分别用Wire与Wire1 |
| 更多同地址器件/控制器不足 | 另设计有手册的多路器或改器件组合 |
参考:[1] Adafruit PID4885:地址不可改、3V输出、已有10k上拉[2] Sensirion SHT4x V6.4:出厂地址料号、CRC、命令与换算[3] Arduino3.3.12 Wire:编号控制器、begin和事务超时[4] Arduino3.3.12 Wire头:TwoWire接口与Wire1声明[5] ESP-IDF5.5.5 S3能力:硬件I2C控制器数量
电源地共享,SDA和SCL必须分开
模块A的VIN/GND接板3V3/J1-1与GND/J1-22,SDA/SCL接GPIO8/J1-12、GPIO9/J1-15;模块B电源地相同,SDA/SCL改为GPIO17/J1-10、GPIO18/J1-11。两个模块3V/3Vo都是稳压输出,留空;不把两路稳压输出并在一起。
PID4885自带上拉,先用每总线一块模块、≤20cm短线、100kHz,不先额外并联电阻。可靠焊好排针或固定端子,完全断电后逐端追线;两个STEMMA QT口不能再相互菊花链接回去,否则总线又被连在一起。GPIO17/18本例不启用UART1,桥UART0仍用43/44;N8R8保留35–37和RGB38都不参与。
先让每块分别单独接GPIO8/9,按P06/S01读到0x44和自己的序列号,再断电按表分线。只接一条桥USB供电,不同时外加5V/第二USB,不使用5V I²C上拉。给模块贴A/B和位置标签,串号用于追踪具体芯片,不把标签或地址当型号鉴定/精度校准。
| DevKit | 模块A | 模块B |
|---|---|---|
| 3V3/J1-1 | VIN | VIN |
| GND/J1-22 | GND | GND |
| GPIO8/9 | SDA/SCL | 不接 |
| GPIO17/18 | 不接 | SDA/SCL |
| 模块3V/3Vo | 留空 | 留空 |
- 做到这里,你应该看到
- A/B有不同数据线端点,图、表、代码中的8/9/17/18一致。
参考:[1] Adafruit PID4885:地址不可改、3V输出、已有10k上拉[3] Arduino3.3.12 Wire:编号控制器、begin和事务超时[8] 指定DevKitC-1 v1.1排针、桥USB和N8R8限制
先固定依赖编译,再验证当前串口程序
环境锁为Arduino CLI1.5.1/core3.3.12,Sensirion I2C SHT4x1.1.2/Core0.7.3。下载包sketch.yaml固定全部版本且没有default_profile;实际构建复用全局已安装core/库,不为相同profile重复下载SDK。README给出安装、完整FQBN、upload和monitor命令,新机器也能显式profile重建这套组合。
先编译完整.ino和channel_state.h,再选插拔确认的桥口、关闭监视器后上传,必要时BOOT+EN进下载,结束释放BOOT再EN。115200里要出现S09启动行和两通道记录;编译通过、上传工具退出或串口仍有旧数值,都不单独证明当前程序和接线通过。本站实际构建与主机逻辑已执行,实板、扫描和器件行为尚未执行。
arduino-cli lib install 'Sensirion Core@0.7.3'
arduino-cli lib install --no-deps 'Sensirion I2C SHT4x@1.1.2'
arduino-cli compile --fqbn 'esp32:esp32:esp32s3:FlashSize=8M,PSRAM=opi,FlashMode=qio,PartitionScheme=default_8MB,CDCOnBoot=default,USBMode=hwcdc' examples/esp32/esp32-i2c-address-conflict参考:[3] Arduino3.3.12 Wire:编号控制器、begin和事务超时[4] Arduino3.3.12 Wire头:TwoWire接口与Wire1声明[6] Sensirion SHT4x1.1.2:实例总线、序列号与高精度读取[7] Sensirion Core0.7.3:帧长与CRC错误返回[8] 指定DevKitC-1 v1.1排针、桥USB和N8R8限制
完整程序让每路拥有自己的驱动、串号和错误
每个Channel持有自己的Sensirion驱动与State,只在自己的Wire对象上begin。总线初始化失败下一轮再试;尚无有效serial时先serialNumber,成功后才测量。总线begin失败的-1是本应用哨兵,输出app_detail而不冒充库返回码。ID_ERROR不冒用上次身份,READ_ERROR会清serial_known,恢复后先重读串号;因此换模块后看到RECOVERED也要核serial是否改变。
固定驱动高精度命令为0xFD,等待10ms后读取;Core验证数据字CRC,错误不进入accept。每轮浮点初始化NAN,失败时当前T/RH仍nan,串号未知打印NA,另保留实际error和driver_detail。程序不拿上一次好温湿度再次标成新样本,也不启动加热器。
采样每2000ms一轮,A调用完才执行B,是顺序而非同步。两条硬件总线/驱动隔离地址和部分电气错误,不会隔离共享电源、同一个主线程或程序崩溃;A耗时会推迟B。20ms是Wire事务超时设置,不是整个loop的20ms实时保证。没有其他任务或ISR并发访问总线。
// SPDX-License-Identifier: MIT
// S09: two fixed-address SHT40 modules on two separate S3 I2C controllers.
#include <Arduino.h>
#include <Wire.h>
#include <SensirionI2cSht4x.h>
#include "channel_state.h"
struct Channel {
const char* label;TwoWire* bus;int sda,scl;SensirionI2cSht4x driver;dual_i2c::State state;
bool bus_ready=false,serial_known=false;uint32_t serial=0;
Channel(const char* n,TwoWire& w,int d,int c):label(n),bus(&w),sda(d),scl(c){}
};
Channel a("A",Wire,8,9),b("B",Wire1,17,18);
constexpr uint32_t PERIOD_MS=2000;uint32_t last_cycle=0;
static bool begin_bus(Channel& c){
bool ok=c.bus->begin(c.sda,c.scl,100000);c.bus->setTimeOut(20);
c.driver.begin(*c.bus,SHT40_I2C_ADDR_44);return ok;
}
static void report(Channel& c){
char identity[12]="NA";if(c.serial_known)snprintf(identity,sizeof identity,"%08lx",(unsigned long)c.serial);
const uint32_t now=millis();
Serial.printf("channel=%s bus=%s sda=%d scl=%d address=0x44 serial=%s uptime_ms=%lu attempts=%lu good=%lu bad=%lu streak=%u state=%s error=%d T_C=%.2f RH_raw=%.2f RH_pct=%.2f clipped=%u good_age_ms=%lld\n",
c.label,c.label[0]=='A'?"I2C0":"I2C1",c.sda,c.scl,identity,(unsigned long)now,(unsigned long)c.state.attempts,(unsigned long)c.state.good,(unsigned long)c.state.bad,c.state.consecutive_bad,dual_i2c::name(c.state.status),c.state.error,c.state.temperature,c.state.rh_raw,c.state.rh,c.state.clipped,(long long)c.state.age(now));
if(c.state.status==dual_i2c::Status::BUS_ERROR)Serial.printf("channel=%s app_detail=bus_begin_failed; retry next cycle\n",c.label);
else if(c.state.error){char detail[96];errorToString(c.state.error,detail,sizeof detail);Serial.printf("channel=%s driver_detail=%s\n",c.label,detail);}
}
static void sample(Channel& c){
if(!c.bus_ready)c.bus_ready=begin_bus(c);
if(!c.bus_ready){c.serial_known=false;c.state.fail(dual_i2c::Status::BUS_ERROR,-1);report(c);return;}
if(!c.serial_known){
uint32_t serial=0;int16_t error=c.driver.serialNumber(serial);
if(error){c.state.fail(dual_i2c::Status::ID_ERROR,error);report(c);return;}
c.serial=serial;c.serial_known=true;
}
float t=NAN,rh=NAN;int16_t error=c.driver.measureHighPrecision(t,rh);
if(error){c.serial_known=false;c.state.fail(dual_i2c::Status::READ_ERROR,error);}
else if(!c.state.accept(millis(),t,rh))c.serial_known=false;
report(c);
}
void setup(){
Serial.begin(115200);a.bus_ready=begin_bus(a);b.bus_ready=begin_bus(b);last_cycle=millis();
Serial.println("S09 core3.3.12 SHT4x1.1.2 Core0.7.3; A Wire0 SDA8/SCL9, B Wire1 SDA17/SCL18, each100kHz/0x44; sequential not simultaneous.");
}
void loop(){
const uint32_t now=millis();
if(uint32_t(now-last_cycle)>=PERIOD_MS){last_cycle=now;sample(a);sample(b);}
delay(1);
}
- 做到这里,你应该看到
- 能把一次错误归到具体通道、阶段、代码和身份,而不是只看最后一个浮点数。
参考:[3] Arduino3.3.12 Wire:编号控制器、begin和事务超时[4] Arduino3.3.12 Wire头:TwoWire接口与Wire1声明[6] Sensirion SHT4x1.1.2:实例总线、序列号与高精度读取[7] Sensirion Core0.7.3:帧长与CRC错误返回
错误清当前值,恢复必须等自己的新好样本
channel_state.h不依赖Arduino,真实固件和主机检查共用它。WAITING没有成功样本时good_age_ms=-1;首次即成功为VALID,任一故障后首个新好样本RECOVERED(含之前未成功过),下一次转VALID。错误保留累计good/bad、连续streak和上次成功年龄,但清当前温湿度为NAN。
湿度保留RH_raw,展示RH_pct裁边0…100并给clipped,不能把裁边说成更准。uint32差值能处理正常回绕,但若连续缺少成功值跨过整个计数周期,年龄不再是完整离线时长。计数和年龄只是诊断字段,不是器件自检或长期可靠性认证。
主机实际检查11组同源输入:初始/正常、错误清值、通道状态隔离、恢复、两端RH裁边、NaN、计数、ID/BUS错误、年龄回绕和连续错误饱和。这没有执行I²C、真实CRC坏帧、电平或调度;图中的状态箭头也不是波形或物理恢复结果。
// SPDX-License-Identifier: MIT
#pragma once
#include <stdint.h>
#include <math.h>
namespace dual_i2c {
enum class Status { WAITING, BUS_ERROR, ID_ERROR, READ_ERROR, INVALID, VALID, RECOVERED };
inline const char* name(Status s) {
switch(s){case Status::WAITING:return "WAITING";case Status::BUS_ERROR:return "BUS_ERROR";case Status::ID_ERROR:return "ID_ERROR";case Status::READ_ERROR:return "READ_ERROR";case Status::INVALID:return "INVALID";case Status::VALID:return "VALID";case Status::RECOVERED:return "RECOVERED";}return "UNKNOWN";
}
struct State {
Status status=Status::WAITING;uint32_t attempts=0,good=0,bad=0,last_good=0;uint16_t consecutive_bad=0;
bool had_fault=false,seen=false,clipped=false;int16_t error=0;
float temperature=NAN,rh_raw=NAN,rh=NAN;
void fail(Status why,int16_t code){
++attempts;++bad;if(consecutive_bad<65535)++consecutive_bad;status=why;error=code;had_fault=true;
temperature=NAN;rh_raw=NAN;rh=NAN;clipped=false;
}
bool accept(uint32_t now,float t,float h){
if(!isfinite(t)||!isfinite(h)){fail(Status::INVALID,0);return false;}
++attempts;++good;status=had_fault?Status::RECOVERED:Status::VALID;error=0;had_fault=false;seen=true;last_good=now;consecutive_bad=0;
temperature=t;rh_raw=h;rh=h<0?0:h>100?100:h;clipped=rh!=rh_raw;return true;
}
int64_t age(uint32_t now)const{return seen?int64_t(uint32_t(now-last_good)):-1;}
};
}
参考:[2] Sensirion SHT4x V6.4:出厂地址料号、CRC、命令与换算[6] Sensirion SHT4x1.1.2:实例总线、序列号与高精度读取[7] Sensirion Core0.7.3:帧长与CRC错误返回
每条总线都做一次已有样本后的断线恢复
正常时保存两路各10周期,核各自bus/pins/0x44/serial和good计数;不能只因两个温度很接近就认定读到了两块。断电在各模块SDA支路预装低压SPST,上电有好样本后只打开A,记录A的ID_ERROR/READ_ERROR与当前nan,观察B后续是否仍在尝试、实际周期是否变长。
闭合A后等自己的新好样本RECOVERED,核serial与随后VALID,再对B重复;各自bad计数应对应各自试验,真实错误码以现场为准。不要带电拔VCC/GND,也不要把冷启动缺模块代替运行后断线。另做A缺/B连与B缺/A连冷启动,区分控制器初始化成功和器件应答。
把日期、板修订、依赖、条件、通道、serial、序号、状态、错误、年龄与结果填入CSV,不填网络示例值。本站无这套器件的扫描、读数、断线或恢复记录,CSV只有表头;温湿度精度、摆放与气流影响另按S01方法记录。
- 做到这里,你应该看到
- A故障与B故障有分开的原始日志、条件和恢复检查,不预填“完全不受影响”。
参考:[1] Adafruit PID4885:地址不可改、3V输出、已有10k上拉[2] Sensirion SHT4x V6.4:出厂地址料号、CRC、命令与换算[3] Arduino3.3.12 Wire:编号控制器、begin和事务超时[8] 指定DevKitC-1 v1.1排针、桥USB和N8R8限制
两路同错先查共享处,一路同错再查该支路
两路都错误先核桥串口是否真在运行S09、3V3/GND、供电稳定和是否把QT口又接回一起;一路持续ID_ERROR/READ_ERROR先断电核那一路SDA/SCL、开关、短线和模块SKU。保留完整driver_detail,再恢复最小接线/已锁版本,不随机改地址或同时换库。
闭合开关后等下一轮;极端总线/供电故障可断电重启恢复,本例不承诺自动清总线或硬实时期限。编译、同源状态检查与这些物理诊断分开验收。完成这篇后可接S08的数据质量规则或继续记录/联网,但不能让平滑或网络层把某一路失效藏起来。
参考:[1] Adafruit PID4885:地址不可改、3V输出、已有10k上拉[3] Arduino3.3.12 Wire:编号控制器、begin和事务超时[6] Sensirion SHT4x1.1.2:实例总线、序列号与高精度读取[7] Sensirion Core0.7.3:帧长与CRC错误返回[8] 指定DevKitC-1 v1.1排针、桥USB和N8R8限制
资料来源与版本说明
实读厂家固定地址说明、SHT4x V6.4、NXP总线手册及固定core/驱动实现;实际构建和同源纯状态输入与物理试验分开。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。