ESP32-1-温湿度计(Build With Template)
- 1 ESP32:0-MacOs中ESP32开发环境构建
- 2 ESP32-1-温湿度计(Build With Template)
项目模板与Cookiecutter
模板构建
当然,我是让Claude代劳了,但是我还是了解了一下Cookiecutter基本的使用方法和ESP32的常用项目框架,不然就算Claude写完了我也看不懂,也不会用啊。
首先,需要定义项目的模板,以下是我的项目结构及对应文件的作用
├── {{cookiecutter.project_name}}
│ ├── boot.py # 启动初始化:按键检测、决定是否跳过配网
│ ├── config.json # 持久化配置:WiFi账号密码、设备参数
│ ├── lib # 核心业务代码
│ │ ├── __init__.py
│ │ ├── ble_ctrl.py # 蓝牙/ESP-NOW 离线控制
│ │ ├── hardware.py # 硬件抽象层
│ │ ├── logger.py # 分级日志
│ │ ├── utils.py # 工具函数
│ │ ├── web_server.py # Web服务与API
│ │ └── wifi_mgr.py # 网络管理(STA/AP)
│ ├── main.py # 业务主入口
│ ├── setup # 配网页面
│ │ └── index.html
│ ├── tests # PC端单元测试
│ │ └── test_hardware.py
│ └── www # Web控制台页面
│ ├── app.js
│ ├── index.html
│ └── style.css
└── cookiecutter.json # 脚手架变量配置
其中 {{cookiecutter.project_name}} 目录下就是脚手架的内容,也就是通过命令创建出来后的项目结构。最重要的是 cookiecutter.json ,定义了项目创建时的交互式询问变量。Key为询问的题目,Value为默认值,当然这里use_rt_thread和board我认为是没什么作用的,但是生成了我也就留在这里了。
{
"project_name": "my-esp-project",
"target": ["esp32", "esp8266"],
"use_rt_thread": ["yes", "no"],
"board": "ESP32 Dev Module",
"description": "ESP32/ESP8266 MicroPython project",
"author": "Lobster Lord"
}
构建好这个工程后,推送到Github上即可
从脚手架创建项目
在脚手架创建项目之前,需要执行 pipx install cookiecutter 来安装命令。具体安装命令如下:
❯ cookiecutter git@github.com:ZaNksC/feedfund-esp-template.git
[1/6] project_name (my-esp-project): feedfund-esp-trh
[2/6] Select target
1 - esp32
2 - esp8266
Choose from [1/2] (1):
[3/6] Select use_rt_thread
1 - yes
2 - no
Choose from [1/2] (1): 2
[4/6] board (ESP32 Dev Module): esp32-c3-supermini
[5/6] description (ESP32/ESP8266 MicroPython project): 基于ESP32 C3的温湿度计
[6/6] author (Lobster Lord):
总之,就是一个相对来说比较简单的工具,免去了复制粘贴的工作,并且可以保留一些项目的公共模块(比如OTA、配网页面)
温湿度计
依旧分为硬件电路与软件编写两部分。
硬件电路
硬件电路其实非常简单,毕竟只有一个设备。我使用温湿度传感器是SHT31,售价十来块,精度到位,使用I2C协议通信。所以,物理电路需要安装I2C通信协议的连接方式来。
首先,SHT31,我购买的是模块,并且偷懒的买了已经好焊接排针的款式,通常这个模块都会引出4针,分别为 VCC 、GND 、 SCL 、SDA ,代表电源正极,地线与I2C通信线。直接找4根杜邦线插上去就完事了。
其次是MCU,MCU选用的是GPIO5和6,作为I2C通信的总线,也就是SCL和SDA。还有3V3引脚与GND引脚,作为传感器的供电方。
仿真软件中找不到SHT31,用相同协议的DHT22代替一下。连线方式如图所示,红线表示VCC,白线为SDA,绿线为SCL,黑线为GND。(实际上I2C协议是需要一个上拉电阻的,但是我买的模块里面自带了,所以就没有放上拉电阻)

