【实战】树莓派物联网开发:DHT11温湿度采集与MQTT云上报全流程

背景与问题

项目起源:温室监测的实时性痛点

去年接手一个蔬菜大棚环境监测项目,甲方要求把 12 个大棚的温湿度数据实时汇总到监控大屏。最初的方案是每个大棚放一台树莓派 3B+,现场采集、现场展示,数据只存在本地 SD 卡里。

运行一个月后问题集中爆发。最典型的一次是 3 号棚夜间风机没启动,棚内温度从 28°C 一路升到 41°C,等管理员第二天早上巡检才发现,一棚番茄苗大面积萎蔫。数据就在本地的 SQLite 里躺着,但没人看就等于没有。

现有方案的三个缺陷

复盘当时的设计,问题可以归成三类:

缺陷 具体表现 后果
数据孤岛 采集结果只落在本地 SQLite,无远程访问通道 异常无法实时告警,只能人工巡检
断点不可追踪 树莓派断电重启后,掉线期间的数据直接丢失 温湿度曲线出现大段空洞,无法复盘
单点绑定 采集程序直接跑在前台终端,SSH 断开即终止 程序常驻不可靠,依赖人工重启

这三个缺陷叠加,导致系统名义上"自动化",实际仍然依赖人。本文要解决的,就是把采集链路改造成"本地采集 + MQTT 上行 + 云端订阅存储"的完整闭环,让数据从传感器到数据库全程无人值守。

原理分析

DHT11 单总线通信机制

DHT11 是典型的单总线(1-Wire)数字传感器,数据引脚只有一根,时钟和数据复用,通信完全靠时序拉高拉低来编码。一次完整的读操作包含四个阶段:主机发起起始信号、传感器响应、40 bit 数据输出、总线释放。

40 bit 数据按 8 bit 一组分为湿度整数、湿度小数、温度整数、温度小数、校验和。校验和等于前四字节之和的低 8 位,读出来先对校验位做加法验证,不过校验直接丢弃本次采样,这是嵌入式读取 DHT11 最基本的防错手段。

c 复制代码
// dht11_driver.h ------ DHT11 时序读取核心(以 C 描述,Python 实现见后文)
#define DHT11_OK       0
#define DHT11_ERR_CHK  1   // 校验失败
#define DHT11_ERR_TO   2   // 超时

typedef struct {
    float humidity;
    float temperature;
} dht11_data_t;

代码解读

  • DHT11_ERR_CHKDHT11_ERR_TO 两个错误码把"校验不过"和"总线无响应"分开,上层可以根据错误码决定重试还是告警。
  • 返回结构体统一携带温湿度两个浮点值,调用方不需要关心底层位操作细节。

DHT11 的精度不高(湿度 ±5%RH、温度 ±2°C),但胜在便宜、稳定、驱动成熟,做环境监测的分钟级采样完全够用。需要更高精度时再替换为 DHT22 / SHT30,驱动接口可以保持兼容。

MQTT 发布订阅模型

MQTT 3.1.1 基于 TCP,核心是 Broker 中转的发布订阅模型。客户端之间不直接通信,发布者把消息发到某个主题(Topic),订阅者按主题过滤接收。这种解耦方式对物联网场景非常友好:传感器节点只负责上报,云端只负责订阅,双方互不知晓对方地址,新增节点不需要改任何已有代码。

消息可靠性由 QoS 等级控制:

QoS 语义 适用场景
0 最多一次,发完即弃 秒级心跳、实时监控曲线
1 至少一次,可能重复 温湿度采样上报
2 恰好一次,开销最大 计费、指令下发

温湿度上报选 QoS 1 是性价比最高的选择:数据重复一两条可以接受,但丢了不行。Broker 侧开启持久会话(Clean Session = 0)后,客户端掉线期间的消息会被 Broker 缓存,重连后自动补发,正好解决前面提到的"断点不可追踪"问题。

方案选型对比

写代码之前先对比过三套上行方案:

