feat: 添加独立状态网页服务,提供设备状态实时监控和API接口

This commit is contained in:
Wang Beihong
2026-03-07 14:14:23 +08:00
parent 981dc2b47c
commit 81da463124
5 changed files with 361 additions and 2 deletions

View File

@@ -15,6 +15,7 @@
- 告警推送:超阈值边沿事件上报 - 告警推送:超阈值边沿事件上报
- 状态上报:周期性遥测(含模式与执行器状态) - 状态上报:周期性遥测(含模式与执行器状态)
- Wi-Fi 配网SoftAP + Captive Portal - Wi-Fi 配网SoftAP + Captive Portal
- 状态网页:独立 HTTP 状态看板与 JSON API端口 8080
## 系统架构 ## 系统架构
@@ -28,6 +29,32 @@
- `components/mqtt_control/`MQTT 连接、发布、控制指令解析 - `components/mqtt_control/`MQTT 连接、发布、控制指令解析
- `main/auto_ctrl_thresholds.*`:阈值存取与校验 - `main/auto_ctrl_thresholds.*`:阈值存取与校验
- `main/auto_alerts.*`:告警判定与回调分发 - `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`:状态快照时间与更新统计
## 运行逻辑 ## 运行逻辑

View File

@@ -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 "." 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
) )

View File

