diff --git a/README.md b/README.md index a615c04..bed830e 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ - 告警推送:超阈值边沿事件上报 - 状态上报:周期性遥测(含模式与执行器状态) - Wi-Fi 配网:SoftAP + Captive Portal +- 状态网页:独立 HTTP 状态看板与 JSON API(端口 8080) ## 系统架构 @@ -28,6 +29,32 @@ - `components/mqtt_control/`:MQTT 连接、发布、控制指令解析 - `main/auto_ctrl_thresholds.*`:阈值存取与校验 - `main/auto_alerts.*`:告警判定与回调分发 +- `main/status_web.*`:独立状态网页服务(HTTP 8080) + +## 状态网页(独立于配网页) + +- 配网页面:`http://192.168.4.1`(仅 SoftAP 配网阶段) +- 状态页面:`http://<设备STA_IP>:8080/` +- 状态 API:`http://<设备STA_IP>:8080/api/status` + +说明: +- 两个网页服务独立运行,端口不同、职责不同。 +- 状态页用于运行态观测,不承载 Wi-Fi 配网流程。 + +`/api/status` 当前主要字段: +- `temp`、`hum`、`soil`、`lux`:传感器字符串值 +- `pump`、`light`:执行器状态(`on/off`) +- `mode`:控制模式(`auto/manual`) +- `soil_on`、`soil_off`、`light_on`、`light_off`:自动控制阈值 +- `wifi_status`:Wi-Fi 状态(`idle/provisioning/connecting/connected/failed/timeout`) +- `sta_ip`:STA 当前 IP +- `mqtt_connected`:MQTT 连接状态(布尔) +- `i2c_ready`、`soil_sensor_ready`:关键外设初始化状态(布尔) +- `loop_counter`:主循环计数 +- `uptime_ms`:设备运行时长(毫秒) +- `free_heap`、`min_free_heap`、`largest_block`:堆内存指标 +- `app_version`:固件版本字符串 +- `snapshot_update_ms`、`snapshot_update_count`、`snapshot_age_ms`:状态快照时间与更新统计 ## 运行逻辑 diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index b55239d..32fb956 100755 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,4 +1,4 @@ -idf_component_register(SRCS "main.c" "auto_ctrl_thresholds.c" "auto_alerts.c" +idf_component_register(SRCS "main.c" "auto_ctrl_thresholds.c" "auto_alerts.c" "status_web.c" INCLUDE_DIRS "." - REQUIRES wifi-connect mqtt_control esp_lvgl_port lvgl_st7735s_use i2c_master_messager io_device_control console_simple_init console console_user_cmds capactive_soil_moisture_sensor_V2.0 ui + REQUIRES wifi-connect mqtt_control esp_lvgl_port lvgl_st7735s_use i2c_master_messager io_device_control console_simple_init console console_user_cmds capactive_soil_moisture_sensor_V2.0 ui esp_app_format ) diff --git a/main/main.c b/main/main.c index 2e35c04..6996d25 100755 --- a/main/main.c +++ b/main/main.c @@ -18,6 +18,7 @@ #include "auto_ctrl_thresholds.h" #include "auto_alerts.h" #include "mqtt_control.h" // MQTT 控制接口 +#include "status_web.h" // 配置宏定义:BH1750 光照传感器是否启用(默认禁用) #ifndef CONFIG_I2C_MASTER_MESSAGER_BH1750_ENABLE @@ -64,6 +65,7 @@ #define BOTANY_MQTT_ALERT_TOPIC "topic/alert/esp32_iothome_001" // MQTT 遥测数据上报周期(毫秒) #define BOTANY_MQTT_TELEMETRY_PERIOD_MS 5000 +#define BOTANY_STATUS_WEB_PORT 8080 // 日志标签 static const char *TAG = "main"; @@ -82,6 +84,9 @@ static bool s_pump_on = false; static bool s_light_on = false; // 全局变量:自动控制模式使能(true=自动,false=手动) static bool s_auto_control_enabled = true; +static bool s_i2c_ready = false; +static bool s_soil_sensor_ready = false; +static uint32_t s_main_loop_counter = 0; /** * @brief 发布当前完整状态快照(含阈值)到传感器主题 @@ -119,6 +124,34 @@ static esp_err_t publish_telemetry_snapshot(void) return mqtt_control_publish_sensor(telemetry_payload, 0, 0); } +static void update_status_web_snapshot(void) +{ + status_web_snapshot_t snap = {0}; + snprintf(snap.temp, sizeof(snap.temp), "%s", s_air_temp[0] ? s_air_temp : "--"); + snprintf(snap.hum, sizeof(snap.hum), "%s", s_air_hum[0] ? s_air_hum : "--"); + snprintf(snap.soil, sizeof(snap.soil), "%s", s_soil[0] ? s_soil : "--"); + snprintf(snap.lux, sizeof(snap.lux), "%s", s_lux[0] ? s_lux : "--"); + snap.pump_on = s_pump_on; + snap.light_on = s_light_on; + snap.auto_mode = s_auto_control_enabled; + + auto_ctrl_thresholds_t thresholds = {0}; + auto_ctrl_thresholds_get(&thresholds); + snap.soil_on_threshold = thresholds.pump_on_soil_below_pct; + snap.soil_off_threshold = thresholds.pump_off_soil_above_pct; + snap.light_on_threshold = thresholds.light_on_lux_below; + snap.light_off_threshold = thresholds.light_off_lux_above; + snap.i2c_ready = s_i2c_ready; + snap.soil_sensor_ready = s_soil_sensor_ready; + snap.loop_counter = s_main_loop_counter; + + esp_err_t ret = status_web_update(&snap); + if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) + { + ESP_LOGW(TAG, "status web update failed: %s", esp_err_to_name(ret)); + } +} + /** * @brief MQTT 控制命令处理函数 * @@ -504,6 +537,7 @@ void app_main(void) else { i2c_ready = true; + s_i2c_ready = true; } // 初始化电容式土壤湿度传感器(GPIO0 / ADC1_CH0)。 @@ -525,11 +559,15 @@ void app_main(void) else { soil_ready = true; + s_soil_sensor_ready = true; } // 按需求:仅在 Wi-Fi 确认连通后再初始化 MQTT和console。 wait_for_wifi_connected(); + // 独立状态网页(端口 8080),与配网页面(端口 80)互不干扰。 + ESP_ERROR_CHECK(status_web_start(BOTANY_STATUS_WEB_PORT)); + ESP_ERROR_CHECK(mqtt_control_register_command_handler(mqtt_control_command_handler, NULL)); ESP_ERROR_CHECK(mqtt_control_start()); // 启动 MQTT 客户端 @@ -554,6 +592,8 @@ void app_main(void) for (;;) { + s_main_loop_counter++; + // 预留给 MQTT 回调动态更新阈值:每个周期读取最新配置。 auto_ctrl_thresholds_get(&thresholds); @@ -612,6 +652,8 @@ void app_main(void) light_lux, &thresholds); + update_status_web_snapshot(); + telemetry_elapsed_ms += 1000; if (telemetry_elapsed_ms >= BOTANY_MQTT_TELEMETRY_PERIOD_MS) { diff --git a/main/status_web.c b/main/status_web.c new file mode 100644 index 0000000..da1773e --- /dev/null +++ b/main/status_web.c @@ -0,0 +1,256 @@ +#include "status_web.h" + +#include +#include + +#include "esp_app_desc.h" +#include "esp_check.h" +#include "esp_heap_caps.h" +#include "esp_http_server.h" +#include "esp_log.h" +#include "esp_netif.h" +#include "esp_netif_ip_addr.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "mqtt_control.h" +#include "wifi-connect.h" + +static const char *TAG = "status_web"; + +static httpd_handle_t s_server = NULL; +static SemaphoreHandle_t s_lock = NULL; +static uint64_t s_snapshot_update_ms = 0; +static uint32_t s_snapshot_update_count = 0; +static status_web_snapshot_t s_snapshot = { + .temp = "--", + .hum = "--", + .soil = "--", + .lux = "--", + .pump_on = false, + .light_on = false, + .auto_mode = true, + .soil_on_threshold = 35.0f, + .soil_off_threshold = 45.0f, + .light_on_threshold = 100.0f, + .light_off_threshold = 350.0f, + .i2c_ready = false, + .soil_sensor_ready = false, + .loop_counter = 0, +}; + +static const char *wifi_status_text(wifi_connect_status_t status) +{ + switch (status) { + case WIFI_CONNECT_STATUS_IDLE: return "idle"; + case WIFI_CONNECT_STATUS_PROVISIONING: return "provisioning"; + case WIFI_CONNECT_STATUS_CONNECTING: return "connecting"; + case WIFI_CONNECT_STATUS_CONNECTED: return "connected"; + case WIFI_CONNECT_STATUS_FAILED: return "failed"; + case WIFI_CONNECT_STATUS_TIMEOUT: return "timeout"; + default: return "unknown"; + } +} + +static void get_sta_ip_text(char *out, size_t out_size) +{ + if (out == NULL || out_size == 0) { + return; + } + + snprintf(out, out_size, "--"); + esp_netif_t *sta = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); + if (sta == NULL) { + return; + } + + esp_netif_ip_info_t ip_info; + if (esp_netif_get_ip_info(sta, &ip_info) == ESP_OK) { + snprintf(out, + out_size, + IPSTR, + IP2STR(&ip_info.ip)); + } +} + +static const char *s_page_html = + "" + "" + "BotanicalBuddy Status" + "
" + "