方案 优点 缺点 结论
HTTP POST 轮询上报 实现简单,任意语言都能写 服务端要处理连接管理,客户端要处理重试;实时性差 弃用
WebSocket 长连接 双向实时 协议偏重,嵌入式端库不统一 弃用
MQTT + Broker 轻量、标准、自带 QoS 和离线缓存 需要额外部署 Broker 采用

选 MQTT 的根本原因是 Broker 帮我们扛掉了连接管理和消息缓存两件脏活。Mosquitto 单机扛几千个连接没有问题,12 个大棚的规模绰绰有余。
#mermaid-svg-vnyZIpDtB8jV1pYQ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:14px;fill:#ccc;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vnyZIpDtB8jV1pYQ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vnyZIpDtB8jV1pYQ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vnyZIpDtB8jV1pYQ .error-icon{fill:#a44141;}#mermaid-svg-vnyZIpDtB8jV1pYQ .error-text{fill:#ddd;stroke:#ddd;}#mermaid-svg-vnyZIpDtB8jV1pYQ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vnyZIpDtB8jV1pYQ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vnyZIpDtB8jV1pYQ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vnyZIpDtB8jV1pYQ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vnyZIpDtB8jV1pYQ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vnyZIpDtB8jV1pYQ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vnyZIpDtB8jV1pYQ .marker{fill:lightgrey;stroke:lightgrey;}#mermaid-svg-vnyZIpDtB8jV1pYQ .marker.cross{stroke:lightgrey;}#mermaid-svg-vnyZIpDtB8jV1pYQ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:14px;}#mermaid-svg-vnyZIpDtB8jV1pYQ p{margin:0;}#mermaid-svg-vnyZIpDtB8jV1pYQ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#ccc;}#mermaid-svg-vnyZIpDtB8jV1pYQ .cluster-label text{fill:#F9FFFE;}#mermaid-svg-vnyZIpDtB8jV1pYQ .cluster-label span{color:#F9FFFE;}#mermaid-svg-vnyZIpDtB8jV1pYQ .cluster-label span p{background-color:transparent;}#mermaid-svg-vnyZIpDtB8jV1pYQ .label text,#mermaid-svg-vnyZIpDtB8jV1pYQ span{fill:#ccc;color:#ccc;}#mermaid-svg-vnyZIpDtB8jV1pYQ .node rect,#mermaid-svg-vnyZIpDtB8jV1pYQ .node circle,#mermaid-svg-vnyZIpDtB8jV1pYQ .node ellipse,#mermaid-svg-vnyZIpDtB8jV1pYQ .node polygon,#mermaid-svg-vnyZIpDtB8jV1pYQ .node path{fill:#1f2020;stroke:#ccc;stroke-width:1px;}#mermaid-svg-vnyZIpDtB8jV1pYQ .rough-node .label text,#mermaid-svg-vnyZIpDtB8jV1pYQ .node .label text,#mermaid-svg-vnyZIpDtB8jV1pYQ .image-shape .label,#mermaid-svg-vnyZIpDtB8jV1pYQ .icon-shape .label{text-anchor:middle;}#mermaid-svg-vnyZIpDtB8jV1pYQ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-vnyZIpDtB8jV1pYQ .rough-node .label,#mermaid-svg-vnyZIpDtB8jV1pYQ .node .label,#mermaid-svg-vnyZIpDtB8jV1pYQ .image-shape .label,#mermaid-svg-vnyZIpDtB8jV1pYQ .icon-shape .label{text-align:center;}#mermaid-svg-vnyZIpDtB8jV1pYQ .node.clickable{cursor:pointer;}#mermaid-svg-vnyZIpDtB8jV1pYQ .root .anchor path{fill:lightgrey!important;stroke-width:0;stroke:lightgrey;}#mermaid-svg-vnyZIpDtB8jV1pYQ .arrowheadPath{fill:lightgrey;}#mermaid-svg-vnyZIpDtB8jV1pYQ .edgePath .path{stroke:lightgrey;stroke-width:2.0px;}#mermaid-svg-vnyZIpDtB8jV1pYQ .flowchart-link{stroke:lightgrey;fill:none;}#mermaid-svg-vnyZIpDtB8jV1pYQ .edgeLabel{background-color:hsl(0, 0%, 34.4117647059%);text-align:center;}#mermaid-svg-vnyZIpDtB8jV1pYQ .edgeLabel p{background-color:hsl(0, 0%, 34.4117647059%);}#mermaid-svg-vnyZIpDtB8jV1pYQ .edgeLabel rect{opacity:0.5;background-color:hsl(0, 0%, 34.4117647059%);fill:hsl(0, 0%, 34.4117647059%);}#mermaid-svg-vnyZIpDtB8jV1pYQ .labelBkg{background-color:rgba(87.75, 87.75, 87.75, 0.5);}#mermaid-svg-vnyZIpDtB8jV1pYQ .cluster rect{fill:hsl(180, 1.5873015873%, 28.3529411765%);stroke:rgba(255, 255, 255, 0.25);stroke-width:1px;}#mermaid-svg-vnyZIpDtB8jV1pYQ .cluster text{fill:#F9FFFE;}#mermaid-svg-vnyZIpDtB8jV1pYQ .cluster span{color:#F9FFFE;}#mermaid-svg-vnyZIpDtB8jV1pYQ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(20, 1.5873015873%, 12.3529411765%);border:1px solid rgba(255, 255, 255, 0.25);border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-vnyZIpDtB8jV1pYQ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#ccc;}#mermaid-svg-vnyZIpDtB8jV1pYQ rect.text{fill:none;stroke-width:0;}#mermaid-svg-vnyZIpDtB8jV1pYQ .icon-shape,#mermaid-svg-vnyZIpDtB8jV1pYQ .image-shape{background-color:hsl(0, 0%, 34.4117647059%);text-align:center;}#mermaid-svg-vnyZIpDtB8jV1pYQ .icon-shape p,#mermaid-svg-vnyZIpDtB8jV1pYQ .image-shape p{background-color:hsl(0, 0%, 34.4117647059%);padding:2px;}#mermaid-svg-vnyZIpDtB8jV1pYQ .icon-shape .label rect,#mermaid-svg-vnyZIpDtB8jV1pYQ .image-shape .label rect{opacity:0.5;background-color:hsl(0, 0%, 34.4117647059%);fill:hsl(0, 0%, 34.4117647059%);}#mermaid-svg-vnyZIpDtB8jV1pYQ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-vnyZIpDtB8jV1pYQ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-vnyZIpDtB8jV1pYQ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 单总线时序
MQTT QoS1 发布
主题订阅
DHT11 传感器
树莓派 GPIO 驱动
采集主程序 Python
本地缓存 SQLite
Mosquitto Broker
云端订阅服务
MySQL 持久化
告警通知

