• 正文
  • 相关推荐
申请入驻 产业图谱

给ESP8266接一个温湿度传感器

9小时前
64
加入交流群
扫码加入
获取工程师必备礼包
参与热点资讯讨论
    • ESP 8266没有硬件I2C,怎么把 AHT20 跑起来?• SDA接错了引脚,AI居然也对接上了!

前情提要

第一篇到第三篇用的是一块吃灰的 NodeMCU(ESP8266 / 4MB Flash)。到第三篇结束时,它能自己开热点配网、连路由器、网页控 LED 和 4 路继电器、OTA 传 .mpy、开机 UDP 广播 IP,还配了个微信小程序。
本来不打算折腾了,但是买的 AHT20 温湿度模块到货了,先在这块板子跑一下吧,不然还是有点遗憾。

AHT20 是 I2C 接口,测温度 -4085°C、湿度 0100%RH,价格十块出头。它有两个兄弟常被拿来替代:SHT30(精度更好、贵一些)、DHT11(单总线、便宜、精度差)。选 AHT20 的理由是它同时有 I2C 和出厂校准,接线只有四根。


先算这块板子还剩哪两个脚

8266 的引脚不能凭印象挑。这块 NodeMCU 上已经分出去的东西:

GPIO 板上用途 能不能给 I2C
0 空(D3),有内部上拉 可以
1 UART0 TX,就是 COM10 的日志口 不能,占了看不到调试信息
2 板载 LED,低电平点亮 能用,但通信期间灯会一直闪
3 UART0 RX 不能
4 继电器第 1 路(D2) 不能
5 继电器第 2 路(D1) 不能
6 ~ 11 接 SPI Flash 碰了不启动
12 继电器第 3 路 不能
13 空(丝印 MTCK) 可以
14 继电器第 4 路 不能
15 板上有 10k 下拉电阻 不能做 SCL,上升沿爬不起来
16 RTC 域,没有内部上拉 有条件可用

这里有个容易踩的地方:网上关于 NodeMCU 接 I2C 的教程,几乎都写 SCL = D1 = GPIO5、SDA = D2 = GPIO4。那是默认板子上这两个脚是空的。这块板子的 5 和 4 已经给了继电器第 1、2 路,照抄教程的结果是接线那一刻继电器就开始动。

所以第一版方案定成 SCL = GPIO13、SDA = GPIO0:两个都不在继电器占用的四个脚里,GPIO0 有内部上拉,GPIO13 空着没接东西。

方案听着合理,但后面第五节的结论是 SDA 实际上插在了 GPIO16 上。先记着这件事。


8266 没有硬件 I2C

这一点和 C3 不一样,值得先讲清楚,因为它决定了后面所有的行为。

ESP8266 芯片内部没有 I2C 外设。machine.I2C 在这块板子上不存在,能用的只有 machine.SoftI2C——拿两个普通 GPIO 拉时序软件模拟出来的。三个直接后果:

一,引脚可以随便挑。 不需要找"硬件 I2C 脚",只要不是特殊功能脚(串口、Flash、带固定上下拉的)都能当 SCL/SDA。这也是为什么第一节能自由改到 13 和 0。

二,速率受 CPU 和中断影响。 硬件 I2C 的时序由外设保证,软件模拟的靠 sleep_us 卡时间,WiFi 中断一插进来就可能被拉长。所以 100kHz 未必稳,跑不通要往下降。

三,上拉电阻必须真的存在。 I2C 是开漏总线,高电平靠上拉拉起来。8266 内部上拉大约 30~100kΩ,偏弱,配上线上电容之后上升沿变得很缓,400kHz 下肯定读错。AHT20 模块自带 4.7k 上拉,正好补上这个缺口。

第二、三点合起来解释了一件事:同一份代码在 C3 上直接能跑(C3 有硬件 I2C、内部上拉也够),搬到 8266 上要重新调参数。这不是代码质量问题,是两个芯片的结构差异。


驱动里三个不明显的决定

sens.py 一百多行,其中三个决定值得单独说,因为它们都是被硬件特性逼出来的。

第一个:读数缓存 3 秒。