软件开发
软件开发直接让AI代劳(这边实测千问水平完全堪忧,用GPT一次过,千问引导了10轮一直输出编译都过不去的垃圾)
from machine import Pin, SoftI2C
import time
i2c = SoftI2C(scl=Pin(6), sda=Pin(5), freq=400000)
devices = i2c.scan()
if devices:
print("发现 I2C 设备:", [hex(d) for d in devices])
else:
print("未检测到任何 I2C 设备,请检查接线!")
def read_sht31():
SHT31_ADDR = 0x44
CMD_MEASURE = bytearray([0x2C, 0x06])
try:
i2c.writeto(SHT31_ADDR, CMD_MEASURE)
time.sleep(0.02)
data = i2c.readfrom(SHT31_ADDR, 6)
print("原始数据:", [hex(x) for x in data])
# 温度
raw_temp = (data[0] << 8) | data[1]
temp = -45 + 175 * raw_temp / 65535
# 湿度
raw_hum = (data[3] << 8) | data[4]
humidity = 100 * raw_hum / 65535
return round(temp, 2), round(humidity, 2)
except OSError as e:
print("读取失败:", e)
return None, None
# 4. 主循环
while True:
temp, humidity = read_sht31()
if temp is not None:
print('温度: {}°C, 湿度: {}%'.format(temp, humidity))
time.sleep(2) # 每 2 秒读取一次
连上板子,点击执行,可以看到控制台输出。后面为用手捏住了传感器,可以明显看到温度升高了。
发现 I2C 设备: ['0x44']
原始数据: ['0x6b', '0x7c', '0x4b', '0x5f', '0xb8', '0xf9']
温度: 28.48°C, 湿度: 37.39%
原始数据: ['0x6b', '0x87', '0x23', '0x5f', '0xd6', '0x5d']
温度: 28.51°C, 湿度: 37.44%
原始数据: ['0x6b', '0x7c', '0x4b', '0x5f', '0x77', '0x90']
温度: 28.48°C, 湿度: 37.29%
原始数据: ['0x6b', '0x87', '0x23', '0x60', '0x4', '0x10']
温度: 28.51°C, 湿度: 37.51%
原始数据: ['0x6b', '0x77', '0xa1', '0x5f', '0x11', '0x8d']
温度: 28.46°C, 湿度: 37.14%
原始数据: ['0x6b', '0x7c', '0x4b', '0x63', '0x8f', '0xad']
温度: 28.48°C, 湿度: 38.89%
原始数据: ['0x6c', '0xbd', '0x93', '0x80', '0x15', '0x14']
温度: 29.33°C, 湿度: 50.03%
原始数据: ['0x6f', '0x8a', '0xec', '0x9e', '0x8', '0x18']
温度: 31.25°C, 湿度: 61.73%
原始数据: ['0x70', '0xc6', '0x5b', '0xa6', '0x97', '0x8a']
温度: 32.09°C, 湿度: 65.08%
原始数据: ['0x71', '0x79', '0x3e', '0xa9', '0x72', '0x27']
温度: 32.57°C, 湿度: 66.19%
原始数据: ['0x71', '0xf7', '0x5b', '0xaa', '0xad', '0x20']
温度: 32.91°C, 湿度: 66.67%
原始数据: ['0x72', '0x4c', '0x23', '0xab', '0x80', '0x1e']
温度: 33.13°C, 湿度: 66.99%
再回过头去大概看一下代码。核心代码其实就i2c设备的发现和数据的读取。至于为什么是 0x44 和读取温湿度的方法,翻一下传感器的文档就知道了。(感谢嘉立创,找到传感器就有文档真的太方便了)
i2c.writeto(0x44, bytearray([0x2C, 0x06])) 表示ESP32侧向I2C总线输入一个寻址到 0x44 的设备,写入 0x2C 0x06 的指令。而 0x2C 0x06 指令,正好对应于文档4.3中说明的测量模式输入指令,其中刚好是使用该模式举例。高精度单次测量。
e.g. 0x2C06: high repeatability measurement with clock stretching enabled
data = i2c.readfrom(0x44, 6) 则表示ESP32从I2C主线向地址为 0x44 的设备读取 6 个字节的数据。6个字节来自于文档4.4,表明了单词测量的读取规则,2个字节的数据+1个字节的CRC,表示温度;2个字节的数据+1个字节的CRC,表示湿度。
The sensor will acknowledge the reception of the read header and send two bytes of data (temperature) followed by one byte CRC checksum and another two bytes of data (relative humidity) followed by one byte CRC checksum. Each byte must be acknowledged by the microcontroller with an ACK condition for the sensor to continue sending data.
然后是温湿度的计算部分,raw_temp = (data[0] << 8) | data[1] 将读取回来的第1和第2个字节,拼成一个16位无符号数字。 << 8 表示左移8位,也就是变为 xxxxxxxx 00000000 ,然后在按位或第2个字节的数据,而0或0=0,0或1=1,也就是无论怎么样,后8位就是第2个字节,完成了一次拼装。湿度部分同理,拼装的是第4和第5字节,因为第3字节是1和2的校验。当然严格的编码来说,校验时需要使用的,但是这里图方便就没使用了。
至于 temp = -45 + 175 * raw_temp / 65535 和 humidity = 100 * raw_hum / 65535 则来自文档4.12的公式。