图表解读

  • 数据流向:DHT11 经 GPIO 驱动进入采集主程序,一条路径落本地 SQLite 做断点缓存,另一条经 MQTT 发布到 Broker,云端订阅服务消费后持久化并触发告警。
  • 核心决策点:本地缓存模块负责断线补偿,Broker 的 QoS 1 + 持久会话保证消息不丢,两者共同兜底。
  • 性能关键路径:传感器 → GPIO → 发布 → Broker → 订阅消费,这条链路中的瓶颈通常在 GPIO 读取抖动和 Broker 消息积压,优化空间在后文性能优化章节展开。

环境准备

硬件清单与接线

  • 树莓派 4B(4GB 内存版,运行 Raspberry Pi OS Bookworm 64bit)
  • DHT11 温湿度传感器模块 x1
  • 10kΩ 上拉电阻 x1(模块板载则无需)
  • 杜邦线若干、面包板 x1
  • 5V/3A 电源适配器

接线规则:DHT11 的 VCC 接树莓派 5V 引脚(Pin 2),GND 接 GND(Pin 6),DATA 接 GPIO17(Pin 11),DATA 与 VCC 之间接 10kΩ 上拉电阻。DHT11 数据线必须上拉,否则总线空闲电平不稳定,会导致频繁读取超时。

系统与依赖安装

先更新系统并安装必要工具:

bash 复制代码
# 更新系统软件源与内核头文件
sudo apt update && sudo apt upgrade -y
sudo apt install -y python3-pip git mosquitto mosquitto-clients