AHT20 每次测量要发命令、等 80ms、再读数据。这 80ms 是芯片内部转换时间,改不了。而网页每 5 秒拉一次 /api/status,如果每次都真读一次总线,页面响应就会固定多出近 100ms。

加一层 3 秒缓存后,多个请求共用同一次读数:

CACHE_MS = 3000

if not force and _t is not None and time.ticks_diff(now, _last) < CACHE_MS:
    return (_t, _h)

温湿度这种物理量,3 秒内的变化小于传感器分辨率,缓存不损失信息。

第二个:连续失败 5 次就停用。

传感器线松了、模块坏了,readfrom 会抛 OSError。如果不处理,之后每个请求都要等一次 80ms 超时,页面会一顿一顿,而且看不出原因。

except Exception as e:
    _fail += 1
    if _fail >= 5:
        _ok = False
        print("[SENS] lost after %d failures: %s" % (_fail, e))

停用之后直接返回缓存值,页面不卡,串口留一行日志说明什么时候掉的。

第三个:20 bit 数据的拆法。

这是手写 AHT20 驱动最容易写错的地方。读回来 7 个字节,格式是这样:

d[0]              状态字节,bit7 = busy
d[1] d[2]         湿度高 16 位
d[3] 的高 4 位     湿度低 4 位
d[3] 的低 4 位     温度高 4 位
d[4] d[5]         温度低 16 位

关键在 d[3] 被两个量共用:高 4 位是湿度的最低 4 位,低 4 位是温度的最高 4 位。

hr = ((d[1] << 16) | (d[2] << 8) | d[3]) >> 4
tr = ((d[3] & 0x0F) << 16) | (d[4] << 8) | d[5]

两个地方容易漏:湿度那个不右移 4 位,结果会大 16 倍;温度那个忘了 & 0x0F,就会把湿度的高 4 位当成温度的最高位,读出一个几万度的值。

换算按手册给的两个公式:

t = tr * 200.0 / 1048576.0 - 50.0      # 200°C 量程,-50 偏移
h = hr * 100.0 / 1048576.0             # 0~100%RH

1048576 是 2 的 20 次方。这个数字在第十节还会再出现一次,用法不一样。


扫不到:四轮排查,全空

固件烧上去,第一次上电,串口打出来是这样:

[SENS] SCL=13 SDA=0 -> []

scan() 返回空列表,总线上没有任何器件应答。到这里有两种可能:引脚选错了,或者线没接好。分不清,只能一层层往下排。

第一轮,用下拉判断"有没有接东西"。

思路是给 SDA 配上内部下拉,如果模块接上了,模块自带的上拉会把线拉住高电平;没接的话就是低。

写下去直接报错:Pin.PULL_DOWN 在 8266 上不存在。8266 只有内部上拉,没有内部下拉。这条路走不通。

第二轮,用高阻输入判断外部上拉。

换成输入模式读电平:接了模块的话,模块的 4.7k 上拉会让线读到 1;悬空的话应该飘。

结果是除了 GPIO15 读到 0(板载 10k 下拉,和引脚图一致),其它脚全读 1。因为 8266 的 Pin.IN 默认就带上拉,读到的 1 是自己拉的,不是模块拉的。这个测法在 STM32 上有效,在这里无效。

第三轮,降速重扫。

怀疑是软件 I2C 时序偏快。10kHz、50kHz、100kHz 三个速率 × 四种引脚接法,共 12 次扫描,全部为空。

第四轮,手工 bit-bang。

SoftI2C.scan() 只告诉你"扫到/没扫到",中间的过程完全看不到。于是绕开它,用最原始的时序一位一位发,每步延时 100μs(约 5kHz,比标准 I2C 慢 20 倍),把每个 ACK 位的实际电平打出来:

[BB] 引脚 SCL=13 SDA=0  延时 100us
[BB] 1) 空转检查
[BB]    SCL 输出低 -> SDA 读到 1     (两线没短路)
[BB]    SDA 输出低 -> SCL 读到 1
[BB]    都释放后 SDA=1 SCL=1
[BB] 2) 逐个地址试 ACK
[BB]   0x38 -> ACK=1  无应答
[BB]   0x39 -> ACK=1  无应答
[BB]   0x44 -> ACK=1  无应答
[BB]   0x45 -> ACK=1  无应答
[BB]   0x3C -> ACK=1  无应答
[BB] 3) 对 0x38 发 AHT20 测量命令
[BB]   ACK 链: 1 1 1 1

