diff --git a/components/agri_env/CMakeLists.txt b/components/agri_env/CMakeLists.txt new file mode 100644 index 0000000..92198fd --- /dev/null +++ b/components/agri_env/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "agri_env.c" + INCLUDE_DIRS "include" + REQUIRES driver mqtt cjson esp_timer esp_event relay_ctrl +) diff --git a/components/agri_env/Kconfig.projbuild b/components/agri_env/Kconfig.projbuild new file mode 100644 index 0000000..5020c71 --- /dev/null +++ b/components/agri_env/Kconfig.projbuild @@ -0,0 +1,26 @@ +menu "MQTT 配置参数" +config AGRI_ENV_MQTT_BROKER_URI + string "MQTT 服务器地址" + default "" + +config AGRI_ENV_MQTT_USERNAME + string "MQTT 用户名" + default "" + +config AGRI_ENV_MQTT_PASSWORD + string "MQTT 密码" + default "" + +config AGRI_ENV_MQTT_CLIENT_ID + string "MQTT Client ID" + default "agri-env-monitor" + +config AGRI_ENV_MQTT_PUBLISH_TOPIC + string "MQTT 发布主题" + default "agri/env/data" + +config AGRI_ENV_MQTT_SUBSCRIBE_TOPIC + string "MQTT 订阅主题" + default "agri/env/cmd" + +endmenu diff --git a/components/agri_env/agri_env.c b/components/agri_env/agri_env.c new file mode 100644 index 0000000..afdfbdf --- /dev/null +++ b/components/agri_env/agri_env.c @@ -0,0 +1,261 @@ +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +#include "esp_check.h" +#include "esp_err.h" +#include "esp_event.h" +#include "esp_log.h" +#include "mqtt_client.h" +#include "cJSON.h" +#include "relay_ctrl.h" + +#include "agri_env.h" + +static const char *TAG = "agri_env"; + +/** + * @brief 农业环境模块上下文结构体 + * 包含 MQTT 客户端句柄、连接状态以及用于线程安全的互斥锁 + */ +typedef struct { + SemaphoreHandle_t lock; /*!< 互斥锁,保护共享资源 */ + + esp_mqtt_client_handle_t mqtt_client; /*!< ESP MQTT 客户端句柄 */ + bool mqtt_connected; /*!< MQTT 连接状态标识 */ +} agri_env_ctx_t; + +static agri_env_ctx_t s_ctx; + +/** + * @brief 规范化 MQTT 代理 URI + * + * 将 menuconfig 中的原始字符串处理为标准形式(例如加上 mqtt:// 前缀或默认端口 1883) + * + * @param buffer 存储结果的缓冲区 + * @param buffer_size 缓冲区大小 + * @param was_prefixed [out] 如果进行了修整或添加前缀,则设为 true + * @return const char* 返回规范化后的字符串指针,失败返回 NULL + */ +static const char *agri_env_get_normalized_mqtt_uri(char *buffer, size_t buffer_size, bool *was_prefixed) +{ + const char *uri = CONFIG_AGRI_ENV_MQTT_BROKER_URI; + if (was_prefixed != NULL) { + *was_prefixed = false; + } + + if (uri == NULL || uri[0] == '\0') { + return NULL; + } + + // 去除前导空白字符 + while (*uri == ' ' || *uri == '\t' || *uri == '\r' || *uri == '\n') { + ++uri; + } + + size_t uri_len = strlen(uri); + // 去除尾部空白字符 + while (uri_len > 0 && (uri[uri_len - 1] == ' ' || uri[uri_len - 1] == '\t' || uri[uri_len - 1] == '\r' || uri[uri_len - 1] == '\n')) { + --uri_len; + } + + if (uri_len == 0) { + return NULL; + } + + // 如果已经包含协议前缀 (如 mqtt://), 直接复制并返回 + if (strstr(uri, "://") != NULL) { + if (uri_len + 1 >= buffer_size) { + return NULL; + } + memcpy(buffer, uri, uri_len); + buffer[uri_len] = '\0'; + return buffer; + } + + // 自动补全协议前缀和默认端口 + const bool has_port = memchr(uri, ':', uri_len) != NULL; + int written; + if (has_port) { + written = snprintf(buffer, buffer_size, "mqtt://%.*s", (int)uri_len, uri); + } else { + written = snprintf(buffer, buffer_size, "mqtt://%.*s:1883", (int)uri_len, uri); + } + + if (written < 0 || written >= (int)buffer_size) { + return NULL; + } + + if (was_prefixed != NULL) { + *was_prefixed = true; + } + + return buffer; +} + +/** + * @brief MQTT 事件处理回调函数 + * + * 处理连接、断开、接收数据等事件 + */ +static void agri_env_mqtt_event_handler(void *handler_args, esp_event_base_t base, int32_t event_id, void *event_data) +{ + (void)handler_args; + (void)base; + + esp_mqtt_event_handle_t event = (esp_mqtt_event_handle_t)event_data; + if (event == NULL) { + return; + } + + if (event_id == MQTT_EVENT_CONNECTED) { + xSemaphoreTake(s_ctx.lock, portMAX_DELAY); + s_ctx.mqtt_connected = true; + xSemaphoreGive(s_ctx.lock); + ESP_LOGI(TAG, "MQTT 已连接"); + + // 连接成功后订阅配置的主题 + if (strlen(CONFIG_AGRI_ENV_MQTT_SUBSCRIBE_TOPIC) > 0) { + esp_mqtt_client_subscribe(s_ctx.mqtt_client, CONFIG_AGRI_ENV_MQTT_SUBSCRIBE_TOPIC, 0); + ESP_LOGI(TAG, "已订阅主题: %s", CONFIG_AGRI_ENV_MQTT_SUBSCRIBE_TOPIC); + } + } else if (event_id == MQTT_EVENT_DISCONNECTED) { + xSemaphoreTake(s_ctx.lock, portMAX_DELAY); + s_ctx.mqtt_connected = false; + xSemaphoreGive(s_ctx.lock); + ESP_LOGW(TAG, "MQTT 已断开连接"); + } else if (event_id == MQTT_EVENT_DATA) { + ESP_LOGI(TAG, "收到 MQTT 消息: topic=%.*s payload=%.*s", + event->topic_len, + event->topic, + event->data_len, + event->data); + } +} + +/** + * @brief 启动 MQTT 客户端 + */ +esp_err_t agri_env_mqtt_start(void) +{ + if (s_ctx.lock == NULL) { + s_ctx.lock = xSemaphoreCreateMutex(); + ESP_RETURN_ON_FALSE(s_ctx.lock != NULL, ESP_ERR_NO_MEM, TAG, "互斥锁创建失败"); + } + + if (s_ctx.mqtt_client != NULL) { + return ESP_OK; + } + + char mqtt_uri[256]; + bool mqtt_uri_prefixed = false; + const char *normalized_uri = agri_env_get_normalized_mqtt_uri(mqtt_uri, sizeof(mqtt_uri), &mqtt_uri_prefixed); + + if (normalized_uri == NULL) { + ESP_LOGW(TAG, "MQTT Broker URI 为空,请在 menuconfig 中填写"); + return ESP_ERR_INVALID_STATE; + } + + if (mqtt_uri_prefixed) { + ESP_LOGW(TAG, "MQTT Broker URI 已规范化为: %s", normalized_uri); + } + + // MQTT 客户端配置 + const esp_mqtt_client_config_t mqtt_cfg = { + .broker.address.uri = normalized_uri, + .credentials.client_id = CONFIG_AGRI_ENV_MQTT_CLIENT_ID, + .credentials.username = CONFIG_AGRI_ENV_MQTT_USERNAME, + .credentials.authentication.password = CONFIG_AGRI_ENV_MQTT_PASSWORD, + .session.protocol_ver = MQTT_PROTOCOL_V_3_1, // 使用 MQTT v3.1 协议 + }; + + s_ctx.mqtt_client = esp_mqtt_client_init(&mqtt_cfg); + ESP_RETURN_ON_FALSE(s_ctx.mqtt_client != NULL, ESP_FAIL, TAG, "MQTT 客户端初始化失败"); + + // 注册所有 MQTT 事件的回调 + ESP_RETURN_ON_ERROR( + esp_mqtt_client_register_event(s_ctx.mqtt_client, MQTT_EVENT_ANY, agri_env_mqtt_event_handler, NULL), + TAG, + "MQTT 注册事件失败"); + + // 启动 MQTT 客户端任务 + ESP_RETURN_ON_ERROR(esp_mqtt_client_start(s_ctx.mqtt_client), TAG, "MQTT 启动失败"); + return ESP_OK; +} + +/** + * @brief 停止并销毁 MQTT 客户端 + */ +esp_err_t agri_env_mqtt_stop(void) +{ + if (s_ctx.mqtt_client == NULL) { + return ESP_OK; + } + + esp_err_t err = esp_mqtt_client_stop(s_ctx.mqtt_client); + if (err != ESP_OK) { + return err; + } + + err = esp_mqtt_client_destroy(s_ctx.mqtt_client); + if (err != ESP_OK) { + return err; + } + + xSemaphoreTake(s_ctx.lock, portMAX_DELAY); + s_ctx.mqtt_client = NULL; + s_ctx.mqtt_connected = false; + xSemaphoreGive(s_ctx.lock); + + return ESP_OK; +} + +/** + * @brief 检查 MQTT 是否已成功连接 + * + * @return true 已连接, false 未连接 + */ +bool agri_env_mqtt_is_connected(void) +{ + bool connected = false; + if (s_ctx.lock == NULL) { + return false; + } + + xSemaphoreTake(s_ctx.lock, portMAX_DELAY); + connected = s_ctx.mqtt_connected; + xSemaphoreGive(s_ctx.lock); + + return connected; +} + +/** + * @brief 发布数据到指定主题 + * + * 当前处于“仅 MQTT”模式,发布内容为固定的 JSON 数据:{"mode":"mqtt_only"} + * + * @param topic 目标主题 + * @param qos 服务质量等级 + * @param retain 保留消息标识 + * @return esp_err_t 成功返回 ESP_OK,失败返回相应错误码 + */ +esp_err_t agri_env_mqtt_publish(const char *topic, const char *payload, int qos, int retain) +{ + ESP_RETURN_ON_FALSE(topic != NULL && topic[0] != '\0', ESP_ERR_INVALID_ARG, TAG, "主题为空"); + ESP_RETURN_ON_FALSE(payload != NULL, ESP_ERR_INVALID_ARG, TAG, "内容为空"); + ESP_RETURN_ON_FALSE(s_ctx.mqtt_client != NULL, ESP_ERR_INVALID_STATE, TAG, "MQTT 客户端未启动"); + ESP_RETURN_ON_FALSE(agri_env_mqtt_is_connected(), ESP_ERR_INVALID_STATE, TAG, "MQTT 未连接"); + + int msg_id = esp_mqtt_client_publish(s_ctx.mqtt_client, topic, payload, 0, qos, retain); + + ESP_RETURN_ON_FALSE(msg_id >= 0, ESP_FAIL, TAG, "MQTT 发布失败"); + return ESP_OK; +} + +esp_err_t agri_env_mqtt_publish_latest(const char *topic, int qos, int retain) +{ + static const char *payload = "{\"mode\":\"mqtt_only\"}"; + return agri_env_mqtt_publish(topic, payload, qos, retain); +} diff --git a/components/agri_env/include/agri_env.h b/components/agri_env/include/agri_env.h new file mode 100644 index 0000000..79aba50 --- /dev/null +++ b/components/agri_env/include/agri_env.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* 启动 MQTT 客户端连接。 */ +esp_err_t agri_env_mqtt_start(void); +/* 停止 MQTT 客户端连接并释放资源。 */ +esp_err_t agri_env_mqtt_stop(void); +/* 查询 MQTT 当前是否已连接。 */ +bool agri_env_mqtt_is_connected(void); +/* 发布指定的 JSON 载荷到指定主题。 */ +esp_err_t agri_env_mqtt_publish(const char *topic, const char *payload, int qos, int retain); +/* 发布固定 MQTT-only 心跳载荷到指定主题。 */ +esp_err_t agri_env_mqtt_publish_latest(const char *topic, int qos, int retain); + +#ifdef __cplusplus +} +#endif diff --git a/dependencies.lock b/dependencies.lock index 50c2beb..baa1f15 100644 --- a/dependencies.lock +++ b/dependencies.lock @@ -13,6 +13,16 @@ dependencies: registry_url: https://components.espressif.com/ type: service version: 1.0.0 + espressif/cjson: + component_hash: e788323270d90738662d66fffa910bfe1fba019bba087f01557e70c40485b469 + dependencies: + - name: idf + require: private + version: '>=5.0' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.7.19~2 espressif/cmake_utilities: component_hash: 351350613ceafba240b761b4ea991e0f231ac7a9f59a9ee901f751bddc0bb18f dependencies: @@ -51,6 +61,16 @@ dependencies: registry_url: https://components.espressif.com type: service version: 1.5.1 + espressif/mqtt: + component_hash: ffdad5659706b4dc14bc63f8eb73ef765efa015bf7e9adf71c813d52a2dc9342 + dependencies: + - name: idf + require: private + version: '>=5.3' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.0.0 espressif/sensor_hub: component_hash: b3e72f3e2d10e165b972b17fff889f294b1a685769efacf73ec8f1e92152d1d5 dependencies: @@ -92,9 +112,11 @@ dependencies: version: 1.1.3 direct_dependencies: - espressif/aht30 +- espressif/cjson - espressif/esp_lvgl_port +- espressif/mqtt - idf - supcik/hx711 -manifest_hash: 09736a2502ce314a6733743671e919311d43e602eb74410732a63f10ed3aa60a +manifest_hash: f379725444325394085b05956aaab66b9a797466dc72d898cd7645dbe6cb359c target: esp32s3 version: 2.0.0 diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 9817749..48da1e5 100755 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,3 +1,3 @@ idf_component_register(SRCS "main.cpp" INCLUDE_DIRS "." - REQUIRES nvs_flash esp_wifi sntp_time aht30 esp_event esp_system wifi-connect ui lvgl_st7789_use efuse relay_ctrl bh1750 MQ-2 JW01 human_door fire_sensor hx711 su-03t) + REQUIRES nvs_flash agri_env esp_wifi sntp_time aht30 esp_event esp_system wifi-connect ui lvgl_st7789_use efuse relay_ctrl bh1750 MQ-2 JW01 human_door fire_sensor hx711 su-03t) diff --git a/main/idf_component.yml b/main/idf_component.yml index 09bb203..59aa6e7 100644 --- a/main/idf_component.yml +++ b/main/idf_component.yml @@ -17,3 +17,5 @@ dependencies: espressif/esp_lvgl_port: ^2.7.2 espressif/aht30: ^1.0.0 supcik/hx711: ^1.1.3 + espressif/mqtt: ^1.0.0 + espressif/cjson: ^1.7.19~2 diff --git a/main/main.cpp b/main/main.cpp index 382876c..2afd424 100644 --- a/main/main.cpp +++ b/main/main.cpp @@ -37,6 +37,7 @@ #include "fire_sensor.h" #include "hx711.hpp" #include "su-03t.h" +#include "agri_env.h" #define TAG "MAIN" #define CO2_SPOILAGE_THRESHOLD_PPM 1500.0f @@ -97,7 +98,8 @@ static void reconfigure_twdt(uint32_t timeout_ms, uint32_t idle_core_mask) }; esp_err_t ret = esp_task_wdt_reconfigure(&twdt_cfg); - if (ret != ESP_OK) { + if (ret != ESP_OK) + { ESP_LOGW(TAG, "TWDT reconfigure failed: %s", esp_err_to_name(ret)); } } @@ -227,33 +229,41 @@ static void relay_status_task(void *arg) { bool relay_on = false; - if (relay_ctrl_get(RELAY_CTRL_ID_1, &relay_on) == ESP_OK) { + if (relay_ctrl_get(RELAY_CTRL_ID_1, &relay_on) == ESP_OK) + { set_var_fan_status(relay_status_text(relay_on)); - if (s_env_data_lock) { + if (s_env_data_lock) + { xSemaphoreTake(s_env_data_lock, portMAX_DELAY); s_env_data.fan_on = relay_on; xSemaphoreGive(s_env_data_lock); } } - if (relay_ctrl_get(RELAY_CTRL_ID_2, &relay_on) == ESP_OK) { + if (relay_ctrl_get(RELAY_CTRL_ID_2, &relay_on) == ESP_OK) + { set_var_light_status(relay_status_text(relay_on)); - if (s_env_data_lock) { + if (s_env_data_lock) + { xSemaphoreTake(s_env_data_lock, portMAX_DELAY); s_env_data.light_on = relay_on; xSemaphoreGive(s_env_data_lock); } } - if (relay_ctrl_get(RELAY_CTRL_ID_3, &relay_on) == ESP_OK) { + if (relay_ctrl_get(RELAY_CTRL_ID_3, &relay_on) == ESP_OK) + { set_var_cool_status(relay_status_text(relay_on)); - if (s_env_data_lock) { + if (s_env_data_lock) + { xSemaphoreTake(s_env_data_lock, portMAX_DELAY); s_env_data.cool_on = relay_on; xSemaphoreGive(s_env_data_lock); } } - if (relay_ctrl_get(RELAY_CTRL_ID_4, &relay_on) == ESP_OK) { + if (relay_ctrl_get(RELAY_CTRL_ID_4, &relay_on) == ESP_OK) + { set_var_hot_status(relay_status_text(relay_on)); - if (s_env_data_lock) { + if (s_env_data_lock) + { xSemaphoreTake(s_env_data_lock, portMAX_DELAY); s_env_data.hot_on = relay_on; xSemaphoreGive(s_env_data_lock); @@ -272,9 +282,11 @@ static void sntp_task(void *arg) { (void)arg; - if (wait_for_wifi_connected(pdMS_TO_TICKS(15000))) { + if (wait_for_wifi_connected(pdMS_TO_TICKS(15000))) + { esp_err_t sntp_ret = sntp_timp_sync_time(10000); - if (sntp_ret != ESP_OK) { + if (sntp_ret != ESP_OK) + { ESP_LOGW(TAG, "SNTP sync failed: %s", esp_err_to_name(sntp_ret)); } } @@ -289,20 +301,24 @@ static void sntp_task(void *arg) static void su03t_rx_callback(const su03t_frame_t *frame, void *user_ctx) { (void)user_ctx; - if (frame == NULL) { + if (frame == NULL) + { return; } char hex_buf[256]; size_t pos = 0; - for (size_t i = 0; i < frame->params_len && pos + 4 < sizeof(hex_buf); ++i) { + for (size_t i = 0; i < frame->params_len && pos + 4 < sizeof(hex_buf); ++i) + { int n = snprintf(&hex_buf[pos], sizeof(hex_buf) - pos, "%02X ", frame->params[i]); - if (n <= 0) { + if (n <= 0) + { break; } pos += (size_t)n; } - if (pos == 0) { + if (pos == 0) + { snprintf(hex_buf, sizeof(hex_buf), "(no params)"); } @@ -311,7 +327,8 @@ static void su03t_rx_callback(const su03t_frame_t *frame, void *user_ctx) (unsigned)frame->params_len, hex_buf); - if (s_env_data_lock) { + if (s_env_data_lock) + { xSemaphoreTake(s_env_data_lock, portMAX_DELAY); s_env_data.su03t_last_msgno = frame->msgno; s_env_data.su03t_rx_count++; @@ -332,9 +349,11 @@ static void hx711_task(void *arg) int tare_ok_count = 0; // 上电空载自动去皮:当前重量作为 0g 基准 - for (int i = 0; i < HX711_TARE_SAMPLES; ++i) { + for (int i = 0; i < HX711_TARE_SAMPLES; ++i) + { int32_t raw = hx711.Read(pdMS_TO_TICKS(HX711_READ_TIMEOUT_MS)); - if (raw != HX711::kUndefined) { + if (raw != HX711::kUndefined) + { tare_sum += raw; tare_ok_count++; } @@ -342,10 +361,13 @@ static void hx711_task(void *arg) } int32_t tare_offset = 0; - if (tare_ok_count > 0) { + if (tare_ok_count > 0) + { tare_offset = (int32_t)(tare_sum / tare_ok_count); ESP_LOGI(TAG, "HX711 tare done: raw0=%ld, samples=%d", (long)tare_offset, tare_ok_count); - } else { + } + else + { ESP_LOGW(TAG, "HX711 tare failed, use 0 as offset"); } @@ -355,14 +377,18 @@ static void hx711_task(void *arg) bool display_locked = false; uint32_t stable_count = 0; uint32_t err_cnt = 0; - for (;;) { + for (;;) + { int32_t value = hx711.Read(pdMS_TO_TICKS(HX711_READ_TIMEOUT_MS)); - if (value != HX711::kUndefined) { + if (value != HX711::kUndefined) + { float weight_g = ((float)(value - tare_offset)) / HX711_COUNTS_PER_GRAM; - if (fabsf(weight_g) < HX711_ZERO_DEADBAND_G) { + if (fabsf(weight_g) < HX711_ZERO_DEADBAND_G) + { weight_g = 0.0f; } - if (weight_g < 0.0f) { + if (weight_g < 0.0f) + { weight_g = 0.0f; } @@ -370,7 +396,8 @@ static void hx711_task(void *arg) filtered_weight_g = filtered_weight_g * (1.0f - HX711_FILTER_ALPHA) + weight_g * HX711_FILTER_ALPHA; float rounded_weight_g = roundf(filtered_weight_g * 100.0f) / 100.0f; - if (!display_initialized) { + if (!display_initialized) + { display_weight_g = rounded_weight_g; display_initialized = true; } @@ -378,40 +405,53 @@ static void hx711_task(void *arg) float diff_from_display = fabsf(rounded_weight_g - display_weight_g); // 稳定后锁定显示,重量明显变化时再解锁并继续更新 - if (display_locked) { - if (diff_from_display >= HX711_UNLOCK_DELTA_G) { + if (display_locked) + { + if (diff_from_display >= HX711_UNLOCK_DELTA_G) + { display_locked = false; stable_count = 0; } } - if (!display_locked) { - if (diff_from_display <= HX711_STABLE_BAND_G) { - if (stable_count < HX711_STABLE_SAMPLES) { + if (!display_locked) + { + if (diff_from_display <= HX711_STABLE_BAND_G) + { + if (stable_count < HX711_STABLE_SAMPLES) + { stable_count++; } - if (stable_count >= HX711_STABLE_SAMPLES) { + if (stable_count >= HX711_STABLE_SAMPLES) + { display_locked = true; } - } else { + } + else + { stable_count = 0; } - if (diff_from_display >= HX711_UPDATE_MIN_STEP_G) { + if (diff_from_display >= HX711_UPDATE_MIN_STEP_G) + { display_weight_g = rounded_weight_g; } } set_var_ice_weight(display_weight_g); - if (s_env_data_lock) { + if (s_env_data_lock) + { xSemaphoreTake(s_env_data_lock, portMAX_DELAY); s_env_data.ice_weight = display_weight_g; xSemaphoreGive(s_env_data_lock); } err_cnt = 0; - } else { - if ((++err_cnt % 20) == 0) { + } + else + { + if ((++err_cnt % 20) == 0) + { ESP_LOGW(TAG, "HX711 read timeout, check DOUT/SCK wiring and power"); } } @@ -449,6 +489,12 @@ extern "C" void app_main(void) if (wait_for_wifi_connected(pdMS_TO_TICKS(15000))) { set_var_system_ip(wifi_connect_get_ip()); + + esp_err_t err = agri_env_mqtt_start(); + if (err != ESP_OK) + { + ESP_LOGW(TAG, "MQTT 启动失败: %s", esp_err_to_name(err)); + } } // 4. 独立 SNTP 对时任务