联网与数据交换 / MQTT双向链路发布 内容更新

把传感器数据发到 MQTT,并让命令回到一盏灯

用固定SHT40与IDF C驱动向自己的broker发布带有效性的温湿度,再通过独立topic控制普通LED,处理失败、保留状态和重连边界。

读完能做什么
分开验收测量、消息和动作,失败时不把旧数据或提交ID当作成功。
开始之前
已完成E01、N01、S01与P01,知道模块地址、普通LED与局域网服务。
所需器材
指定S3板、Adafruit SHT40 PID4885、WP7113ID红LED与330Ω电阻、同网电脑/Mosquitto2.0.20、面包板、短导线、USB数据线
版本组合
SDK:ESP-IDF5.5.5 / 内置esp-mqtt驱动:Sensirion embedded-i2c-sht4x1.1.0 / 8e16a390服务:Mosquitto2.0.20;可信LAN明文演示模块:PID4885 / 0x44 / SDA8 / SCL9
适用范围

官方 ESP32-S3-DevKitC-1 v1.1 / ESP32-S3-WROOM-1-N8R8;IDF5.5.5、PID4885/SHT40、GPIO8/9、GPIO4普通LED、私有Mosquitto2.0.20。

这篇验证到了哪一步

资料核对已通过
docs/esp32-evidence/networking-product.md#n04;逐项实际阅读、固定来源及限制。验证日期:
构建 / 配置已通过
docs/esp32-evidence/logs/n04-build.log;实际编译成功,摘要见networking-product-builds.json。对象:官方 ESP32-S3-DevKitC-1 v1.1 / ESP32-S3-WROOM-1-N8R8版本:ESP-IDF / 5.5.5验证日期:
仿真不适用
没有器件级或无线仿真;主机协议fixture与源码检查另列,不冒充ESP32仿真。
本站实板待验证
本站尚未执行这套组合的烧录、联网、传感器与实体动作;正文提供独立验收步骤。

构建通过只说明指定环境中的代码或配置通过检查;实物功能、功耗和稳定性以对应的实板记录为准。

01 / MQTT双向链路

一条MQTT消息之前,先分清三段链路

这一篇把S01指定的SHT40模块接到同一S3,再把新测量发给你自己管理的局域网broker;独立的命令topic只控制GPIO4普通LED。传感器读数、MQTT传输和实体动作各有验收,互不冒充成功。

固定ESP-IDF5.5.5。协议与CRC使用Sensirion官方C驱动1.1.0原样文件,新增HAL只负责IDF新master I2C的总线和读写,不把Arduino的C++驱动直接混进来。构建时不拉取浮动main,原始BSD许可与新代码MIT归属均在包内。

做到这里,你应该看到
能从模块端点、C驱动、节点、broker到控制灯分别指出边界。

参考:[1] ESP-IDF v5.5.5 MQTT TCP上游完整应用[2] ESP-IDF v5.5.5 ESP-MQTT说明:事件、重连与遗嘱[3] ESP-IDF v5.5.5 新I2C master bus/device接口[4] Sensirion SHT4x C驱动1.1.0:命令、等待与单位[5] Sensirion C驱动1.1.0 公共I2C/CRC实现[6] SHT4x数据手册V6.4:FD命令、等待、CRC与换算

02 / MQTT双向链路

先分别验收S01模块和P01灯,再组合

固定官方v1.1 N8R8板与Adafruit PID4885;断电接3V3/J1-1到VIN,GND/J1-22到GND,GPIO8/J1-12到SDA,GPIO9/J1-15到SCL,模块3Vo留空。模块已有10k上拉,单模块短线100kHz,不额外堆上拉。裸SHT40或另一料号不能套这个针脚表。

GPIO4/J1-4经330Ω接WP7113ID阳极,阴极回地;GPIO38板载RGB不参与。只用USB-to-UART一条线供电与串口。先断电完成接线;若要做传感器失败实验,预装SDA开关,避免带电挪动电源线。

开发板端点器件端点
3V3 / J1-1PID4885 VIN
GND / J1-22PID4885 GND、LED阴极
GPIO8 / J1-12PID4885 SDA
GPIO9 / J1-15PID4885 SCL
GPIO4 / J1-4330Ω → WP7113ID阳极
PID4885 3Vo不接
做到这里,你应该看到
S01和P01各自能独立验收;针脚、板修订与模块料号都有记录。