这三段信息量不同:

    • 第一段证明了引脚本身是好的:能拉低、能释放、两根线不短路。• 第二段证明了 5kHz 下也没有任何地址应答,可以基本排除"时序偏快"。• 第三段是发完整命令序列,四个字节全部无应答,说明不是地址问题。

到这里,代码和时序都能排除。剩下的可能只有一个:线没接在我以为的那两个脚上。


不猜了,72 种组合一次跑完

"接到别的脚上了"这件事没法靠猜。8266 上可用的 GPIO 一共 9 个(排除串口 1/3 和 Flash 6~11),两两组合 72 种,全扫一遍只要十几秒。

PINS = (0, 2, 4, 5, 12, 13, 14, 15, 16)

for scl in PINS:
    for sda in PINS:
        if scl == sda:
            continue
        i2c = machine.SoftI2C(scl=machine.Pin(scl), sda=machine.Pin(sda),
                              freq=50000, timeout=1000)
        found = i2c.scan()

(扫描期间会把继电器那四个脚也拉来拉去,接的如果是机械继电器,能听到轻微的动作声。属正常。)

第一次跑出来 9 个"命中",全部集中在 SDA=15 和 SDA=16 上。看着像找到设备了,但其中一条扫描结果暴露了问题:地址列表从 0x08 一路排到 0x77,一个不落。

一次扫出 0x08 到 0x77 全部地址。这不是几十个设备挂在总线上,这是总线被拉死在低电平。

原理不难:主机每发完 8 位会释放 SDA,读第 9 个时钟的电平作为 ACK。如果 SDA 一直是 0,那么不管从机在不在,读到的永远是"有人拉低",于是每个地址都算应答。

加一条判据就能过滤掉:

if len(found) > 8:      # 一次扫出 8 个以上,是总线短地,不是设备
    fake += 1
    continue

重跑:

[ALL] pins = (0, 2, 4, 5, 12, 13, 14, 15, 16)
[ALL] HIT  SCL=13 SDA=16 -> ['0x38'] (总 1 个地址)
[ALL] 试了 72 种组合,真命中 1 个,总线拉低的假象 8 个
[ALL] done

模块的 SDA 实际接在 GPIO16 上,也就是排针最后一格,不是我说的 GPIO0。

为什么假象全在 15 和 16 上,也能解释清楚:GPIO15 板上有 10k 下拉,做 SDA 时被钉在低电平;GPIO16 在 RTC 域,没有内部上拉,如果没有外部上拉同样钉在低。这两个脚"看起来能扫到东西",其实是自己把总线拉死了。

同一个现象在别的场景里出现过:一根 SDA 对地短路,扫描会返回同样的一长串地址。把"一次扫出超过 8 个地址"当成固定判据,比逐个去查电路快得多。


读一次真数据

换到实测引脚,先单读几次确认模块是活的:

AHT20  @ 0x38  calibration ok
26.86 °C   33.90 %RH
26.84 °C   33.86 %RH
26.86 °C   33.77 %RH

三次一致,湿度在小幅变化。说明是真的在测量转换,不是返回一个固定值。

(这是接好线后第一次读到的值。板子放了一段时间之后室内温度升到 28°C 附近,后面的采样表是那时的数据。)

然后改配置:

SENSOR_ENABLE = True
SENSOR_SCL = 13           # 丝印 GPIO13 MTCK
SENSOR_SDA = 16           # 丝印 GPIO16(排针最后一格)
SENSOR_FREQ = 50000       # GPIO16 无内部上拉,50kHz 实测稳定
SENSOR_ADDR = 0           # 0 = 自动探测(0x38=AHT20,0x44=SHT3x)

重新部署,串口:

[SENS] SCL=13 SDA=16 -> ['0x38']
[SENS] AHT20 at 0x38 ready

八次连续采样,间隔 3.2 秒(大于缓存时间,每次都是真读):

