#include "status_web.h" #include #include #include #include "cJSON.h" #include "auto_ctrl_thresholds.h" #include "esp_check.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 mqtt_control_command_handler_t s_control_handler = NULL; static void *s_control_user_ctx = NULL; static status_web_snapshot_t s_snapshot = { .temp = "--", .hum = "--", .lux = "--", .fan_on = false, .light_on = false, .hot_on = false, .cool_on = false, .auto_mode = true, .light_on_threshold = 100.0f, .light_off_threshold = 350.0f, .hot_on_temp_threshold = 18.0f, .hot_off_temp_threshold = 20.0f, .cool_on_temp_threshold = 30.0f, .cool_off_temp_threshold = 28.0f, .fan_on_hum_threshold = 80.0f, .fan_off_hum_threshold = 70.0f, .i2c_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 bool json_read_number(cJSON *root, const char *key, float *out) { cJSON *item = cJSON_GetObjectItemCaseSensitive(root, key); if (!cJSON_IsNumber(item) || out == NULL) { return false; } *out = (float)item->valuedouble; return true; } static bool json_read_bool(cJSON *root, const char *key, bool *out) { cJSON *item = cJSON_GetObjectItemCaseSensitive(root, key); if (item == NULL || out == NULL) { return false; } if (cJSON_IsBool(item)) { *out = cJSON_IsTrue(item); return true; } if (cJSON_IsNumber(item)) { *out = (item->valuedouble != 0.0); return true; } if (cJSON_IsString(item) && item->valuestring != NULL) { const char *s = item->valuestring; if (strcasecmp(s, "on") == 0 || strcasecmp(s, "true") == 0 || strcmp(s, "1") == 0) { *out = true; return true; } if (strcasecmp(s, "off") == 0 || strcasecmp(s, "false") == 0 || strcmp(s, "0") == 0) { *out = false; return true; } } return false; } static bool json_read_mode_auto(cJSON *root, const char *key, bool *out_auto) { cJSON *item = cJSON_GetObjectItemCaseSensitive(root, key); if (item == NULL || out_auto == NULL) { return false; } if (cJSON_IsString(item) && item->valuestring != NULL) { if (strcasecmp(item->valuestring, "auto") == 0) { *out_auto = true; return true; } if (strcasecmp(item->valuestring, "manual") == 0) { *out_auto = false; return true; } } if (cJSON_IsBool(item)) { *out_auto = cJSON_IsTrue(item); return true; } if (cJSON_IsNumber(item)) { *out_auto = (item->valuedouble != 0.0); return true; } return false; } 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" "
" "

智能粮仓终端设备状态总览

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

传感与控制

" "
空气温度
--
" "
空气湿度
--
" "
光照强度
--
" "
风扇
--
" "
补光灯
--
" "
加热
--
" "
制冷
--
" "
控制模式
--
manual
" "
light_on/off
--
" "
hot_on/off (C)
--
" "
cool_on/off (C)
--
" "
fan_hum_on/off (%)
--
" "
" "

参数设置

" "
light_on
" "
light_off
" "
hot_on_temp
" "
hot_off_temp
" "
cool_on_temp
" "
cool_off_temp
" "
fan_on_hum
" "
fan_off_hum
" "
" "

快捷控制

" "
模式
" "
风扇
" "
补光灯
" "
加热
" "
制冷
" "
" "

连接与系统