参考:[3] ESP-IDF v5.5.5 新I2C master bus/device接口[6] SHT4x数据手册V6.4:FD命令、等待、CRC与换算[7] Adafruit PID4885针脚:VIN/3Vo、0x44、10k上拉[9] 乐鑫 DevKitC-1 v1.1 固定文档:修订、供电与排针[11] Kingbright WP7113ID数据手册

03 / MQTT双向链路

先在本机检查broker,再开放给实验LAN

本例锁定Mosquitto2.0.20及同版pub/sub工具。先按官方包或源码安装到自己的工具目录,下载包的broker-loopback.conf仅监听127.0.0.1:18884,匿名、无持久化,适合先验证主机协议;没有把系统服务作为隐含前置。

设备连接前,在配置副本中把listener改成电脑实际私有LAN IP,并在menuconfig填同一个mqtt://私有IP:18884。localhost在S3上指自己,默认192.0.2.10是占位。只向可信实验LAN开放,不做公网映射;这份匿名明文演示不提供生产认证与TLS。

代码 / 配置
mosquitto -c broker-loopback.conf -v
# 另开终端,保持订阅器运行
mosquitto_sub -h 127.0.0.1 -p 18884 -t 'hixinwu/n04/#' -v
# 再一个终端发布明确的主机测试消息
mosquitto_pub -h 127.0.0.1 -p 18884 -t hixinwu/n04/test -m host-only
做到这里,你应该看到
先由主机收发host-only,再配置LAN监听;没有把这条消息写成ESP32测量。

参考:[1] ESP-IDF v5.5.5 MQTT TCP上游完整应用[2] ESP-IDF v5.5.5 ESP-MQTT说明:事件、重连与遗嘱[8] Mosquitto 2.0.20正式源码与修订[10] Mosquitto官方配置:listener与allow_anonymous

04 / MQTT双向链路

用新I2C适配层,保持协议驱动原样

通过E01进入5.5.5环境,在项目根目录set-target、menuconfig、build。Wi-Fi凭据只进被忽略的sdkconfig,占位值不联网;同一net.c用事件和退避管理ONLINE。dependencies.lock固定SDK,main/sensirion保留固定上游C文件与LICENSE,THIRD_PARTY.md列明归属。

HAL建立地址0x44、100kHz的新master device,收发各有100ms上限并传播错误。官方高精度命令等待10ms;任务延时按上取整再多一tick,抵消调用时刻的tick相位,避免被提前结束。C驱动的结果是milli°C和milli%RH,使用整数近似换算,再除1000显示,三位小数不代表测量准确度;CRC仍由原样sensirion_i2c.c检查。

代码 / 配置
idf.py --version
idf.py set-target esp32s3
idf.py menuconfig
idf.py build
idf.py -p PORT flash monitor
做到这里,你应该看到
lock、驱动提交和HAL对应同一份下载工程;实际构建成功不替代I2C实物检查。

参考:[1] ESP-IDF v5.5.5 MQTT TCP上游完整应用[3] ESP-IDF v5.5.5 新I2C master bus/device接口[4] Sensirion SHT4x C驱动1.1.0:命令、等待与单位[5] Sensirion C驱动1.1.0 公共I2C/CRC实现[6] SHT4x数据手册V6.4:FD命令、等待、CRC与换算

05 / MQTT双向链路

遥测、命令、状态与在线消息分开

telemetry每5秒发布,QoS1且不retain;每轮新变量,成功才有valid:true和单位明确的新数。sensor读取或CRC失败时valid:false、温湿度null,连续三次失败尝试soft reset。成功湿度的展示裁到0…100%RH,另保留raw;不启用加热器,也不把裁边说成更准确。

命令只接受完整的精确ON或OFF,拒绝其他长度、分片或错误值;发送者不用retain。节点忽略重新订阅时带retain标记的历史命令,避免恢复连接后重放旧动作。led/state发布重新读取的GPIO数字电平并retain,仍须观察实体灯;availability在线和遗嘱也不代表温湿度持续新鲜。