次数 温度 湿度 响应
1 28.1°C 31.3% 151ms
2 28.1°C 31.2% 239ms
3 28.1°C 31.1% 165ms
4 28.1°C 31.0% 204ms
5 28.1°C 30.9% 150ms
6 28.0°C 31.0% 135ms
7 28.0°C 30.9% 161ms
8 28.0°C 30.9% 145ms

温度从 28.1 缓慢降到 28.0,湿度从 31.3 降到 30.9,变化幅度都在传感器的分辨率量级上,符合室内环境的正常波动。

响应时间 135~239ms 里,约 80ms 是 AHT20 的固有转换时间,剩下的是 HTTP 往返加软 I2C 一位一位搬 7 个字节的开销。这也是为什么必须做缓存——不做的话每次请求都要付这 130ms 以上。


GPIO16 能用,但不理想

GPIO16 在 ESP8266 上属于 RTC 域,限制很清楚:没有内部上拉、不支持中断。它最常用的场景是 deep-sleep 唤醒,不是当普通 IO。现在能正常工作,靠的是 AHT20 模块自带的 4.7k 上拉把线拉起来,频率也降到了 50kHz。

这意味着它对布线比较敏感。线超过 15cm、或者换一个没带上拉的传感器,就可能又不通了。

想更稳的话,把 SDA 那根线从排针最后一格挪到 GPIO0(左侧第 12 格),config.py 里 SENSOR_SDA 改成 0,别的都不用动。这次没改,因为现场能用就先不折腾,但这是个已知的隐患,记在这里。


网页这边:没有传感器就给 null

接口上做了一个小设计:sensor 字段在没传感器时给 null,不是给 0。

{"led":0,"uptime":600,"mem":8928,"rssi":-57,
 "ip":"192.168.151.67","ssid":"TEST","relay":[0,0,0,0],"sensor":null}

接了之后:

"sensor":{"kind":"AHT20","t":27.9,"h":30.8}

网页上的温湿度卡片初始是隐藏的,只有拿到 sensor 才显示出来。这样区分得开三种情况:没接传感器(null)、接了但读失败(null + 串口日志)、读数真的是 0(0.0)。

如果统一给 0,页面上会出现"0°C 0%RH"这种看着像设备坏了、其实只是没接的状态。


内存:一个传感器占 3.5KB

8266 的堆一直是紧张的,前三篇里多次出现"加功能之前先算这一笔"。这次的实际数字:

状态 free
装 sens 模块之前,开机后 15520 B
装之后,刚启动 12016 B
同一块板子跑 10 分钟后 8928 B

第一行到第二行差 3.5KB,是 .mpy 代码、I2C 缓冲、几个缓存变量。

第三行值得单独看:什么都没改,只是跑了一会儿,free 就从 12016 掉到 8928。8266 的 WiFi 协议栈和 lwIP 会按需申请缓冲,加上长时间运行产生的堆碎片,gc.mem_free() 会持续下降。所以这个数字是瞬时值,不是稳定值——写代码时按最低点算,别按开机那一刻的数字算。

正因为内存紧,8266 这边必须把 .py 编译成 .mpy 再部署(.mpy 从 flash 读进 RAM 执行,省堆),而 .mpy 的格式有版本号,要和固件对上。

本机原本没装 mpy-cross。装的过程中碰到一个和代码无关的坑:清华镜像走系统代理一直返回 502,换官方 PyPI 直接就下到了:

pip install mpy-cross -i https://pypi.tuna.tsinghua.edu.cn/simple   # 502
pip download mpy-cross                                              # ok

版本下来是 1.29.0.post2,正好对上板子固件的 MicroPython 1.29.0。编译出来的头字节里 ver=6,和板上原有 .mpy 一致。

部署时还有一个反复出现的现象:8266 启动日志会打断上传,报 could not enter raw repl。原因是板子正在跑主循环、串口被占着。重跑一次就行,不用改代码——但要知道这是正常现象,不是坏板。


离线测试:不接硬件也能验一半

传感器只有一块,反复烧板验证很慢。所以先在 PC 上把协议逻辑验穿:用假的 I2C 对象注入 7 字节数据帧,跑 tools/test_sens_pc.py。