总之,如果我自己去翻文档去读的话,大概率没有1个小时是写不出来的。而GPT只用了1分钟就写出来了,并且在他的帮助下,我有了以上对于代码的深刻理解。当然,作为一个具备优良封装意思的软件开发者,这个方法后面对我来说就是个黑盒了。只要没出问题,应该也不会深究内部的原理了,毕竟已经可以用了,不是吗,最多再加一下校验,选择丢弃一些校验无法通过的噪声数据。
最小规模的温湿度计
当然,上面的案例显然只是一个SHT31的实验电路,甚至可以说是非常简单的元件驱动。作为一个温湿度计而言,他至少还应该有个屏幕。所以,接下来的内容就是用TFT屏幕,展示温湿度内容。我用的是一块0.96寸的SPI屏幕做实验,型号为st7735s。
硬件电路
依旧先看硬件电路,为了保持连线的便捷性,更改了一下GPIO的分配。具体分配如下
"i2c_scl": 3,
"i2c_sda": 4,
"display_sck": 5,
"display_mosi": 6,
"display_rst": 7,
"display_dc": 8,
"display_cs": 9,
屏幕还有另外三个引脚,GND接地,然后另外两个引脚都接入电源即可。
另外,我还在GPIO10的引脚上接入了一个按键开关(注意串联电阻,我选的是1K电阻),用于在屏幕熄灭时进行点亮。
软件开发
因为之前一直都是AI直接生成代码的,所以其实VSCode很多提示报错我也就没搭理,包括补全都是不完整的。现在开始搭建一个比较规范框架时,还是需要整理一下的。其实步骤也不难,就下载一个stubs,然后将目录配置到Python的语法服务路径中。
uv run https://raw.githubusercontent.com/Josverl/micropython-stubs/refs/heads/main/setup_micropython_stubs.py
MicroPython-stubs setup script (version 0.1.28) ? Project folder : ./
? Where is the code located in the project? .
? Select static type checker to configure : none
? Select MicroPython stubs package (type to search) : micropython-esp32-stubs
? Add 'typings*' to .gitignore ?: Yes
? Apply configuration and install stubs now? : Yes
* Selected package: micropython-esp32-stubs (1.28.0)
Created .gitignore with typings*
Install command: uv pip install -U micropython-esp32-stubs==1.28.0.* --target /Users/zanks/Documents/03_code/personal/ESP32/typings
Using CPython 3.13.12 interpreter at: /Users/zanks/.cache/uv/environments-v2/82fad5906a61d1c3/bin/python3
Resolved 2 packages in 10.75s
Prepared 2 packages in 2.21s
Installed 2 packages in 19ms
+ micropython-esp32-stubs==1.28.0.post4
+ micropython-stdlib-stubs==1.28.0.post6
Setup complete.然后记住typings目录的具体路径,去VSCode上添加配置,black-formatter是格式化插件,安装一下就行。
"[python]": { "editor.defaultFormatter": "ms-python.black-formatter" },
"python.analysis.diagnosticSeverityOverrides": {
"reportMissingModuleSource": "none"
},
"python.analysis.extraPaths": [
"path_to_typings" # 替换成自己的
], 首先,整一个st7735s的驱动(AI代劳,感谢国色赞助的Qwen-3.8max),当然,看不懂是没关系的,因为我也看不懂。
# 旋转配置:MADCTL + 地址偏移 + 分辨率(0.96" 80x160 常用值,可能需按面板微调)
# rot1/rot3 的 MADCTL 已按本项目实测面板校准:横屏需置 MX 位,否则画面左右镜像。
ROTATION_CONFIG = {
0: {"madctl": 0xC8, "x_offset": 26, "y_offset": 1, "width": 80, "height": 160},
1: {"madctl": 0x68, "x_offset": 1, "y_offset": 26, "width": 160, "height": 80},
2: {"madctl": 0x08, "x_offset": 26, "y_offset": 1, "width": 80, "height": 160},
3: {"madctl": 0xA8, "x_offset": 1, "y_offset": 26, "width": 160, "height": 80},
}
def rotation_config(rotation):
"""按旋转值返回显示配置(madctl/x_offset/y_offset/width/height);非法返回 None。"""
return ROTATION_CONFIG.get(rotation)
def rgb(r, g, b):
"""把 0-255 的 (r,g,b) 转为 RGB565 颜色值。"""
return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)
def swap_rgb565(src, dst):
"""把 src 中 RGB565 像素由 framebuf 小端序交换为大端序写入 dst(每像素两字节对调)。
framebuf.RGB565 按小端存储(低字节在前),ST7735 需要大端(高字节在前),
发送前必须对调;dst 须为与 src 等长的独立 bytearray。
"""
for i in range(0, len(src), 2):
dst[i] = src[i + 1]
dst[i + 1] = src[i]
class ST7735S:
"""0.96 英寸 ST7735S 显示屏驱动(组合 framebuf,提供便捷绘图方法)。"""
def __init__(self, spi, dc, cs, rst=None, bl=None, rotation=0, bgr=True):
"""初始化并上电显示。
:param spi: 已配置的 SPI 对象(模式 0)
:param dc: 数据/命令引脚号(GPIO)
:param cs: 片选引脚号
:param rst: 复位引脚号(可为 None)
:param bl: 背光引脚号(可为 None)
:param rotation: 0-3(默认 0 竖屏 80x160)
:param bgr: True=BGR 颜色序(MADCTL BGR 位),红蓝对调时改 False
"""
import machine
import framebuf
cfg = rotation_config(rotation)
if cfg is None:
raise ValueError("rotation 必须为 0-3,实际: {}".format(rotation))
self.spi = spi
self.width = cfg["width"]
self.height = cfg["height"]
self._madctl = cfg["madctl"]
self._x_offset = cfg["x_offset"]
self._y_offset = cfg["y_offset"]
self.dc = machine.Pin(dc, machine.Pin.OUT)
self.cs = machine.Pin(cs, machine.Pin.OUT)
self.rst = machine.Pin(rst, machine.Pin.OUT) if rst is not None else None
self.bl = machine.Pin(bl, machine.Pin.OUT) if bl is not None else None
if not bgr:
self._madctl &= ~0x08
self.buffer = bytearray(self.width * self.height * 2)
self._swap = bytearray(self.width * self.height * 2)
self.fb = framebuf.FrameBuffer(self.buffer, self.width, self.height, framebuf.RGB565)
self.init_display()
self.backlight(True)
@classmethod
def from_pins(cls, sck, mosi, dc, cs, rst=None, bl=None, rotation=0,
bgr=True, freq=20_000_000, spi_id=1):
"""用引脚号创建 SPI(模式 0)并构造实例的便捷工厂。
:param spi_id: ESP32 上 1=HSPI / 2=VSPI
"""
import machine
spi = machine.SPI(
spi_id,
baudrate=freq,
polarity=0,
phase=0,
sck=machine.Pin(sck),
mosi=machine.Pin(mosi),
)
return cls(spi, dc=dc, cs=cs, rst=rst, bl=bl, rotation=rotation, bgr=bgr)
# ----------------------
# 底层寄存器写
# ----------------------
def _cmd(self, cmd, data=b""):
"""写单条命令,可选跟一段数据(data 为 bytes)。"""
self.dc.value(0)
self.cs.value(0)
self.spi.write(bytes((cmd,)))
if data:
self.dc.value(1)
self.spi.write(data)
self.cs.value(1)
def _window(self, x0, y0, x1, y1):
"""设置地址窗口(CASET/RASET),自动叠加面板偏移。"""
x0 += self._x_offset
x1 += self._x_offset
y0 += self._y_offset
y1 += self._y_offset
self._cmd(0x2A, bytes((x0 >> 8, x0 & 0xFF, x1 >> 8, x1 & 0xFF)))
self._cmd(0x2B, bytes((y0 >> 8, y0 & 0xFF, y1 >> 8, y1 & 0xFF)))
self._cmd(0x2C)
def _reset(self):
if self.rst is None:
return
import time
self.rst.value(1)
time.sleep_ms(50)
self.rst.value(0)
time.sleep_ms(50)
self.rst.value(1)
time.sleep_ms(150)
def init_display(self):
"""上电初始化序列(ST7735S 常用寄存器配置)。"""
import time
self._reset()
self._cmd(0x01) # SWRESET 软件复位
time.sleep_ms(150)
self._cmd(0x11) # SLPOUT 退出睡眠
time.sleep_ms(200)
# 帧率控制
self._cmd(0xB1, bytes((0x01, 0x2C, 0x2D))) # FRMCTR1
self._cmd(0xB2, bytes((0x01, 0x2C, 0x2D))) # FRMCTR2
self._cmd(0xB3, bytes((0x01, 0x2C, 0x2D, 0x01, 0x2C, 0x2D))) # FRMCTR3
self._cmd(0xB4, bytes((0x07,))) # INVCTR
# 电源控制
self._cmd(0xC0, bytes((0xA2, 0x02, 0x84))) # PWCTR1
self._cmd(0xC1, bytes((0xC5,))) # PWCTR2
self._cmd(0xC2, bytes((0x0A, 0x00))) # PWCTR3
self._cmd(0xC3, bytes((0x8A, 0x2A))) # PWCTR4
self._cmd(0xC4, bytes((0x8A, 0xEE))) # PWCTR5
self._cmd(0xC5, bytes((0x0E,))) # VMCTR1
self._cmd(0x20) # INVOFF 反显关闭
self._cmd(0x36, bytes((self._madctl,))) # MADCTL 内存访问控制
self._cmd(0x3A, bytes((0x05,))) # COLMOD 像素格式 16 位
self._cmd(0x13) # NORON 正常显示开
time.sleep_ms(10)
self._cmd(0x29) # DISPON 显示开
# ----------------------
# 公共 API
# ----------------------
def show(self):
"""把 framebuf 内容推送到屏幕(整屏)。"""
self._window(0, 0, self.width - 1, self.height - 1)
swap_rgb565(self.buffer, self._swap)
self.dc.value(1)
self.cs.value(0)
self.spi.write(self._swap)
self.cs.value(1)
def clear(self, color=0x0000):
"""清屏并立即显示。"""
self.fb.fill(color)
self.show()
def backlight(self, on=True):
"""背光开关(未接背光引脚时为空操作)。"""
if self.bl is not None:
self.bl.value(1 if on else 0)
def sleep(self):
"""熄屏省电:关背光(若接线)+ DISPOFF + SLEEPIN;用 wake() 恢复。"""
self.backlight(False)
self._cmd(0x28) # DISPOFF 显示关
self._cmd(0x10) # SLEEPIN 进入睡眠
def wake(self):
"""睡眠唤醒:SLPOUT + DISPON + 开背光;唤醒后需重新 show() 刷新画面。"""
import time
self._cmd(0x11) # SLPOUT 退出睡眠
time.sleep_ms(120)
self._cmd(0x29) # DISPON 显示开
self.backlight(True)
def invert(self, on=True):
"""反显开/关(部分面板需 invert(True) 才能显示正常颜色)。"""
self._cmd(0x21 if on else 0x20) # INVON / INVOFF
# ----------------------
# framebuf 便捷封装(绘制后需调用 show())
# ----------------------
def fill(self, color):
self.fb.fill(color)
def pixel(self, x, y, color):
self.fb.pixel(x, y, color)
def hline(self, x, y, w, color):
self.fb.hline(x, y, w, color)
def vline(self, x, y, h, color):
self.fb.vline(x, y, h, color)
def line(self, x1, y1, x2, y2, color):
self.fb.line(x1, y1, x2, y2, color)
def rect(self, x, y, w, h, color):
self.fb.rect(x, y, w, h, color)
def fill_rect(self, x, y, w, h, color):
self.fb.fill_rect(x, y, w, h, color)
def text(self, s, x, y, color=0xFFFF):
self.fb.text(s, x, y, color)
暂时就不做解释与理解了(主要是我自己也不想看这么多底层代码,学起来很累)。然后就是使用这个驱动来创建一个显示的屏幕了。
from lib.st7735s import rgb
# ----------------------
# 配色(深色底 + 指标强调色)
# ----------------------
COLOR_BG = rgb(6, 10, 18) # 屏幕背景(深海军蓝)
COLOR_BAR = rgb(14, 20, 36) # 状态栏背景
COLOR_BORDER = rgb(40, 48, 66) # 分隔线 / 卡片描边
COLOR_UNIT = rgb(120, 130, 148) # 单位文本(灰)
COLOR_VALUE = rgb(255, 255, 255) # 数值(白,高对比)
COLOR_PLACEHOLDER = rgb(88, 96, 112) # 预留占位符 "--"(灰)
COLOR_TIME = rgb(210, 220, 235) # 时间文本
COLOR_DIM = rgb(66, 74, 88) # 状态图标"未连接/未启用"
COLOR_ON = rgb(80, 220, 120) # 状态图标"已连接"
# ----------------------
# 布局常量(横屏 160x80)
# ----------------------
SCREEN_W = 160
SCREEN_H = 80
BAR_H = 13 # 状态栏高度
CARD_W = 76
CARD_H = 30 # 四槽位卡片高
CARD2_H = SCREEN_H - (BAR_H + 3) - 2 # 两槽位大卡高(62)
CARD_GAP = 2 # 卡片间距
MARGIN_X = 3 # 左右边距
CARD_TOP = BAR_H + 3 # 首行卡片 y(分隔线下留 2px)
ACCENT_W = 3 # 卡片左侧强调色竖条宽度
# 指标定义:(状态键, 标签, 单位, 强调色, 卡片深色底)
METRICS = (
("temp", "TEMP", "C", rgb(255, 120, 60), rgb(42, 22, 14)),
("humi", "HUMI", "%", rgb(64, 180, 255), rgb(14, 28, 44)),
("soil", "SOIL", "%", rgb(120, 200, 80), rgb(20, 34, 18)),
("lux", "LUX", "lx", rgb(255, 200, 60), rgb(40, 32, 12)),
)
# 8x8 图标位图(每元素一行,MSB=最左像素)
# WiFi:两道同心弧 + 底部方点(弧顶加宽、占满 8 行,小屏更易识别);
# MQTT:双向箭头(发布/订阅)
WIFI_ICON = (0x7E, 0x81, 0x00, 0x3C, 0x42, 0x00, 0x18, 0x18)
MQTT_ICON = (0x00, 0x04, 0x7E, 0x04, 0x20, 0x7E, 0x20, 0x00)
ICON_SIZE = 8
def format_value(v):
"""格式化指标数值:None → "--"(预留位);结果不超过 4 字符以适配卡片大字号宽度。
默认保留一位小数;超宽时降为整数;超出显示范围钳制到 -999/9999。
"""
if v is None:
return "--"
if v >= 9999.5:
return "9999"
if v <= -999.5:
return "-999"
s = "{:.1f}".format(v)
if len(s) > 4:
s = "{:.0f}".format(v)
return s
def idle_expired(now_s, last_active_s, timeout_s):
"""是否已达熄屏空闲超时(供 main.py 省电逻辑使用,纯函数可单测)。"""
return (now_s - last_active_s) >= timeout_s
def card_rect(index, four_slots=True):
"""指标卡片的 (x, y, w, h)。
four_slots=True:2x2 网格,index 0-3 先行后列;
four_slots=False:左右两张大卡,index 0-1。
"""
if four_slots:
col = index % 2
row = index // 2
x = MARGIN_X + col * (CARD_W + CARD_GAP)
y = CARD_TOP + row * (CARD_H + CARD_GAP)
return x, y, CARD_W, CARD_H
x = MARGIN_X + index * (CARD_W + CARD_GAP)
return x, CARD_TOP, CARD_W, CARD2_H
class ScreenUI:
"""横屏 UI:状态变化时整帧重绘并 show();状态未变化时跳过(省电)。"""
def __init__(self, disp):
self.disp = disp
self._last_state = None
self._glyph_buf = None
self._glyph_fb = None
# ----------------------
# 公共接口
# ----------------------
def update(self, temp=None, humi=None, soil=None, lux=None,
wifi_on=False, mqtt_on=False, time_text=None, four_slots=True):
"""按最新状态刷新屏幕;返回 True=重绘,False=状态未变跳过。
temp/humi/soil/lux 传 None 表示对应传感器未接入(显示 "--" 占位);
time_text 为 None 时状态栏不显示时间;four_slots=False 时仅绘制温湿度两张大卡。
"""
state = {
"temp": temp, "humi": humi, "soil": soil, "lux": lux,
"wifi": bool(wifi_on), "mqtt": bool(mqtt_on),
"time": time_text, "four": bool(four_slots),
}
if state == self._last_state:
return False
self._draw(state)
self._last_state = state
return True
def invalidate(self):
"""下次 update() 强制重绘(屏幕从睡眠唤醒后调用)。"""
self._last_state = None
# ----------------------
# 内部绘制
# ----------------------
def _draw(self, st):
d = self.disp
d.fill(COLOR_BG)
self._draw_bar(st)
for i in range(4 if st["four"] else 2):
key, label, unit, color, card_bg = METRICS[i]
self._draw_card(card_rect(i, st["four"]), label, unit,
st[key], color, card_bg)
d.show()
def _draw_bar(self, st):
d = self.disp
d.fill_rect(0, 0, SCREEN_W, BAR_H, COLOR_BAR)
self._draw_icon(WIFI_ICON, 4, 2, COLOR_ON if st["wifi"] else COLOR_DIM)
self._draw_icon(MQTT_ICON, 17, 2, COLOR_ON if st["mqtt"] else COLOR_DIM)
t = st["time"]
if t: # WiFi 未启用时业务层传 None:不显示时间
d.text(t, SCREEN_W - len(t) * 8 - 4, 3, COLOR_TIME)
d.hline(0, BAR_H, SCREEN_W, COLOR_BORDER)
def _draw_card(self, rect, label, unit, value, color, card_bg):
d = self.disp
x, y, w, h = rect
d.fill_rect(x, y, w, h, card_bg)
d.rect(x, y, w, h, COLOR_BORDER)
d.fill_rect(x + 1, y + 1, ACCENT_W, h - 2, color) # 左侧强调色竖条
tx = x + ACCENT_W + 5
d.text(label, tx, y + 3, color)
d.text(unit, x + w - 4 - len(unit) * 8, y + 3, COLOR_UNIT)
vs = format_value(value)
vy = y + 12 if h <= 34 else y + h // 2 - 4 # 大卡数值垂直居中
if value is None: # 预留指标位:占位符用灰色大字,卡片照样显眼
self._draw_text_scaled(vs, tx, vy, 2, COLOR_PLACEHOLDER)
else:
self._draw_text_scaled(vs, tx, vy, 2, COLOR_VALUE)
def _draw_icon(self, rows, x, y, color):
d = self.disp
for ry, bits in enumerate(rows):
if not bits:
continue
for rx in range(ICON_SIZE):
if bits & (0x80 >> rx):
d.pixel(x + rx, y + ry, color)
def _draw_text_scaled(self, s, x, y, scale, color):
"""内置 8x8 字体无字号选项:先渲染到小 buffer,再按 scale 逐像素放大绘制。"""
import framebuf
if self._glyph_buf is None:
self._glyph_buf = bytearray(8 * 8 * 2)
self._glyph_fb = framebuf.FrameBuffer(
self._glyph_buf, 8, 8, framebuf.RGB565)
for ci, ch in enumerate(s):
self._glyph_fb.fill(0)
self._glyph_fb.text(ch, 0, 0, 0xFFFF)
ox = x + ci * 8 * scale
for gy in range(8):
for gx in range(8):
if self._glyph_fb.pixel(gx, gy):
self.disp.fill_rect(ox + gx * scale, y + gy * scale,
scale, scale, color)
当然,这里还实现了一些其他的逻辑(后续计划的土壤湿度与光照传感器的部分)。其中最核心的是
update: 实现了惰式更新,如果状态一致这不刷新屏幕,可以省电,当然我是选择一定时间之后就熄灭了_draw_rect: 实现布局,仅接入温湿度计就显示两个格子_draw_text_scaled: 按像素扩散实现大字体 然后,就让我们的main.py把所有模块串联起来。
from lib.trh_sht31 import SHT31
from lib.st7735s import ST7735S
from lib.screen_ui import ScreenUI, idle_expired
from lib.utils import load_config, debounce_accept
from machine import I2C, Pin
import time
LOOP_TICK_S = 1 # 主循环节拍(每拍检查按键唤醒/熄屏计时)
READ_EVERY_TICKS = 5 # 每 N 拍读取一次传感器并刷新 UI(= 5 秒)
# I2C 传感器识别地址(启动时 i2c.scan() 比对)
I2C_ADDR_SHT31 = 0x44 # 温湿度 SHT31
I2C_ADDR_LUX_BH1750 = 0x23 # 光照 BH1750(常见 I2C 光照传感器)
SCREEN_OFF_S_DEFAULT = 180 # 无操作 3 分钟熄屏(config.json 的 screen_off_s 可覆盖)
BTN_DEBOUNCE_MS = 200 # 按键去抖窗口(机械按键抖动通常 < 50ms)
_btn_pressed = False # 按键中断标志(IRQ 置位,主循环消费)
_btn_last_ms = 0 # 上次有效按键时间(去抖用,IRQ 内更新)
def _on_button(_pin):
"""按键按下中断:去抖通过后置唤醒标志,实际点亮在主循环处理(IRQ 内不做耗时操作)。"""
global _btn_pressed, _btn_last_ms
now = time.ticks_ms()
if debounce_accept(now, _btn_last_ms, BTN_DEBOUNCE_MS):
_btn_last_ms = now
_btn_pressed = True
# 引脚默认值(config.json 未定义时的回退,与 config.json 中的键一致)
PIN_DEFAULTS = {
"i2c_scl": 3,
"i2c_sda": 4,
"display_sck": 5,
"display_mosi": 6,
"display_rst": 7,
"display_dc": 8,
"display_cs": 9,
}
def _pins(cfg):
"""合并 config.json 与默认值,返回引脚配置 dict。"""
return {k: cfg.get(k, v) for k, v in PIN_DEFAULTS.items()}
def _detect_sensors(i2c, cfg):
"""扫描 I2C 判定接入的传感器,返回 (has_sht31, has_soil, has_lux)。
光照按 BH1750(0x23)识别;土壤湿度需在 config.json 指定 soil_sensor_addr(int),
再与扫描结果比对(土壤传感器形态多样,无统一地址)。
"""
addrs = i2c.scan()
has_sht31 = I2C_ADDR_SHT31 in addrs
has_lux = I2C_ADDR_LUX_BH1750 in addrs
soil_addr = cfg.get("soil_sensor_addr")
has_soil = isinstance(soil_addr, int) and soil_addr in addrs
return has_sht31, has_soil, has_lux
def main():
global _btn_pressed
print("ESP32-TRH 主循环启动,读取 config.json 中的引脚配置(未定义则使用默认值)")
cfg = load_config("config.json") # 读取失败时返回 {}
pins = _pins(cfg)
# 初始化 I2C(引脚取自 config.json)
i2c = I2C(0, scl=Pin(pins["i2c_scl"]), sda=Pin(pins["i2c_sda"]), freq=100000)
# 扫描 I2C 判定接入的传感器:土壤/光照均未接入时屏幕只显示温湿度两张大卡
has_sht31, has_soil, has_lux = _detect_sensors(i2c, cfg)
print("传感器检测: SHT31={}, 土壤={}, 光照={}".format(has_sht31, has_soil, has_lux))
trh = SHT31(i2c) if has_sht31 else None
four_slots = has_soil or has_lux # 任一接入则保留四槽位(缺者显示 "--")
# 初始化 ST7735S 显示屏(横屏 160x80);失败则不显示,传感器循环照常运行
# 旋转方向取 config.json 的 display_rotation(1/3,默认 1),非法值回退 1
rotation = cfg.get("display_rotation", 1)
if rotation not in (1, 3):
rotation = 1
disp = None
try:
disp = ST7735S.from_pins(
sck=pins["display_sck"],
mosi=pins["display_mosi"],
dc=pins["display_dc"],
cs=pins["display_cs"],
rst=pins["display_rst"],
rotation=rotation,
)
except Exception as e:
print("显示屏初始化失败,跳过显示: {}".format(e))
# 横屏 UI(状态栏 + 2/4 指标卡);显示屏初始化失败时 ui 为 None,循环跳过显示
ui = ScreenUI(disp) if disp is not None else None
# 配网按键复用为点亮屏幕按键:下降沿中断(按键接 GND、内部上拉、按下低电平)
# 引脚不可用时(如 ESP32-C3 无 GPIO 27)降级为无按键:熄屏后只能重启唤醒
button_pin = cfg.get("button_pin", 27)
button = None
try:
button = Pin(button_pin, Pin.IN, Pin.PULL_UP)
button.irq(trigger=Pin.IRQ_FALLING, handler=_on_button)
except ValueError as e:
print("按键引脚 GPIO{} 不可用({}),按键点亮功能禁用".format(button_pin, e))
# 熄屏省电:无操作超过 screen_off_s(默认 180s)熄灭屏幕,按键点亮
screen_off_s = cfg.get("screen_off_s", SCREEN_OFF_S_DEFAULT)
awake = disp is not None
last_active = time.time()
# 状态栏图标接线点:恢复 WiFi/MQTT 后改为真实连接状态
# (如 wm.is_connected() / mqtt.is_connected())
wifi_connected = False
mqtt_connected = False
# 时间接线点:启用 WiFi 后 NTP 同步(ntptime.settime())再传 "HH:MM";
# WiFi 未启用时保持 None,状态栏不显示时间
clock_text = None
loop_count = 0
tick = 0
while True:
# 按键点亮屏幕(唤醒)并刷新空闲计时
if _btn_pressed and button is not None:
_btn_pressed = False
if disp is not None and not awake:
disp.wake()
ui.invalidate() # 唤醒后强制重绘一帧
awake = True
print("按键唤醒,点亮屏幕")
last_active = time.time()
# 空闲超时熄屏(业务循环不受影响,仅屏幕睡眠)
if awake and idle_expired(time.time(), last_active, screen_off_s):
disp.sleep()
awake = False
print("空闲 {} 秒,熄灭屏幕".format(screen_off_s))
# 每 READ_EVERY_TICKS 拍(5 秒)读取传感器并刷新 UI
if tick % READ_EVERY_TICKS == 0:
temp = humidity = None
if trh is not None:
data = trh.read() # read() 返回 dict
temp = data["temperature"]
humidity = data["humidity"]
print("温度: {}°C, 湿度: {}%".format(temp, humidity))
# 熄屏期间不推帧(省电);状态未变化时 ScreenUI 也会自动跳过重绘
if ui is not None and awake:
ui.update(
temp=temp, humi=humidity, soil=None, lux=None,
wifi_on=wifi_connected, mqtt_on=mqtt_connected,
time_text=clock_text, four_slots=four_slots,
)
tick += 1
time.sleep(LOOP_TICK_S)
if __name__ == "__main__":
main()
整体的流程基本就是启动时检测已接入的传感器,然后根据接入的传感器初始化屏幕。并且我设计了一个省电逻辑,在3分钟后,屏幕会自动熄灭,可以通过按下按键实现点亮,点亮时重新绘制屏幕。 当然,还有一个配置文件。
{
"version": "0.1.0",
"button_pin": 10,
"i2c_scl": 3,
"i2c_sda": 4,
"display_sck": 5,
"display_mosi": 6,
"display_rst": 7,
"display_dc": 8,
"display_cs": 9,
"display_rotation": 1,
"screen_off_s": 180
} 文章标题:ESP32-1-温湿度计(Build With Template)
作者:
文章链接:https://www.zanks.link/post/esp32-1-wen-shi-du-ji-build-with-template_zanks
许可协议:CC BY-NC-ND 4.0
本作品采用知识共享署名 - 非商业性使用 - 禁止演绎 4.0 国际许可协议进行许可。您可以自由复制、分发本作品,但必须标明原作者,不得用于商业目的,且不得修改原作品。