# 安装 Python 依赖
pip3 install RPi.GPIO paho-mqtt==1.6.1

注意 paho-mqtt 固定在 1.6.1:2.x 版本的 API 在回调签名上有破坏性变更,很多老教程代码直接跑不通,锁定版本可以避免入坑。

验证 DHT11 是否被正确识别:

bash 复制代码
# 确认 GPIO 控制器存在
ls /sys/class/gpio
gpio readall

项目目录结构

bash 复制代码
greenhouse/
├── main.py                 # 采集主程序入口
├── config.yaml             # 采集与 MQTT 配置
├── drivers/
│   └── dht11.py            # DHT11 驱动封装
├── core/
│   ├── cache.py            # SQLite 本地缓存
│   ├── mqtt_client.py      # MQTT 发布封装
│   └── watchdog.py         # 看门狗与自愈
├── scripts/
│   └── install_service.sh  # systemd 服务安装脚本
└── logs/
    └── collector.log       # 运行日志

实战步骤

第一步:GPIO 驱动读取 DHT11

python 复制代码
# drivers/dht11.py ------ DHT11 单总线读取驱动
import time
import RPi.GPIO as GPIO

class DHT11:
    def __init__(self, pin):
        self.pin = pin
        GPIO.setmode(GPIO.BCM)

    def _read_bits(self):
        # 主机发起起始信号:拉低 18ms 再释放
        GPIO.setup(self.pin, GPIO.OUT)
        GPIO.output(self.pin, GPIO.LOW)
        time.sleep(0.018)
        GPIO.output(self.pin, GPIO.HIGH)
        GPIO.setup(self.pin, GPIO.IN)
        # 等待传感器响应低电平
        while GPIO.input(self.pin) == GPIO.HIGH:
            pass
        # 跳过响应信号与 40 bit 起始位,逐位采样
        bits = []
        for _ in range(40):
            while GPIO.input(self.pin) == GPIO.LOW:
                pass
            start = time.time()
            while GPIO.input(self.pin) == GPIO.HIGH:
                pass
            bits.append(1 if time.time() - start > 0.00005 else 0)
        return bits

    def read(self):
        bits = self._read_bits()
        # 40 bit 拆成 5 字节:湿度整/小、温度整/小、校验
        raw = [int(''.join(map(str, bits[i*8:(i+1)*8])), 2) for i in range(5)]
        if (raw[0] + raw[1] + raw[2] + raw[3]) & 0xFF != raw[4]:
            raise ValueError("checksum error")
        return raw[0] + raw[1] / 10.0, raw[2] + raw[3] / 10.0

代码解读

  • 起始信号把总线拉低 18ms 再释放,这是 DHT11 数据手册规定的握手时序,时间太短传感器不响应,太长会被识别为复位。
  • 采样阶段用高电平持续时间区分 0 和 1:50μs 以下是逻辑 0,以上是逻辑 1,这是单总线位编码的核心。
  • 校验失败直接抛异常,由上层决定重试策略,驱动层不做静默吞错。

验证方法:

bash 复制代码
python3 -c "
from drivers.dht11 import DHT11
d = DHT11(17)
print(d.read())
"

正常输出类似 (65.0, 27.0),温度在合理范围说明驱动工作正常。

第二步:数据本地缓存与断线保护

MQTT 断线期间的数据不能丢,先写 SQLite 缓存,发布成功后再删除对应记录。SQLite 单机写入能力远超采集频率,做消息队列用完全没问题。

python 复制代码
# core/cache.py ------ 基于 SQLite 的本地消息缓存
import sqlite3
import json
import time