通过 24 项,失败 0 项

覆盖的内容:

    • • 换算正确性(0°C/0%RH、-20°C、-40°C、45.5°C/99%RH、100%RH 这些点)• 校准命令

0xBE 0x08 0x00

    •  和测量命令

0xAC 0x33 0x00

     的字节顺序• 3 秒内不重复访问总线• busy 位为 1 时不更新读数• SHT30 分支的 CRC8 校验,CRC 错了不用脏数据• 连败 5 次后自动停用

写测试桩的时候自己踩了一个边界,值得单独讲:

# 错的做法:把 2 的 20 次方当满量程
hr = int(h / 100 * 1048576) & 0xFFFFF

100% 湿度按这个算出来是 1048576,& 0xFFFFF 截断之后变成 0。测试报"100% 那条读回 0",查了半天以为是驱动溢出。

实际原因是:20 位能表示的最大值是 1048575,不是 1048576。驱动里用 1048576 做除数是对的(量程是 2^20,端点取不到),但生成测试数据时不能用 2^20 当最大值——一个数在两个方向上的用法不一样,这是典型的差一错误。

改掉之后 24 项全过。


现在它长这样

esp8266-nodemcu-demo/
├── src/
│   ├── config.py       引脚、传感器、OTA 白名单
│   ├── main.py         GPIO → 传感器 → WiFi/AP → HTTP 主循环
│   ├── board.py        板载 LED(8266 上没被冻结,不用改名)
│   ├── gpio.py         D0~D8 上电归低
│   ├── relay.py        四路继电器
│   ├── sens.py         AHT20 / SHT3x 自动探测,软 I2C + 3 秒缓存
│   ├── pages.py        模板渲染 + form 解码
│   ├── web.py          HTTP 服务、路由、OTA
│   └── sta.tpl / ap.tpl / ota.tpl / style.css
└── tools/
    ├── deploy.py       编译 .mpy 并上传
    ├── find_board.py   UDP 找板子
    ├── probe_i2c_all.py      72 种组合穷举
    ├── probe_i2c_bitbang.py  手工时序,打每个 ACK
    └── test_sens_pc.py       离线协议测试,24 项

新增的两个接口:

用途 请求
状态(含温湿度) GET /api/status → sensor 字段
只读温湿度 GET /api/sensor

页面上的温湿度卡片在 LED 卡片下面,两个 32px 的大字显示 27.9 °C 和 30.8 %RH。

附现在的页面:

web页面

附:这篇的坑(7 个)

# 现象 真正原因 解法
1 按常见教程准备接 D1/D2 这块板子的 GPIO5/4 已分给继电器第 1、2 路 接线前先数一遍已被占用的脚
2 Pin.PULL_DOWN 报错 8266 只有内部上拉,没有下拉 只能靠外部电路判断
3 高阻输入判断外部上拉,全部读 1 Pin.IN 在 8266 上默认带上拉 这个测法在 8266 上无效
4 扫描返回 0x08~0x77 全部地址 SDA 被钉在低电平,每个地址都"ACK" 判据:一次扫出 >8 个地址即为假象
5 SCL=13 SDA=0 一直扫不到 SDA 实际接在 GPIO16(排针最后一格) 穷举 72 种组合定位
6 100% 湿度测试读回 0 测试桩用 2^20 当 20 位满量程,截断溢出 数据生成用 1048575,驱动除数仍用 1048576
7 pip install mpy-cross 一直 502 镜像走系统代理 换官方 PyPI,下到 1.29.0.post2

前四篇的坑(复位时序、.mpy 版本号在低字节、frozen 模块覆盖、LED 极性、请求没到达板子等)不重复了,见连载第一到第四篇。


本文所有日志、读数、内存数字均来自真实环境:Windows + CP210x@COM10 / 固件 MicroPython 1.29.0 / NodeMCU ESP8266 4MB Flash / AHT20 @ I2C 0x38(SCL=GPIO13, SDA=GPIO16, 50kHz)。

相关推荐

专注嵌入式Linux应用开发,C/C++技术交流;Qt编程技术交流。