BotanicalBuddy 设备状态总览

" + "
独立状态服务(port 8080),每2秒自动刷新
" + "

传感与控制

" + "
空气温度
--
" + "
空气湿度
--
" + "
土壤湿度
--
" + "
光照强度
--
" + "
水泵
--
" + "
补光灯
--
" + "
控制模式
--
" + "
soil_on/off
--
" + "
light_on/off
--
" + "
" + "

连接与系统

" + "
Wi-Fi 状态
--
" + "
STA IP
--
" + "
MQTT 连接
--
" + "
I2C Ready
--
" + "
Soil Sensor Ready
--
" + "
主循环计数
--
" + "
运行时长
--
" + "
空闲堆/最小堆
--
" + "
固件版本
--
" + "
快照更新时间
--
" + "
快照序号
--
" + "
快照年龄
--
" + "
" + "

原始 JSON

{}
" + "" + "
"; + +static esp_err_t status_root_handler(httpd_req_t *req) +{ + httpd_resp_set_type(req, "text/html"); + return httpd_resp_send(req, s_page_html, HTTPD_RESP_USE_STRLEN); +} + +static esp_err_t status_api_handler(httpd_req_t *req) +{ + status_web_snapshot_t snap; + uint64_t snapshot_update_ms = 0; + uint32_t snapshot_update_count = 0; + xSemaphoreTake(s_lock, portMAX_DELAY); + snap = s_snapshot; + snapshot_update_ms = s_snapshot_update_ms; + snapshot_update_count = s_snapshot_update_count; + xSemaphoreGive(s_lock); + + const esp_app_desc_t *app_desc = esp_app_get_description(); + uint64_t now_ms = (uint64_t)(esp_timer_get_time() / 1000); + uint64_t snapshot_age_ms = (snapshot_update_ms > 0 && now_ms >= snapshot_update_ms) ? (now_ms - snapshot_update_ms) : 0; + const uint32_t free_heap = esp_get_free_heap_size(); + const uint32_t min_free_heap = esp_get_minimum_free_heap_size(); + const uint32_t largest_block = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT); + const bool mqtt_connected = mqtt_control_is_connected(); + const wifi_connect_status_t wifi_status = wifi_connect_get_status(); + char ip_text[16] = {0}; + get_sta_ip_text(ip_text, sizeof(ip_text)); + + char json[640]; + int len = snprintf(json, + sizeof(json), + "{\"temp\":\"%s\",\"hum\":\"%s\",\"soil\":\"%s\",\"lux\":\"%s\",\"pump\":\"%s\",\"light\":\"%s\",\"mode\":\"%s\",\"soil_on\":%.1f,\"soil_off\":%.1f,\"light_on\":%.1f,\"light_off\":%.1f,\"wifi_status\":\"%s\",\"sta_ip\":\"%s\",\"mqtt_connected\":%s,\"i2c_ready\":%s,\"soil_sensor_ready\":%s,\"loop_counter\":%lu,\"uptime_ms\":%llu,\"free_heap\":%lu,\"min_free_heap\":%lu,\"largest_block\":%lu,\"app_version\":\"%s\",\"snapshot_update_ms\":%llu,\"snapshot_update_count\":%lu,\"snapshot_age_ms\":%llu}", + snap.temp, + snap.hum, + snap.soil, + snap.lux, + snap.pump_on ? "on" : "off", + snap.light_on ? "on" : "off", + snap.auto_mode ? "auto" : "manual", + snap.soil_on_threshold, + snap.soil_off_threshold, + snap.light_on_threshold, + snap.light_off_threshold, + wifi_status_text(wifi_status), + ip_text, + mqtt_connected ? "true" : "false", + snap.i2c_ready ? "true" : "false", + snap.soil_sensor_ready ? "true" : "false", + (unsigned long)snap.loop_counter, + (unsigned long long)now_ms, + (unsigned long)free_heap, + (unsigned long)min_free_heap, + (unsigned long)largest_block, + app_desc ? app_desc->version : "unknown", + (unsigned long long)snapshot_update_ms, + (unsigned long)snapshot_update_count, + (unsigned long long)snapshot_age_ms); + if (len <= 0 || len >= (int)sizeof(json)) { + return ESP_FAIL; + } + + httpd_resp_set_type(req, "application/json"); + return httpd_resp_sendstr(req, json); +} + +esp_err_t status_web_start(uint16_t port) +{ + if (s_server != NULL) { + return ESP_OK; + } + + if (s_lock == NULL) { + s_lock = xSemaphoreCreateMutex(); + ESP_RETURN_ON_FALSE(s_lock != NULL, ESP_ERR_NO_MEM, TAG, "create mutex failed"); + } + + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.server_port = port; + config.ctrl_port = (uint16_t)(port + 1); + + ESP_RETURN_ON_ERROR(httpd_start(&s_server, &config), TAG, "httpd_start failed"); + + const httpd_uri_t root = { + .uri = "/", + .method = HTTP_GET, + .handler = status_root_handler, + .user_ctx = NULL, + }; + const httpd_uri_t api = { + .uri = "/api/status", + .method = HTTP_GET, + .handler = status_api_handler, + .user_ctx = NULL, + }; + + ESP_RETURN_ON_ERROR(httpd_register_uri_handler(s_server, &root), TAG, "register root failed"); + ESP_RETURN_ON_ERROR(httpd_register_uri_handler(s_server, &api), TAG, "register api failed"); + + ESP_LOGI(TAG, "status web started at port %u", (unsigned)port); + return ESP_OK; +} + +esp_err_t status_web_update(const status_web_snapshot_t *snapshot) +{ + ESP_RETURN_ON_FALSE(snapshot != NULL, ESP_ERR_INVALID_ARG, TAG, "snapshot is null"); + ESP_RETURN_ON_FALSE(s_lock != NULL, ESP_ERR_INVALID_STATE, TAG, "status web not started"); + + xSemaphoreTake(s_lock, portMAX_DELAY); + s_snapshot = *snapshot; + s_snapshot_update_ms = (uint64_t)(esp_timer_get_time() / 1000); + s_snapshot_update_count++; + xSemaphoreGive(s_lock); + return ESP_OK; +} diff --git a/main/status_web.h b/main/status_web.h new file mode 100644 index 0000000..0a43a30 --- /dev/null +++ b/main/status_web.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + char temp[16]; + char hum[16]; + char soil[16]; + char lux[16]; + bool pump_on; + bool light_on; + bool auto_mode; + float soil_on_threshold; + float soil_off_threshold; + float light_on_threshold; + float light_off_threshold; + bool i2c_ready; + bool soil_sensor_ready; + uint32_t loop_counter; +} status_web_snapshot_t; + +esp_err_t status_web_start(uint16_t port); +esp_err_t status_web_update(const status_web_snapshot_t *snapshot); + +#ifdef __cplusplus +} +#endif