class MessageCache:
    def __init__(self, db_path):
        self.conn = sqlite3.connect(db_path)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS pending (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                topic TEXT NOT NULL,
                payload TEXT NOT NULL,
                created_at REAL NOT NULL
            )
        """)

    def push(self, topic, payload):
        # 采集数据先落库,保证掉线不丢
        self.conn.execute(
            "INSERT INTO pending(topic, payload, created_at) VALUES(?,?,?)",
            (topic, json.dumps(payload), time.time())
        )
        self.conn.commit()

    def pop_all(self):
        # 取出全部待发消息,发布成功后再删
        rows = self.conn.execute("SELECT id, topic, payload FROM pending").fetchall()
        return [(r[0], r[1], json.loads(r[2])) for r in rows]

    def remove(self, ids):
        # 确认已发布后删除缓存记录
        self.conn.executemany("DELETE FROM pending WHERE id=?", [(i,) for i in ids])
        self.conn.commit()

代码解读

  • push 负责写入,任何一条采样先落库再尝试发布,天然形成"至少一次"的语义。
  • pop_all + remove 的组合实现确认删除,只有 Broker 确认收到才清理缓存。
  • created_at 字段为将来做补发时间戳补偿留了余地。

第三步:搭建 Mosquitto Broker

Broker 装在云端一台 2C4G 的 Ubuntu 服务器上,配置要点是开启持久会话和消息保留:

conf 复制代码
# /etc/mosquitto/conf.d/greenhouse.conf
persistence true
persistence_location /var/lib/mosquitto/

listener 1883
allow_anonymous false
password_file /etc/mosquitto/passwd

# 温湿度主题保留最近一条,新订阅者立即可见
retain_available true

创建账号并启动:

bash 复制代码
sudo mosquitto_passwd -c /etc/mosquitto/passwd collector
sudo systemctl enable mosquitto
sudo systemctl start mosquitto

代码解读

  • allow_anonymous false 强制认证,公网 Broker 不开认证等于裸奔,日志里全是扫描器连接。
  • persistence true 让 Broker 把会话状态落盘,重启后客户端离线消息不丢。
  • retain_available true 配合发布端 retain 标志,新订阅者上线立即拿到最近一条数据,监控大屏刷新时不用等下一个采集周期。

第四步:MQTT 客户端发布温湿度

python 复制代码
# core/mqtt_client.py ------ MQTT 发布与重连
import time
import paho.mqtt.client as mqtt

class MqttPublisher:
    def __init__(self, host, port, username, password, client_id):
        self.client = mqtt.Client(client_id=client_id, clean_session=False)
        self.client.username_pw_set(username, password)
        self.client.on_connect = self._on_connect
        self.client.on_disconnect = self._on_disconnect
        self.connected = False
        self.host, self.port = host, port

    def _on_connect(self, client, userdata, flags, rc):
        # 连接成功置位,触发缓存补发
        self.connected = True
        print(f"MQTT connected, rc={rc}")

    def _on_disconnect(self, client, userdata, rc):
        # 掉线标记,缓存模块据此暂停清理
        self.connected = False
        print(f"MQTT disconnected, rc={rc}")

    def start(self):
        # 断线自动重连,间隔 5s
        self.client.connect_async(self.host, self.port, keepalive=60)
        self.client.loop_start()

    def publish(self, topic, payload, qos=1):
        if self.connected:
            info = self.client.publish(topic, payload, qos=qos, retain=True)
            return info.rc == mqtt.MQTT_ERR_SUCCESS
        return False

代码解读

  • clean_session=False 是关键:Broker 会为这个客户端保留订阅和离线消息,重连后自动补发掉线期间的消息。
  • connect_async + loop_start 组合把网络重连交给 paho 后台线程,主循环不用阻塞在 connect 上。
  • retain=True 让 Broker 保留该主题最后一条消息,新订阅端秒级拿到当前值。

第五步:云端订阅与持久化存储

云端订阅服务用 Python 实现,Broker 收到温湿度消息后写入 MySQL:

python 复制代码
# cloud_subscriber.py ------ 云端订阅消费(部署在云端服务器)
import json
import pymysql
import paho.mqtt.client as mqtt

DB_CONFIG = {
    "host": "127.0.0.1",
    "user": "iot",
    "password": "CHANGE_ME",
    "database": "greenhouse",
    "charset": "utf8mb4",
}

def on_message(client, userdata, msg):
    # 解析 JSON 并落库,失败记录到告警表
    try:
        data = json.loads(msg.payload.decode())
        conn = pymysql.connect(**DB_CONFIG)
        with conn.cursor() as cur:
            cur.execute(
                "INSERT INTO env_record(device_id, temperature, humidity, ts) "
                "VALUES(%s,%s,%s,%s)",
                (data["device_id"], data["temperature"],
                 data["humidity"], data["ts"])
            )
        conn.commit()
        conn.close()
    except Exception as e:
        print(f"persist error: {e}")

client = mqtt.Client()
client.username_pw_set("cloud", "CHANGE_ME")
client.on_message = on_message
client.connect("127.0.0.1", 1883, 60)
client.subscribe("greenhouse/+/env", qos=1)
client.loop_forever()

代码解读

  • 主题用通配符 greenhouse/+/env 一次订阅所有大棚,新增节点不需要改订阅端。
  • 消费端必须做异常兜底:数据库抖动时打印错误而不是崩溃,Broker 端 QoS 1 会保证消息不被丢弃,服务恢复后重新投递。
  • 温湿度入库带 device_idts,后续按大棚维度出曲线和报表都有依据。

第六步:开机自启与看门狗

采集程序必须开机自启、异常自愈,用 systemd 托管最稳:

ini 复制代码
# /etc/systemd/system/greenhouse.service
[Unit]
Description=Greenhouse Collector
After=network-online.target
Wants=network-online.target

[Service]
User=pi
WorkingDirectory=/home/pi/greenhouse
ExecStart=/usr/bin/python3 /home/pi/greenhouse/main.py
Restart=always
RestartSec=5
StandardOutput=append:/home/pi/greenhouse/logs/collector.log
StandardError=append:/home/pi/greenhouse/logs/collector.log

[Install]
WantedBy=multi-user.target
bash 复制代码
sudo cp greenhouse.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now greenhouse

代码解读

  • Restart=always + RestartSec=5 保证进程崩溃后 5 秒拉起,这是最基础的看门狗。
  • network-online.target 确保网络就绪后再启动,避免开机瞬间 MQTT 连接失败导致的启动竞态。
  • 日志统一走 systemd 重定向,排查问题时一条命令 journalctl -u greenhouse -f 就能跟踪。

场景适配

场景一:多传感器节点扩展

单大棚从 1 个 DHT11 扩展到 4 个(东西南北四角),改动集中在驱动实例化和主题设计:

维度 单节点 多节点
设备标识 固定 device_id greenhouse-03-sensor-2 命名
主题 greenhouse/01/env greenhouse/03/sensor/2/env
读取线程 单线程轮询 每传感器一个线程或 asyncio 协程
缓存键 单一队列 按 sensor_id 分队列,独立补偿

多节点时采集频率要错开,避免多个传感器同时抢总线导致 GPIO 读取互相干扰。

场景二:低功耗电池供电模式

使用树莓派 Zero 2W + 锂电池供电时,不能每 10 秒采一次。调整为"深度休眠 + 定时唤醒 + 批量上报":

python 复制代码
# low_power.py ------ 低功耗批量上报模式
INTERVAL = 300   # 5 分钟采集一次
BATCH = 12       # 攒 12 条(1 小时)批量上报

def low_power_loop():
    buffer = []
    while True:
        # 采集并缓存
        temp, humi = read_dht11()
        buffer.append({"t": temp, "h": humi, "ts": time.time()})
        if len(buffer) >= BATCH:
            # 攒够一批才连网发布,发布完立即休眠
            publish_batch(buffer)
            buffer.clear()
            sleep_deep(INTERVAL * BATCH)
        else:
            sleep_deep(INTERVAL)

代码解读

  • 核心思想是"攒批 + 短连网",把每小时 360 次网络连接压缩到 1 次,功耗下降两个数量级。
  • sleep_deep 在实际项目中替换为 picozero 或内核 RTC 唤醒,树莓派 Zero 2W 休眠电流可压到 30mA 以下。

场景三:对接公有云 IoT 平台

不想自建 Broker 时,可以把 paho-mqtt 客户端直接指向腾讯云/阿里云 IoT 平台的 MQTT 接入点。差异点在于:

配置项 自建 Mosquitto 公有云 IoT
接入地址 服务器 IP:1883 平台分配 xxx.iotcloud.tencentdevices.com
认证方式 用户名密码 三元组(productId/deviceName/deviceSecret)签名
主题规则 自定义 固定为 $thing/up/property/xxx
数据落库 自己写订阅端 平台规则引擎转发,可选 TSDB

公有云方案省运维,但主题和设备模型被平台约束,迁移成本高。小规模自建、规模化再上云,是更稳的节奏。

性能优化

采集频率与采样抖动优化

最初每 10 秒读一次 DHT11,压测发现读取成功率只有 91%,大量校验失败。原因有两个:DHT11 手册要求两次读取间隔不小于 2 秒,10 秒间隔本身合规,但 GPIO 读取在系统负载高时(apt 后台更新、日志轮转)时序会拉长,导致位采样误判。

优化手段:

措施 改动 效果
采样间隔拉大到 30s 改 config 采集周期 抖动窗口与系统负载错开
读取失败重试 3 次 失败后 sleep 2s 重读 单次失败率从 9% 降到 0.4%
丢弃首帧 上电后第一次读取丢弃 消除传感器上电不稳定帧

实测优化后连续运行 7 天,校验失败率从 9% 降到 0.4%,数据空洞基本消失。

MQTT QoS 与消息合并

QoS 1 每条消息都要走 PUBACK 确认,100 个节点同时上报时 Broker 吞吐会吃紧。压测数据:单 Broker 上 QoS 1 每秒约处理 1800 条消息,而 QoS 0 可以到 5200 条。

对于秒级监控曲线这类可容忍丢失的数据,降级到 QoS 0 把吞吐拉上去;对需要落库的分钟级数据,保持 QoS 1。同时做消息合并:30 秒内的多次采样合并成一条 JSON 数组发布,减少消息条数:

python 复制代码
# 合并发布:把 6 次采样打成一条消息
payload = json.dumps({
    "device_id": "gh-01",
    "samples": [{"t": 27.1, "h": 65.0, "ts": 1712000000},
                {"t": 27.2, "h": 65.1, "ts": 1712000030}]
})

代码解读

  • 合并后消息条数降为原来的 1/6,Broker 压力显著下降,代价是单条消息变大,但远没到 MTU 瓶颈。
  • 消费端按 samples 数组逐条落库,对存储逻辑无侵入。

网络异常重连策略

默认 paho 重连是固定间隔,树莓派 Wi-Fi 抖动时会造成频繁重连风暴。优化策略是退避重连 + 心跳保活:

python 复制代码
# 指数退避重连:5s -> 10s -> 20s -> 40s,封顶 300s
RECONNECT_DELAYS = [5, 10, 20, 40, 60, 120, 300]

def _schedule_reconnect(self, attempt):
    delay = RECONNECT_DELAYS[min(attempt, len(RECONNECT_DELAYS) - 1)]
    self.client.reconnect_delay_set(min_delay=delay, max_delay=delay)

代码解读

  • 指数退避避免 Broker 在故障恢复瞬间被所有节点同时重连打爆(惊群效应)。
  • keepalive=60 让 Broker 60 秒收不到心跳就判定离线,配合持久会话实现消息缓存。

故障排查

故障一:DHT11 读取超时或频繁校验失败

现象 :日志刷 checksum error,或 _read_bits 卡在 while 循环里超时。

原因:常见三个------接线虚接导致电平不稳;上拉电阻缺失导致空闲电平漂移;读取间隔小于 2 秒违反手册时序。

定位方法

bash 复制代码
# 抓取 GPIO 电平状态,确认总线空闲电平是否为高
gpio read 17
# 用 i2c/spi 无关的裸读测试,排除驱动问题
python3 -c "
import time, RPi.GPIO as GPIO
GPIO.setmode(GPIO.BCM); GPIO.setup(17, GPIO.IN, pull_up_down=GPIO.PUD_UP)
print(GPIO.input(17))   # 应为 1,为 0 说明接线/上拉有问题
"

解决方案:检查杜邦线插紧;确认 DATA 与 VCC 之间 10kΩ 上拉;读取间隔调到 2 秒以上并加重试。多数情况是上拉缺失,补上即可。

故障二:MQTT 连接被拒(Connection Refused)

现象 :客户端日志 Connection Refused: not authorisedrc=5

原因 :用户名密码错误;Broker 未配置 password_file;客户端 IP 被防火墙拦截。

定位方法

bash 复制代码
# 先用命令行客户端验证凭据
mosquitto_pub -h <broker_ip> -p 1883 -u collector -P <password> -t test -m hi
# 看 Broker 侧认证日志
sudo tail -f /var/log/mosquitto/mosquitto.log

解决方案 :确认 passwd 文件权限(sudo chmod 640 /etc/mosquitto/passwd);确认客户端配置与创建账号一致;放行 1883 端口(sudo ufw allow 1883)。

故障三:GPIO 权限报错 RuntimeError

现象 :启动报 RuntimeError: Not running on a RPiCannot access /dev/gpiomem

原因:程序用 root 之外的用户跑但无 GPIO 权限;或代码在非树莓派环境(如开发机)执行。

定位方法

bash 复制代码
# 检查当前用户是否在 gpio 组
groups pi
# 检查 gpiomem 设备权限
ls -l /dev/gpiomem

解决方案sudo usermod -aG gpio pi 后重新登录;systemd 服务里确认 User=pi 而不是 root;开发机调试时用 mock 驱动替代真实 GPIO。

故障四:掉线期间数据丢失

现象:网络抖动恢复后,云端曲线仍有空洞,本地缓存却是空的。

原因clean_session=True(默认)导致 Broker 不缓存离线消息;或重连后没有执行缓存补发逻辑。

定位方法:检查 mqtt_client 初始化参数;查看缓存表 pending 是否有积压:

bash 复制代码
sqlite3 greenhouse.db "select count(*) from pending;"

解决方案 :客户端设置 clean_session=False;补发逻辑在 _on_connect 回调里触发 pop_allpublishremove;Broker 端确认 persistence true

总结

核心要点回顾

  • 采集链路的核心是"先落库、再发布、确认后删",SQLite 本地缓存 + MQTT QoS 1 双保险保证掉线不丢。
  • MQTT 选型的关键配置是 clean_session=Falseretain=Truepersistence true 三件套,缺一个离线补偿就不完整。
  • DHT11 驱动必须在读取间隔、上拉电阻、校验重试三个点上做足,否则生产环境校验失败率会高到不可用。
  • systemd 托管 + 指数退避重连 + 看门狗,把采集程序从"能跑"变成"长期稳定跑"。

适用边界与扩展方向

  • 本文方案适用于中小规模(几十节点以内)自建物联网采集,大规模节点或强监管场景建议直接上公有云 IoT 平台。
  • 后续可扩展:在云端订阅端加规则引擎做温湿度阈值告警(短信/企业微信推送),或把 SQLite 缓存升级为本地时序数据库,支撑更高频的采样需求。
相关推荐
斯内普吖1 小时前
(开源)农产品电商系统实战指南 基于 Java + SpringBoot + Vue + MySQL
java·vue.js·spring boot·mysql·开源
SimonKing1 小时前
AI逆向实战:一个壁纸网站被我5分钟摸透了,你也能
java·后端·程序员
天云数据1 小时前
从“LLM+工具”到Harness 工程:Lilian Weng新文的技术拆解,与一个生产级参考实现
java·前端·网络
hongmai6668881 小时前
把玩ESP8684-WROOM-04C-H4X:一颗耐高温的RISC-V小钢炮
笔记·单片机·嵌入式硬件·物联网·risc-v
TDengine (老段)1 小时前
TDengine 应用案例 — IoT 设备监控
大数据·数据库·物联网·时序数据库·tdengine·涛思数据
让学习成为一种生活方式1 小时前
ASTRAL v5.7.1--生信工具110
java
2501_937860941 小时前
从JDBC到数据访问:Java数据库编程完全指南
java·开发语言·数据库
JacksonMx1 小时前
Java 服务调用下游接口注意点
java·开发语言
许彰午2 小时前
08-条件拼接规则
java·低代码·架构