topic后缀(共同前缀hixinwu/n04/)含义
telemetry新序号、valid与温湿度;QoS1非retain
led/set控制方发精确ON或OFF,不用-r
led/state节点GPIO读回报告;QoS1+retain
availabilityonline / 遗嘱offline;QoS1+retain
代码 / 配置
mosquitto_pub -h LAN_IP -p 18884 -t hixinwu/n04/led/set -q 1 -m ON
mosquitto_pub -h LAN_IP -p 18884 -t hixinwu/n04/led/set -q 1 -m OFF
mosquitto_pub -h LAN_IP -p 18884 -t hixinwu/n04/led/set -q 1 -m toggle
做到这里,你应该看到
重复ON不翻转;toggle被拒绝;传感器失败不沿用旧正常值。

参考:[1] ESP-IDF v5.5.5 MQTT TCP上游完整应用[2] ESP-IDF v5.5.5 ESP-MQTT说明:事件、重连与遗嘱[4] Sensirion SHT4x C驱动1.1.0:命令、等待与单位[5] Sensirion C驱动1.1.0 公共I2C/CRC实现[6] SHT4x数据手册V6.4:FD命令、等待、CRC与换算

06 / MQTT双向链路

重连重订阅,离线时不宣称已补发

MQTT在Wi-Fi首次ONLINE后启动,SDK自动重连;每次CONNECTED重新订阅命令,发布在线与GPIO状态。broker离线时本例记录LOCAL_ONLY,新序号仍按测量轮次前进,本应用没有保存离线未提交的采样。SDK内部outbox中已提交的QoS1消息仍可能重传,断线检测前也存在已提交的竞态;接收方可看到重复或较旧序号,不能宣称每条恢复消息都是新采样。

publish返回message id只表示提交到客户端流程,QoS确认也不是下游界面或实体动作完成。停掉并恢复broker,要观察新的CONNECTED、订阅和后续新sequence;不能从旧retain状态推断恢复期间的测量都已送到。默认client_id只供一台节点,两台共用会互相踢线,扩展前先分别设计身份和topic。

做到这里,你应该看到
能分别说明Wi-Fi状态、MQTT状态、样本valid、序号与实体反馈;离线未提交采样没有被写成保证送达,SDK重传与新采样分别识别。

参考:[1] ESP-IDF v5.5.5 MQTT TCP上游完整应用[2] ESP-IDF v5.5.5 ESP-MQTT说明:事件、重连与遗嘱[8] Mosquitto 2.0.20正式源码与修订[10] Mosquitto官方配置:listener与allow_anonymous

07 / MQTT双向链路

完整节点主程序与HAL归属

下方是工程完整main.c,网络封装与I2C HAL在下载包中;官方协议C文件保持原字节,方便按固定提交核对。GPIO4配置为INPUT_OUTPUT,使电平读回可用;它没有光学传感器,日志会明确说明。

先修改自己的menuconfig,不把SSID、密码或broker私密信息贴进代码和公开日志。项目只控制普通LED,不把这个命令边界泛化为其他执行器。