" "
Wi-Fi 状态
--
" "
STA IP
--
" "
MQTT 连接
--
" "
I2C Ready
--
" "
运行时长
--
" "
" "" "
"; 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_favicon_handler(httpd_req_t *req) { httpd_resp_set_status(req, "204 No Content"); return httpd_resp_send(req, NULL, 0); } static esp_err_t status_api_handler(httpd_req_t *req) { status_web_snapshot_t snap; uint64_t snapshot_update_ms = 0; xSemaphoreTake(s_lock, portMAX_DELAY); snap = s_snapshot; snapshot_update_ms = s_snapshot_update_ms; xSemaphoreGive(s_lock); uint64_t now_ms = (uint64_t)(esp_timer_get_time() / 1000); 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)); uint64_t uptime_ms = now_ms; if (snapshot_update_ms > 0 && now_ms < snapshot_update_ms) { uptime_ms = snapshot_update_ms; } char json[620]; int len = snprintf(json, sizeof(json), "{\"temp\":\"%s\",\"hum\":\"%s\",\"lux\":\"%s\",\"fan\":\"%s\",\"light\":\"%s\",\"hot\":\"%s\",\"cool\":\"%s\",\"mode\":\"%s\",\"light_on\":%.1f,\"light_off\":%.1f,\"hot_on_temp\":%.1f,\"hot_off_temp\":%.1f,\"cool_on_temp\":%.1f,\"cool_off_temp\":%.1f,\"fan_on_hum\":%.1f,\"fan_off_hum\":%.1f,\"wifi_status\":\"%s\",\"sta_ip\":\"%s\",\"mqtt_connected\":%s,\"i2c_ready\":%s,\"uptime_ms\":%llu}", snap.temp, snap.hum, snap.lux, snap.fan_on ? "on" : "off", snap.light_on ? "on" : "off", snap.hot_on ? "on" : "off", snap.cool_on ? "on" : "off", snap.auto_mode ? "auto" : "manual", snap.light_on_threshold, snap.light_off_threshold, snap.hot_on_temp_threshold, snap.hot_off_temp_threshold, snap.cool_on_temp_threshold, snap.cool_off_temp_threshold, snap.fan_on_hum_threshold, snap.fan_off_hum_threshold, wifi_status_text(wifi_status), ip_text, mqtt_connected ? "true" : "false", snap.i2c_ready ? "true" : "false", (unsigned long long)uptime_ms); if (len <= 0 || len >= (int)sizeof(json)) { return ESP_FAIL; } httpd_resp_set_type(req, "application/json"); return httpd_resp_sendstr(req, json); } static esp_err_t status_config_handler(httpd_req_t *req) { if (req->content_len <= 0 || req->content_len > 512) { httpd_resp_set_status(req, "400 Bad Request"); return httpd_resp_sendstr(req, "{\"ok\":false,\"error\":\"invalid content length\"}"); } char body[513] = {0}; int received = 0; while (received < req->content_len) { int ret = httpd_req_recv(req, body + received, req->content_len - received); if (ret <= 0) { httpd_resp_set_status(req, "400 Bad Request"); return httpd_resp_sendstr(req, "{\"ok\":false,\"error\":\"read body failed\"}"); } received += ret; } cJSON *root = cJSON_ParseWithLength(body, (size_t)req->content_len); if (root == NULL) { httpd_resp_set_status(req, "400 Bad Request"); return httpd_resp_sendstr(req, "{\"ok\":false,\"error\":\"invalid json\"}"); } float light_on = 0.0f; float light_off = 0.0f; float hot_on = 0.0f; float hot_off = 0.0f; float cool_on = 0.0f; float cool_off = 0.0f; float fan_on = 0.0f; float fan_off = 0.0f; bool ok = json_read_number(root, "light_on", &light_on) && json_read_number(root, "light_off", &light_off) && json_read_number(root, "hot_on_temp", &hot_on) && json_read_number(root, "hot_off_temp", &hot_off) && json_read_number(root, "cool_on_temp", &cool_on) && json_read_number(root, "cool_off_temp", &cool_off) && json_read_number(root, "fan_on_hum", &fan_on) && json_read_number(root, "fan_off_hum", &fan_off); if (!ok) { cJSON_Delete(root); httpd_resp_set_status(req, "400 Bad Request"); return httpd_resp_sendstr(req, "{\"ok\":false,\"error\":\"missing threshold fields\"}"); } esp_err_t set_ret = auto_ctrl_thresholds_set_values(light_on, light_off, hot_on, hot_off, cool_on, cool_off, fan_on, fan_off); cJSON_Delete(root); if (set_ret != ESP_OK) { ESP_LOGW(TAG, "web config reject: light(%.1f/%.1f) hot(%.1f/%.1f) cool(%.1f/%.1f) fan_hum(%.1f/%.1f), err=%s", light_on, light_off, hot_on, hot_off, cool_on, cool_off, fan_on, fan_off, esp_err_to_name(set_ret)); httpd_resp_set_status(req, "400 Bad Request"); return httpd_resp_sendstr(req, "{\"ok\":false,\"error\":\"invalid threshold range\"}"); } xSemaphoreTake(s_lock, portMAX_DELAY); s_snapshot.light_on_threshold = light_on; s_snapshot.light_off_threshold = light_off; s_snapshot.hot_on_temp_threshold = hot_on; s_snapshot.hot_off_temp_threshold = hot_off; s_snapshot.cool_on_temp_threshold = cool_on; s_snapshot.cool_off_temp_threshold = cool_off; s_snapshot.fan_on_hum_threshold = fan_on; s_snapshot.fan_off_hum_threshold = fan_off; s_snapshot_update_ms = (uint64_t)(esp_timer_get_time() / 1000); xSemaphoreGive(s_lock); ESP_LOGI(TAG, "web config saved: light(%.1f/%.1f) hot(%.1f/%.1f) cool(%.1f/%.1f) fan_hum(%.1f/%.1f)", light_on, light_off, hot_on, hot_off, cool_on, cool_off, fan_on, fan_off); httpd_resp_set_type(req, "application/json"); return httpd_resp_sendstr(req, "{\"ok\":true}"); } static esp_err_t status_control_handler(httpd_req_t *req) { if (s_control_handler == NULL) { httpd_resp_set_status(req, "503 Service Unavailable"); return httpd_resp_sendstr(req, "{\"ok\":false,\"error\":\"control handler not ready\"}"); } if (req->content_len <= 0 || req->content_len > 256) { httpd_resp_set_status(req, "400 Bad Request"); return httpd_resp_sendstr(req, "{\"ok\":false,\"error\":\"invalid content length\"}"); } char body[257] = {0}; int received = 0; while (received < req->content_len) { int ret = httpd_req_recv(req, body + received, req->content_len - received); if (ret <= 0) { httpd_resp_set_status(req, "400 Bad Request"); return httpd_resp_sendstr(req, "{\"ok\":false,\"error\":\"read body failed\"}"); } received += ret; } cJSON *root = cJSON_ParseWithLength(body, (size_t)req->content_len); if (root == NULL) { httpd_resp_set_status(req, "400 Bad Request"); return httpd_resp_sendstr(req, "{\"ok\":false,\"error\":\"invalid json\"}"); } mqtt_control_command_t cmd = {0}; cmd.has_mode = json_read_mode_auto(root, "mode", &cmd.auto_mode); cmd.has_fan = json_read_bool(root, "fan", &cmd.fan_on); cmd.has_light = json_read_bool(root, "light", &cmd.light_on); cmd.has_hot = json_read_bool(root, "hot", &cmd.hot_on); cmd.has_cool = json_read_bool(root, "cool", &cmd.cool_on); cJSON_Delete(root); if (!(cmd.has_mode || cmd.has_fan || cmd.has_light || cmd.has_hot || cmd.has_cool)) { httpd_resp_set_status(req, "400 Bad Request"); return httpd_resp_sendstr(req, "{\"ok\":false,\"error\":\"no valid control fields\"}"); } esp_err_t ret = s_control_handler(&cmd, s_control_user_ctx); if (ret != ESP_OK) { ESP_LOGW(TAG, "web control apply failed: %s", esp_err_to_name(ret)); httpd_resp_set_status(req, "500 Internal Server Error"); return httpd_resp_sendstr(req, "{\"ok\":false,\"error\":\"control apply failed\"}"); } xSemaphoreTake(s_lock, portMAX_DELAY); if (cmd.has_mode) { s_snapshot.auto_mode = cmd.auto_mode; } if (cmd.has_fan) { s_snapshot.fan_on = cmd.fan_on; } if (cmd.has_light) { s_snapshot.light_on = cmd.light_on; } if (cmd.has_hot) { s_snapshot.hot_on = cmd.hot_on; } if (cmd.has_cool) { s_snapshot.cool_on = cmd.cool_on; } s_snapshot_update_ms = (uint64_t)(esp_timer_get_time() / 1000); xSemaphoreGive(s_lock); ESP_LOGI(TAG, "web control ok: mode=%s fan=%s light=%s hot=%s cool=%s", cmd.has_mode ? (cmd.auto_mode ? "auto" : "manual") : "-", cmd.has_fan ? (cmd.fan_on ? "on" : "off") : "-", cmd.has_light ? (cmd.light_on ? "on" : "off") : "-", cmd.has_hot ? (cmd.hot_on ? "on" : "off") : "-", cmd.has_cool ? (cmd.cool_on ? "on" : "off") : "-"); httpd_resp_set_type(req, "application/json"); return httpd_resp_sendstr(req, "{\"ok\":true}"); } 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); config.lru_purge_enable = true; // Keep this <= (LWIP_MAX_SOCKETS - 3 internal sockets). // Current target allows 7 total, so 4 is the safe upper bound. config.max_open_sockets = 4; 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, }; const httpd_uri_t icon = { .uri = "/favicon.ico", .method = HTTP_GET, .handler = status_favicon_handler, .user_ctx = NULL, }; const httpd_uri_t cfg = { .uri = "/api/config", .method = HTTP_POST, .handler = status_config_handler, .user_ctx = NULL, }; const httpd_uri_t ctrl = { .uri = "/api/control", .method = HTTP_POST, .handler = status_control_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, &icon), TAG, "register favicon failed"); ESP_RETURN_ON_ERROR(httpd_register_uri_handler(s_server, &api), TAG, "register api failed"); ESP_RETURN_ON_ERROR(httpd_register_uri_handler(s_server, &cfg), TAG, "register config failed"); ESP_RETURN_ON_ERROR(httpd_register_uri_handler(s_server, &ctrl), TAG, "register control 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); xSemaphoreGive(s_lock); return ESP_OK; } esp_err_t status_web_register_control_handler(mqtt_control_command_handler_t handler, void *user_ctx) { s_control_handler = handler; s_control_user_ctx = user_ctx; return ESP_OK; }