@@ -18,6 +18,7 @@
#include "auto_ctrl_thresholds.h" #include "auto_ctrl_thresholds.h"
#include "auto_alerts.h" #include "auto_alerts.h"
#include "mqtt_control.h" // MQTT 控制接口 #include "mqtt_control.h" // MQTT 控制接口
#include "status_web.h"
// 配置宏定义BH1750 光照传感器是否启用(默认禁用) // 配置宏定义BH1750 光照传感器是否启用(默认禁用)
#ifndef CONFIG_I2C_MASTER_MESSAGER_BH1750_ENABLE #ifndef CONFIG_I2C_MASTER_MESSAGER_BH1750_ENABLE
@@ -64,6 +65,7 @@
#define BOTANY_MQTT_ALERT_TOPIC "topic/alert/esp32_iothome_001" #define BOTANY_MQTT_ALERT_TOPIC "topic/alert/esp32_iothome_001"
// MQTT 遥测数据上报周期(毫秒) // MQTT 遥测数据上报周期(毫秒)
#define BOTANY_MQTT_TELEMETRY_PERIOD_MS 5000 #define BOTANY_MQTT_TELEMETRY_PERIOD_MS 5000
#define BOTANY_STATUS_WEB_PORT 8080
// 日志标签 // 日志标签
static const char *TAG = "main"; static const char *TAG = "main";
@@ -82,6 +84,9 @@ static bool s_pump_on = false;
static bool s_light_on = false; static bool s_light_on = false;
// 全局变量自动控制模式使能true=自动false=手动) // 全局变量自动控制模式使能true=自动false=手动)
static bool s_auto_control_enabled = true; 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 发布当前完整状态快照(含阈值)到传感器主题 * @brief 发布当前完整状态快照(含阈值)到传感器主题
@@ -119,6 +124,34 @@ static esp_err_t publish_telemetry_snapshot(void)
return mqtt_control_publish_sensor(telemetry_payload, 0, 0); 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 控制命令处理函数 * @brief MQTT 控制命令处理函数
* *
@@ -504,6 +537,7 @@ void app_main(void)
else else
{ {
i2c_ready = true; i2c_ready = true;
s_i2c_ready = true;
} }
// 初始化电容式土壤湿度传感器GPIO0 / ADC1_CH0 // 初始化电容式土壤湿度传感器GPIO0 / ADC1_CH0
@@ -525,11 +559,15 @@ void app_main(void)
else else
{ {
soil_ready = true; soil_ready = true;
s_soil_sensor_ready = true;
} }
// 按需求:仅在 Wi-Fi 确认连通后再初始化 MQTT和console。 // 按需求:仅在 Wi-Fi 确认连通后再初始化 MQTT和console。
wait_for_wifi_connected(); 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_register_command_handler(mqtt_control_command_handler, NULL));
ESP_ERROR_CHECK(mqtt_control_start()); // 启动 MQTT 客户端 ESP_ERROR_CHECK(mqtt_control_start()); // 启动 MQTT 客户端
@@ -554,6 +592,8 @@ void app_main(void)
for (;;) for (;;)
{ {
s_main_loop_counter++;
// 预留给 MQTT 回调动态更新阈值:每个周期读取最新配置。 // 预留给 MQTT 回调动态更新阈值:每个周期读取最新配置。
auto_ctrl_thresholds_get(&thresholds); auto_ctrl_thresholds_get(&thresholds);
@@ -612,6 +652,8 @@ void app_main(void)
light_lux, light_lux,
&thresholds); &thresholds);
update_status_web_snapshot();
telemetry_elapsed_ms += 1000; telemetry_elapsed_ms += 1000;
if (telemetry_elapsed_ms >= BOTANY_MQTT_TELEMETRY_PERIOD_MS) if (telemetry_elapsed_ms >= BOTANY_MQTT_TELEMETRY_PERIOD_MS)
{ {

256
main/status_web.c Normal file
View File

@@ -0,0 +1,256 @@
#include "status_web.h"
#include <stdio.h>
#include <string.h>
#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 =
"<!doctype html><html><head><meta charset='utf-8'/>"
"<meta name='viewport' content='width=device-width, initial-scale=1'/>"
"<title>BotanicalBuddy Status</title>"
"<style>"
"body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#eef2f7;margin:0;padding:14px;color:#111827;}"
".wrap{max-width:1080px;margin:0 auto;background:#fff;border-radius:14px;padding:14px;box-shadow:0 8px 24px rgba(0,0,0,.08);}"
"h1{font-size:20px;margin:0 0 10px;}"
".meta{font-size:12px;color:#6b7280;margin-bottom:10px;}"
".sec{margin-top:10px;}"
".sec h2{font-size:14px;margin:0 0 6px;color:#374151;}"
".grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;}"
"@media(max-width:900px){.grid{grid-template-columns:1fr 1fr;}}"
"@media(max-width:560px){.grid{grid-template-columns:1fr;}}"
".card{padding:10px;border:1px solid #e5e7eb;border-radius:10px;background:#fafafa;}"
".k{font-size:12px;color:#6b7280;}"
".v{font-size:17px;font-weight:600;margin-top:2px;word-break:break-all;}"
"#raw{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;background:#0f172a;color:#e2e8f0;padding:10px;border-radius:10px;overflow:auto;max-height:260px;}"
"button{margin-top:10px;border:none;border-radius:8px;background:#1d4ed8;color:#fff;padding:8px 12px;cursor:pointer;}"
"</style></head><body><div class='wrap'>"
"<h1>BotanicalBuddy 设备状态总览</h1>"
"<div class='meta'>独立状态服务port 8080每2秒自动刷新</div>"
"<div class='sec'><h2>传感与控制</h2><div class='grid'>"
"<div class='card'><div class='k'>空气温度</div><div id='temp' class='v'>--</div></div>"
"<div class='card'><div class='k'>空气湿度</div><div id='hum' class='v'>--</div></div>"
"<div class='card'><div class='k'>土壤湿度</div><div id='soil' class='v'>--</div></div>"
"<div class='card'><div class='k'>光照强度</div><div id='lux' class='v'>--</div></div>"
"<div class='card'><div class='k'>水泵</div><div id='pump' class='v'>--</div></div>"
"<div class='card'><div class='k'>补光灯</div><div id='light' class='v'>--</div></div>"
"<div class='card'><div class='k'>控制模式</div><div id='mode' class='v'>--</div></div>"
"<div class='card'><div class='k'>soil_on/off</div><div id='soil_th' class='v'>--</div></div>"
"<div class='card'><div class='k'>light_on/off</div><div id='light_th' class='v'>--</div></div>"
"</div></div>"
"<div class='sec'><h2>连接与系统</h2><div class='grid'>"
"<div class='card'><div class='k'>Wi-Fi 状态</div><div id='wifi' class='v'>--</div></div>"
"<div class='card'><div class='k'>STA IP</div><div id='ip' class='v'>--</div></div>"
"<div class='card'><div class='k'>MQTT 连接</div><div id='mqtt' class='v'>--</div></div>"
"<div class='card'><div class='k'>I2C Ready</div><div id='i2c' class='v'>--</div></div>"
"<div class='card'><div class='k'>Soil Sensor Ready</div><div id='soil_ready' class='v'>--</div></div>"
"<div class='card'><div class='k'>主循环计数</div><div id='loop' class='v'>--</div></div>"
"<div class='card'><div class='k'>运行时长</div><div id='uptime' class='v'>--</div></div>"
"<div class='card'><div class='k'>空闲堆/最小堆</div><div id='heap' class='v'>--</div></div>"
"<div class='card'><div class='k'>固件版本</div><div id='ver' class='v'>--</div></div>"
"<div class='card'><div class='k'>快照更新时间</div><div id='snap' class='v'>--</div></div>"
"<div class='card'><div class='k'>快照序号</div><div id='seq' class='v'>--</div></div>"
"<div class='card'><div class='k'>快照年龄</div><div id='age' class='v'>--</div></div>"
"</div></div>"
"<div class='sec'><h2>原始 JSON</h2><pre id='raw'>{}</pre></div>"
"<button onclick='loadStatus()'>立即刷新</button>"
"</div><script>"
"function onoff(v){return v?'on':'off';}"
"function yn(v){return v?'yes':'no';}"
"function fmtMs(ms){if(ms<1000)return ms+' ms';const s=Math.floor(ms/1000);if(s<60)return s+' s';const m=Math.floor(s/60);const rs=s%60;if(m<60)return m+'m '+rs+'s';const h=Math.floor(m/60);return h+'h '+(m%60)+'m';}"
"async function loadStatus(){try{const r=await fetch('/api/status');const d=await r.json();"
"temp.textContent=d.temp;hum.textContent=d.hum;soil.textContent=d.soil;lux.textContent=d.lux;"
"pump.textContent=d.pump;light.textContent=d.light;mode.textContent=d.mode;"
"soil_th.textContent=`${d.soil_on}/${d.soil_off}`;light_th.textContent=`${d.light_on}/${d.light_off}`;"
"wifi.textContent=d.wifi_status;ip.textContent=d.sta_ip;mqtt.textContent=onoff(d.mqtt_connected);"
"i2c.textContent=yn(d.i2c_ready);soil_ready.textContent=yn(d.soil_sensor_ready);"
"loop.textContent=d.loop_counter;uptime.textContent=fmtMs(d.uptime_ms);"
"heap.textContent=`${d.free_heap}/${d.min_free_heap}`;ver.textContent=d.app_version;"
"snap.textContent=d.snapshot_update_ms;seq.textContent=d.snapshot_update_count;age.textContent=fmtMs(d.snapshot_age_ms);"
"raw.textContent=JSON.stringify(d,null,2);"
"}catch(e){raw.textContent='读取失败: '+e;}}"
"setInterval(loadStatus,2000);loadStatus();"
"</script></body></html>";
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;
}

34
main/status_web.h Normal file
View File

@@ -0,0 +1,34 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#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