代码 / 配置
// SPDX-License-Identifier: MIT
#include <string.h>
#include <inttypes.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/event_groups.h"
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "mqtt_client.h"
#include "sdkconfig.h"
#include "net.h"
#include "sensirion_hal_idf.h"
#include "sensirion_i2c_hal.h"
#include "sht4x_i2c.h"
static const char *TAG = "N04";
static const char *COMMAND_TOPIC = "hixinwu/n04/led/set";
static const char *STATE_TOPIC = "hixinwu/n04/led/state";
static const char *AVAILABILITY_TOPIC = "hixinwu/n04/availability";
static const char *TELEMETRY_TOPIC = "hixinwu/n04/telemetry";
static EventGroupHandle_t mqtt_flags;
static void publish_led(esp_mqtt_client_handle_t client) {
    esp_mqtt_client_publish(client, STATE_TOPIC, gpio_get_level(GPIO_NUM_4) ? "ON" : "OFF", 0, 1, 1);
}
static void mqtt_event(void *arg, esp_event_base_t base, int32_t id, void *data) {
    (void)arg; (void)base;
    esp_mqtt_event_handle_t event = data;
    if (id == MQTT_EVENT_CONNECTED) {
        xEventGroupSetBits(mqtt_flags, BIT0);
        ESP_LOGI(TAG, "MQTT_CONNECTED: subscribing to command on each connection");
        esp_mqtt_client_subscribe(event->client, COMMAND_TOPIC, 1);
        esp_mqtt_client_publish(event->client, AVAILABILITY_TOPIC, "online", 0, 1, 1);
        publish_led(event->client);
    } else if (id == MQTT_EVENT_DISCONNECTED) {
        xEventGroupClearBits(mqtt_flags, BIT0); ESP_LOGW(TAG, "MQTT_DISCONNECTED; SDK reconnect enabled");
    } else if (id == MQTT_EVENT_DATA) {
        const bool topic_ok = event->topic && event->topic_len == strlen(COMMAND_TOPIC) && !memcmp(event->topic, COMMAND_TOPIC, event->topic_len);
        // Bounded whole-message commands only. Never apply the first fragment of a longer message.
        const bool whole = event->current_data_offset == 0 && event->data_len == event->total_data_len && event->data_len <= 3;
        const bool turn_on = whole && event->data_len == 2 && !memcmp(event->data, "ON", 2);
        const bool turn_off = whole && event->data_len == 3 && !memcmp(event->data, "OFF", 3);
        if (topic_ok && !event->retain && (turn_on || turn_off)) {
            gpio_set_level(GPIO_NUM_4, turn_on); publish_led(event->client);
            ESP_LOGI(TAG, "LED gpio-digital-level=%s (not an optical measurement)", turn_on ? "ON" : "OFF");
        } else ESP_LOGW(TAG, "COMMAND_REJECTED: exact non-retained ON/OFF whole payload required");
    } else if (id == MQTT_EVENT_ERROR) ESP_LOGW(TAG, "MQTT_ERROR; inspect local broker and network, credentials are not logged");
}
void app_main(void) {
    gpio_reset_pin(GPIO_NUM_4); gpio_set_direction(GPIO_NUM_4, GPIO_MODE_INPUT_OUTPUT); gpio_set_level(GPIO_NUM_4, 0);
    mqtt_flags = xEventGroupCreate(); if (!mqtt_flags) { ESP_LOGE(TAG, "allocation failed"); return; }
    net_start(); sensirion_i2c_hal_init(); sht4x_init(0x44);
    ESP_LOGI(TAG, "IDF=%s SHT4x C driver=1.1.0 PID4885 SDA8/SCL9 100kHz; hardware unverified", esp_get_idf_version());
    const esp_mqtt_client_config_t config = {.broker.address.uri = CONFIG_DEMO_BROKER_URL, .credentials.client_id = "hixinwu-n04-demo", .session.last_will = {.topic = AVAILABILITY_TOPIC, .msg = "offline", .qos = 1, .retain = 1}, .network = {.timeout_ms = 3000, .reconnect_timeout_ms = 3000}};
    esp_mqtt_client_handle_t client = esp_mqtt_client_init(&config);
    if (!client) { ESP_LOGE(TAG, "MQTT init failed"); return; }
    ESP_ERROR_CHECK(esp_mqtt_client_register_event(client, ESP_EVENT_ANY_ID, mqtt_event, NULL));
    bool started = false; uint32_t sequence = 0, failures = 0;
    for (;;) {
        if (!started && net_online()) { ESP_ERROR_CHECK(esp_mqtt_client_start(client)); started = true; }
        int32_t temp_milli = 0, humidity_milli = 0;
        int16_t result = demo_i2c_ready() ? sht4x_measure_high_precision(&temp_milli, &humidity_milli) : -1;
        char payload[256]; ++sequence;
        if (result == 0) {
            failures = 0;
            int32_t displayed_rh = humidity_milli < 0 ? 0 : humidity_milli > 100000 ? 100000 : humidity_milli;
            snprintf(payload, sizeof(payload), "{\"sequence\":%" PRIu32 ",\"uptime_ms\":%" PRIi64 ",\"valid\":true,\"temperature_c\":%.3f,\"humidity_raw_percent\":%.3f,\"humidity_percent\":%.3f}", sequence, esp_timer_get_time()/1000, temp_milli/1000.0, humidity_milli/1000.0, displayed_rh/1000.0);
        } else {
            ++failures;
            snprintf(payload, sizeof(payload), "{\"sequence\":%" PRIu32 ",\"valid\":false,\"temperature_c\":null,\"humidity_percent\":null,\"sensor_error\":%d}", sequence, result);
            ESP_LOGW(TAG, "SENSOR_ERROR=%d HAL=%s; current values invalid", result, esp_err_to_name(demo_i2c_last_error()));
            if (failures % 3 == 0 && demo_i2c_ready()) ESP_LOGW(TAG, "soft_reset result=%d", sht4x_soft_reset());
        }
        if (started && (xEventGroupGetBits(mqtt_flags) & BIT0)) {
            int message = esp_mqtt_client_publish(client, TELEMETRY_TOPIC, payload, 0, 1, 0);
            ESP_LOGI(TAG, "publish queued_id=%d valid=%u; queued/ack is not physical delivery proof", message, result == 0);
        } else ESP_LOGW(TAG, "LOCAL_ONLY sequence=%" PRIu32 " mqtt=offline; no app sample buffer; submitted SDK QoS1 may retry", sequence);
        vTaskDelay(pdMS_TO_TICKS(5000));
    }
}

参考:[1] ESP-IDF v5.5.5 MQTT TCP上游完整应用[2] ESP-IDF v5.5.5 ESP-MQTT说明:事件、重连与遗嘱[3] ESP-IDF v5.5.5 新I2C master bus/device接口[4] Sensirion SHT4x C驱动1.1.0:命令、等待与单位[5] Sensirion C驱动1.1.0 公共I2C/CRC实现

08 / MQTT双向链路

把失败注入、恢复和证据留下来

订阅端先看到这台板的新sequence与有效读数,再分别发送ON/OFF、toggle与超长命令,对照GPIO报告和实体LED。停/启broker验证重连重订阅;使用事先安装的SDA开关观察valid:false与null,再恢复并确认后续新测量。每项填写observations.csv,保留真实错误与条件,不用预期文字代填。

ONLINE却连不上broker,先用同网主机pub/sub检查IP、监听地址和端口;MQTT正常而sensor_error,回到S01的0x44、供电、8/9与CRC。本站已经实际编译,并在主机运行Mosquitto2.0.20验证QoS1、晚订阅读取retain与清除;没有本站ESP32联网、实体动作、实物CRC注入或broker恢复证据,四维状态如实区分。结束后停临时broker和订阅器,清测试retain。

做到这里,你应该看到
真实测量、主机合成消息与未执行实板步骤分别可追溯。

参考:[1] ESP-IDF v5.5.5 MQTT TCP上游完整应用[2] ESP-IDF v5.5.5 ESP-MQTT说明:事件、重连与遗嘱[3] ESP-IDF v5.5.5 新I2C master bus/device接口[5] Sensirion C驱动1.1.0 公共I2C/CRC实现[7] Adafruit PID4885针脚:VIN/3Vo、0x44、10k上拉[8] Mosquitto 2.0.20正式源码与修订[10] Mosquitto官方配置:listener与allow_anonymous

BACK TO THE SOURCE

资料来源与版本说明

按指定板级修订、框架与上游源码制作。真实构建、主机辅助检查与实板行为分别记录;没有把主机测试或示意图写成本站硬件结果。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。

  1. ESP-IDF v5.5.5 MQTT TCP上游完整应用
  2. ESP-IDF v5.5.5 ESP-MQTT说明:事件、重连与遗嘱
  3. ESP-IDF v5.5.5 新I2C master bus/device接口
  4. Sensirion SHT4x C驱动1.1.0:命令、等待与单位
  5. Sensirion C驱动1.1.0 公共I2C/CRC实现
  6. SHT4x数据手册V6.4:FD命令、等待、CRC与换算
  7. Adafruit PID4885针脚:VIN/3Vo、0x44、10k上拉
  8. Mosquitto 2.0.20正式源码与修订
  9. 乐鑫 DevKitC-1 v1.1 固定文档:修订、供电与排针
  10. Mosquitto官方配置:listener与allow_anonymous
  11. Kingbright WP7113ID数据手册