完成初代的雏形设计

This commit is contained in:
Wang Beihong
2026-03-11 20:14:14 +08:00
commit 2f56316c18
63 changed files with 10594 additions and 0 deletions

4
main/CMakeLists.txt Normal file
View File

@@ -0,0 +1,4 @@
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 ui esp_app_format cjson
)

144
main/auto_alerts.c Normal file
View File

@@ -0,0 +1,144 @@
#include "auto_alerts.h"
#include "esp_check.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
static const char *TAG = "auto_alerts"; // 日志标签
// 用于保护全局状态的自旋锁(临界区)
static portMUX_TYPE s_alerts_lock = portMUX_INITIALIZER_UNLOCKED;
// 用户注册的回调函数
static auto_alert_callback_t s_callback = NULL;
// 回调函数的用户上下文指针
static void *s_user_ctx = NULL;
// 光照强度告警是否已激活
static bool s_light_alarm_active = false;
/**
* @brief 发送自动告警事件
*
* @param metric 告警指标类型(如光照强度)
* @param state 告警状态(告警或恢复正常)
* @param value 当前测量值
* @param threshold 触发告警的阈值
*/
static void auto_alerts_emit(auto_alert_metric_t metric,
auto_alert_state_t state,
float value,
float threshold)
{
auto_alert_event_t event = {
.metric = metric,
.state = state,
.value = value,
.threshold = threshold,
.timestamp_ms = esp_timer_get_time() / 1000, // 转换为毫秒时间戳
};
auto_alert_callback_t callback = NULL;
void *user_ctx = NULL;
// 进入临界区,安全读取回调和上下文
taskENTER_CRITICAL(&s_alerts_lock);
callback = s_callback;
user_ctx = s_user_ctx;
taskEXIT_CRITICAL(&s_alerts_lock);
if (callback != NULL)
{
callback(&event, user_ctx); // 调用用户注册的回调函数
}
// 打印日志信息
ESP_LOGI(TAG,
"alert metric=%d state=%d value=%.1f threshold=%.1f",
(int)event.metric,
(int)event.state,
event.value,
event.threshold);
}
/**
* @brief 初始化自动告警模块
*
* 将所有告警状态重置为未激活。
*/
void auto_alerts_init(void)
{
taskENTER_CRITICAL(&s_alerts_lock);
s_light_alarm_active = false;
taskEXIT_CRITICAL(&s_alerts_lock);
}
/**
* @brief 注册自动告警回调函数
*
* @param callback 用户定义的回调函数
* @param user_ctx 用户上下文指针
* @return esp_err_t 总是返回 ESP_OK
*/
esp_err_t auto_alerts_register_callback(auto_alert_callback_t callback, void *user_ctx)
{
taskENTER_CRITICAL(&s_alerts_lock);
s_callback = callback;
s_user_ctx = user_ctx;
taskEXIT_CRITICAL(&s_alerts_lock);
return ESP_OK;
}
/**
* @brief 根据当前传感器数据和阈值评估是否触发或解除告警
*
* @param light_valid 光照数据是否有效
* @param light_lux 当前光照强度单位lux
* @param thresholds 自动控制阈值配置结构体指针
*/
void auto_alerts_evaluate(bool light_valid,
float light_lux,
const auto_ctrl_thresholds_t *thresholds)
{
if (thresholds == NULL)
{
return; // 阈值为空,直接返回
}
// 处理光照强度告警逻辑
if (light_valid)
{
bool emit_alarm = false; // 是否需要触发告警
bool emit_recover = false; // 是否需要恢复通知
taskENTER_CRITICAL(&s_alerts_lock);
// 如果当前未告警,且光照强度低于开启补光灯的阈值,则触发告警
if (!s_light_alarm_active && light_lux < thresholds->light_on_lux_below)
{
s_light_alarm_active = true;
emit_alarm = true;
}
// 如果当前处于告警状态,且光照强度高于关闭补光灯的阈值,则恢复
else if (s_light_alarm_active && light_lux > thresholds->light_off_lux_above)
{
s_light_alarm_active = false;
emit_recover = true;
}
taskEXIT_CRITICAL(&s_alerts_lock);
if (emit_alarm)
{
auto_alerts_emit(AUTO_ALERT_METRIC_LIGHT_INTENSITY,
AUTO_ALERT_STATE_ALARM,
light_lux,
thresholds->light_on_lux_below);
}
if (emit_recover)
{
auto_alerts_emit(AUTO_ALERT_METRIC_LIGHT_INTENSITY,
AUTO_ALERT_STATE_NORMAL,
light_lux,
thresholds->light_off_lux_above);
}
}
}

45
main/auto_alerts.h Normal file
View File

@@ -0,0 +1,45 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "auto_ctrl_thresholds.h"
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
AUTO_ALERT_METRIC_LIGHT_INTENSITY = 1,
} auto_alert_metric_t;
typedef enum {
AUTO_ALERT_STATE_NORMAL = 0,
AUTO_ALERT_STATE_ALARM = 1,
} auto_alert_state_t;
typedef struct {
auto_alert_metric_t metric;
auto_alert_state_t state;
float value;
float threshold;
int64_t timestamp_ms;
} auto_alert_event_t;
typedef void (*auto_alert_callback_t)(const auto_alert_event_t *event, void *user_ctx);
// Reset internal state at boot.
void auto_alerts_init(void);
// Register callback sink (e.g. MQTT publisher). Passing NULL clears callback.
esp_err_t auto_alerts_register_callback(auto_alert_callback_t callback, void *user_ctx);
// Evaluate current sensor values and emit edge-triggered alert events.
void auto_alerts_evaluate(bool light_valid,
float light_lux,
const auto_ctrl_thresholds_t *thresholds);
#ifdef __cplusplus
}
#endif

199
main/auto_ctrl_thresholds.c Normal file
View File

@@ -0,0 +1,199 @@
#include "auto_ctrl_thresholds.h"
#include "freertos/FreeRTOS.h"
#include "esp_check.h"
// 默认光照强度低于此值时开启补光灯单位lux
#define DEFAULT_LIGHT_ON_LUX_BELOW 200.0f
// 默认光照强度高于此值时关闭补光灯单位lux
#define DEFAULT_LIGHT_OFF_LUX_ABOVE 350.0f
// 默认温度低于此值时开启加热(单位:摄氏度)
#define DEFAULT_HOT_ON_TEMP_BELOW_C 18.0f
// 默认温度高于此值时关闭加热(单位:摄氏度)
#define DEFAULT_HOT_OFF_TEMP_ABOVE_C 20.0f
// 默认温度高于此值时开启制冷(单位:摄氏度)
#define DEFAULT_COOL_ON_TEMP_ABOVE_C 30.0f
// 默认温度低于此值时关闭制冷(单位:摄氏度)
#define DEFAULT_COOL_OFF_TEMP_BELOW_C 28.0f
// 默认湿度高于此值时开启风扇(单位:%RH
#define DEFAULT_FAN_ON_HUMIDITY_ABOVE_PCT 80.0f
// 默认湿度低于此值时关闭风扇(单位:%RH
#define DEFAULT_FAN_OFF_HUMIDITY_BELOW_PCT 70.0f
// 用于保护阈值数据的自旋锁(临界区)
static portMUX_TYPE s_thresholds_lock = portMUX_INITIALIZER_UNLOCKED;
// 全局阈值配置结构体,初始化为默认值
static auto_ctrl_thresholds_t s_thresholds = {
.light_on_lux_below = DEFAULT_LIGHT_ON_LUX_BELOW,
.light_off_lux_above = DEFAULT_LIGHT_OFF_LUX_ABOVE,
.hot_on_temp_below_c = DEFAULT_HOT_ON_TEMP_BELOW_C,
.hot_off_temp_above_c = DEFAULT_HOT_OFF_TEMP_ABOVE_C,
.cool_on_temp_above_c = DEFAULT_COOL_ON_TEMP_ABOVE_C,
.cool_off_temp_below_c = DEFAULT_COOL_OFF_TEMP_BELOW_C,
.fan_on_humidity_above_pct = DEFAULT_FAN_ON_HUMIDITY_ABOVE_PCT,
.fan_off_humidity_below_pct = DEFAULT_FAN_OFF_HUMIDITY_BELOW_PCT,
};
/**
* @brief 验证自动控制阈值配置的有效性
*
* 检查指针非空、数值范围合法、启停阈值满足 on < off 等条件。
*
* @param cfg 待验证的阈值配置指针
* @return esp_err_t 验证结果ESP_OK 表示有效
*/
static esp_err_t auto_ctrl_thresholds_validate(const auto_ctrl_thresholds_t *cfg)
{
ESP_RETURN_ON_FALSE(cfg != NULL, ESP_ERR_INVALID_ARG, "auto_ctrl_thresholds", "cfg is null");
ESP_RETURN_ON_FALSE(cfg->light_on_lux_below >= 0.0f,
ESP_ERR_INVALID_ARG,
"auto_ctrl_thresholds",
"light_on_lux_below out of range");
ESP_RETURN_ON_FALSE(cfg->light_off_lux_above >= 0.0f,
ESP_ERR_INVALID_ARG,
"auto_ctrl_thresholds",
"light_off_lux_above out of range");
ESP_RETURN_ON_FALSE(cfg->light_on_lux_below < cfg->light_off_lux_above,
ESP_ERR_INVALID_ARG,
"auto_ctrl_thresholds",
"light thresholds must satisfy on < off");
ESP_RETURN_ON_FALSE(cfg->hot_on_temp_below_c >= -40.0f && cfg->hot_on_temp_below_c <= 125.0f,
ESP_ERR_INVALID_ARG,
"auto_ctrl_thresholds",
"hot_on_temp_below_c out of range");
ESP_RETURN_ON_FALSE(cfg->hot_off_temp_above_c >= -40.0f && cfg->hot_off_temp_above_c <= 125.0f,
ESP_ERR_INVALID_ARG,
"auto_ctrl_thresholds",
"hot_off_temp_above_c out of range");
ESP_RETURN_ON_FALSE(cfg->hot_on_temp_below_c < cfg->hot_off_temp_above_c,
ESP_ERR_INVALID_ARG,
"auto_ctrl_thresholds",
"hot thresholds must satisfy on < off");
ESP_RETURN_ON_FALSE(cfg->cool_off_temp_below_c >= -40.0f && cfg->cool_off_temp_below_c <= 125.0f,
ESP_ERR_INVALID_ARG,
"auto_ctrl_thresholds",
"cool_off_temp_below_c out of range");
ESP_RETURN_ON_FALSE(cfg->cool_on_temp_above_c >= -40.0f && cfg->cool_on_temp_above_c <= 125.0f,
ESP_ERR_INVALID_ARG,
"auto_ctrl_thresholds",
"cool_on_temp_above_c out of range");
ESP_RETURN_ON_FALSE(cfg->cool_off_temp_below_c < cfg->cool_on_temp_above_c,
ESP_ERR_INVALID_ARG,
"auto_ctrl_thresholds",
"cool thresholds must satisfy off < on");
ESP_RETURN_ON_FALSE(cfg->hot_off_temp_above_c <= cfg->cool_off_temp_below_c,
ESP_ERR_INVALID_ARG,
"auto_ctrl_thresholds",
"temperature thresholds overlap excessively");
ESP_RETURN_ON_FALSE(cfg->fan_on_humidity_above_pct >= 0.0f && cfg->fan_on_humidity_above_pct <= 100.0f,
ESP_ERR_INVALID_ARG,
"auto_ctrl_thresholds",
"fan_on_humidity_above_pct out of range");
ESP_RETURN_ON_FALSE(cfg->fan_off_humidity_below_pct >= 0.0f && cfg->fan_off_humidity_below_pct <= 100.0f,
ESP_ERR_INVALID_ARG,
"auto_ctrl_thresholds",
"fan_off_humidity_below_pct out of range");
ESP_RETURN_ON_FALSE(cfg->fan_off_humidity_below_pct < cfg->fan_on_humidity_above_pct,
ESP_ERR_INVALID_ARG,
"auto_ctrl_thresholds",
"fan humidity thresholds must satisfy off < on");
return ESP_OK;
}
/**
* @brief 初始化阈值为默认值
*
* 将全局阈值结构体重置为预设的默认配置。
*/
void auto_ctrl_thresholds_init_defaults(void)
{
const auto_ctrl_thresholds_t defaults = {
.light_on_lux_below = DEFAULT_LIGHT_ON_LUX_BELOW,
.light_off_lux_above = DEFAULT_LIGHT_OFF_LUX_ABOVE,
.hot_on_temp_below_c = DEFAULT_HOT_ON_TEMP_BELOW_C,
.hot_off_temp_above_c = DEFAULT_HOT_OFF_TEMP_ABOVE_C,
.cool_on_temp_above_c = DEFAULT_COOL_ON_TEMP_ABOVE_C,
.cool_off_temp_below_c = DEFAULT_COOL_OFF_TEMP_BELOW_C,
.fan_on_humidity_above_pct = DEFAULT_FAN_ON_HUMIDITY_ABOVE_PCT,
.fan_off_humidity_below_pct = DEFAULT_FAN_OFF_HUMIDITY_BELOW_PCT,
};
taskENTER_CRITICAL(&s_thresholds_lock);
s_thresholds = defaults;
taskEXIT_CRITICAL(&s_thresholds_lock);
}
/**
* @brief 获取当前阈值配置
*
* 安全地复制当前阈值到输出参数中。
*
* @param out 输出参数,指向接收阈值的结构体
*/
void auto_ctrl_thresholds_get(auto_ctrl_thresholds_t *out)
{
if (out == NULL) {
return;
}
taskENTER_CRITICAL(&s_thresholds_lock);
*out = s_thresholds;
taskEXIT_CRITICAL(&s_thresholds_lock);
}
/**
* @brief 设置新的阈值配置
*
* 验证输入配置有效性后,安全更新全局阈值。
*
* @param cfg 新的阈值配置指针
* @return esp_err_t 设置结果ESP_OK 表示成功
*/
esp_err_t auto_ctrl_thresholds_set(const auto_ctrl_thresholds_t *cfg)
{
ESP_RETURN_ON_ERROR(auto_ctrl_thresholds_validate(cfg), "auto_ctrl_thresholds", "invalid thresholds");
taskENTER_CRITICAL(&s_thresholds_lock);
s_thresholds = *cfg;
taskEXIT_CRITICAL(&s_thresholds_lock);
return ESP_OK;
}
/**
* @brief 通过独立参数设置阈值
*
* 提供一种更便捷的阈值设置方式,内部封装为结构体后调用 set 接口。
*
* @param light_on_lux_below 补光灯开启光照阈值lux
* @param light_off_lux_above 补光灯关闭光照阈值lux
* @return esp_err_t 设置结果
*/
esp_err_t auto_ctrl_thresholds_set_values(float light_on_lux_below,
float light_off_lux_above,
float hot_on_temp_below_c,
float hot_off_temp_above_c,
float cool_on_temp_above_c,
float cool_off_temp_below_c,
float fan_on_humidity_above_pct,
float fan_off_humidity_below_pct)
{
const auto_ctrl_thresholds_t cfg = {
.light_on_lux_below = light_on_lux_below,
.light_off_lux_above = light_off_lux_above,
.hot_on_temp_below_c = hot_on_temp_below_c,
.hot_off_temp_above_c = hot_off_temp_above_c,
.cool_on_temp_above_c = cool_on_temp_above_c,
.cool_off_temp_below_c = cool_off_temp_below_c,
.fan_on_humidity_above_pct = fan_on_humidity_above_pct,
.fan_off_humidity_below_pct = fan_off_humidity_below_pct,
};
return auto_ctrl_thresholds_set(&cfg);
}

View File

@@ -0,0 +1,41 @@
#pragma once
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
float light_on_lux_below;
float light_off_lux_above;
float hot_on_temp_below_c;
float hot_off_temp_above_c;
float cool_on_temp_above_c;
float cool_off_temp_below_c;
float fan_on_humidity_above_pct;
float fan_off_humidity_below_pct;
} auto_ctrl_thresholds_t;
// Initializes default thresholds once at boot.
void auto_ctrl_thresholds_init_defaults(void);
// Thread-safe snapshot read, intended for control loop usage.
void auto_ctrl_thresholds_get(auto_ctrl_thresholds_t *out);
// Thread-safe full update with range/order validation.
esp_err_t auto_ctrl_thresholds_set(const auto_ctrl_thresholds_t *cfg);
// Convenience API for MQTT callback usage.
esp_err_t auto_ctrl_thresholds_set_values(float light_on_lux_below,
float light_off_lux_above,
float hot_on_temp_below_c,
float hot_off_temp_above_c,
float cool_on_temp_above_c,
float cool_off_temp_below_c,
float fan_on_humidity_above_pct,
float fan_off_humidity_below_pct);
#ifdef __cplusplus
}
#endif

23
main/idf_component.yml Normal file
View File

@@ -0,0 +1,23 @@
## IDF Component Manager Manifest File
dependencies:
## Required IDF version
idf:
version: '>=4.1.0'
# # Put list of dependencies here
# # For components maintained by Espressif:
# component: "~1.0.0"
# # For 3rd party components:
# username/component: ">=1.0.0,<2.0.0"
# username2/component2:
# version: "~1.0.0"
# # For transient dependencies `public` flag can be set.
# # `public` flag doesn't have an effect dependencies of the `main` component.
# # All dependencies of `main` are public by default.
# public: true
espressif/esp_lvgl_port: ^2.7.2
espressif/bh1750: ^2.0.0
k0i05/esp_ahtxx: ^1.2.7
espressif/console_simple_init: ^1.1.0
espressif/mqtt: ^1.0.0
espressif/cjson: ^1.7.19

780
main/main.c Normal file
View File

@@ -0,0 +1,780 @@
#include <stdio.h>
#include <inttypes.h>
#include "sdkconfig.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_check.h"
#include "esp_log.h"
#include "wifi-connect.h"
#include "lvgl_st7735s_use.h"
#include "i2c_master_messager.h"
#include "io_device_control.h"
#include "console_simple_init.h" // 提供 console_cmd_user_register 和 console_cmd_all_register
#include "console_user_cmds.h"
#include "ui.h" // 使用EEZStudio提供的ui组件便于后续扩展
#include "esp_lvgl_port.h"
#include "vars.h" // 定义全局变量接口
#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
#define CONFIG_I2C_MASTER_MESSAGER_BH1750_ENABLE 0
#endif
// 配置宏定义AHT30 温湿度传感器是否启用(默认禁用)
#ifndef CONFIG_I2C_MASTER_MESSAGER_AHT30_ENABLE
#define CONFIG_I2C_MASTER_MESSAGER_AHT30_ENABLE 0
#endif
// 配置宏定义BH1750 读取周期毫秒默认500ms
#ifndef CONFIG_I2C_MASTER_MESSAGER_BH1750_READ_PERIOD_MS
#define CONFIG_I2C_MASTER_MESSAGER_BH1750_READ_PERIOD_MS 500
#endif
// 配置宏定义AHT30 读取周期毫秒默认2000ms
#ifndef CONFIG_I2C_MASTER_MESSAGER_AHT30_READ_PERIOD_MS
#define CONFIG_I2C_MASTER_MESSAGER_AHT30_READ_PERIOD_MS 2000
#endif
// 配置宏定义I2C 是否启用内部上拉电阻(默认启用)
#ifndef CONFIG_I2C_MASTER_MESSAGER_ENABLE_INTERNAL_PULLUP
#define CONFIG_I2C_MASTER_MESSAGER_ENABLE_INTERNAL_PULLUP 1
#endif
// I2C 端口配置
#define BOTANY_I2C_PORT I2C_NUM_0
// I2C SCL 引脚
#define BOTANY_I2C_SCL_GPIO GPIO_NUM_5
// I2C SDA 引脚
#define BOTANY_I2C_SDA_GPIO GPIO_NUM_4
// BH1750 使能标志
#define BOTANY_BH1750_ENABLE CONFIG_I2C_MASTER_MESSAGER_BH1750_ENABLE
// AHT30 使能标志
#define BOTANY_AHT30_ENABLE CONFIG_I2C_MASTER_MESSAGER_AHT30_ENABLE
// BH1750 读取周期
#define BOTANY_BH1750_PERIOD_MS CONFIG_I2C_MASTER_MESSAGER_BH1750_READ_PERIOD_MS
// AHT30 读取周期
#define BOTANY_AHT30_PERIOD_MS CONFIG_I2C_MASTER_MESSAGER_AHT30_READ_PERIOD_MS
// I2C 内部上拉使能
#define BOTANY_I2C_INTERNAL_PULLUP CONFIG_I2C_MASTER_MESSAGER_ENABLE_INTERNAL_PULLUP
// MQTT 告警主题
#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";
// 全局变量:存储空气温度字符串
static char s_air_temp[16];
// 全局变量:存储空气湿度字符串
static char s_air_hum[16];
// 全局变量:存储光照强度字符串
static char s_lux[16];
// 全局变量风扇状态true=开启false=关闭)
static bool s_fan_on = false;
// 全局变量补光灯状态true=开启false=关闭)
static bool s_light_on = false;
// 全局变量加热状态true=开启false=关闭)
static bool s_hot_on = false;
// 全局变量制冷状态true=开启false=关闭)
static bool s_cool_on = false;
// 全局变量自动控制模式使能true=自动false=手动)
static bool s_auto_control_enabled = true;
static bool s_i2c_ready = false;
static uint32_t s_main_loop_counter = 0;
/**
* @brief 发布当前完整状态快照(含阈值)到传感器主题
*/
static esp_err_t publish_telemetry_snapshot(void)
{
if (!mqtt_control_is_connected())
{
return ESP_ERR_INVALID_STATE;
}
auto_ctrl_thresholds_t thresholds = {0};
auto_ctrl_thresholds_get(&thresholds);
char telemetry_payload[512] = {0};
int len = snprintf(telemetry_payload,
sizeof(telemetry_payload),
"{\"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}",
s_air_temp,
s_air_hum,
s_lux,
s_fan_on ? "on" : "off",
s_light_on ? "on" : "off",
s_hot_on ? "on" : "off",
s_cool_on ? "on" : "off",
s_auto_control_enabled ? "auto" : "manual",
thresholds.light_on_lux_below,
thresholds.light_off_lux_above,
thresholds.hot_on_temp_below_c,
thresholds.hot_off_temp_above_c,
thresholds.cool_on_temp_above_c,
thresholds.cool_off_temp_below_c,
thresholds.fan_on_humidity_above_pct,
thresholds.fan_off_humidity_below_pct);
if (len <= 0 || len >= (int)sizeof(telemetry_payload))
{
return ESP_ERR_INVALID_SIZE;
}
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.lux, sizeof(snap.lux), "%s", s_lux[0] ? s_lux : "--");
snap.fan_on = s_fan_on;
snap.light_on = s_light_on;
snap.hot_on = s_hot_on;
snap.cool_on = s_cool_on;
snap.auto_mode = s_auto_control_enabled;
auto_ctrl_thresholds_t thresholds = {0};
auto_ctrl_thresholds_get(&thresholds);
snap.light_on_threshold = thresholds.light_on_lux_below;
snap.light_off_threshold = thresholds.light_off_lux_above;
snap.hot_on_temp_threshold = thresholds.hot_on_temp_below_c;
snap.hot_off_temp_threshold = thresholds.hot_off_temp_above_c;
snap.cool_on_temp_threshold = thresholds.cool_on_temp_above_c;
snap.cool_off_temp_threshold = thresholds.cool_off_temp_below_c;
snap.fan_on_hum_threshold = thresholds.fan_on_humidity_above_pct;
snap.fan_off_hum_threshold = thresholds.fan_off_humidity_below_pct;
snap.i2c_ready = s_i2c_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 控制命令处理函数
*
* 处理来自 MQTT 的控制命令,包括模式切换、阈值更新、水泵和补光灯控制。
*
* @param cmd 指向 MQTT 控制命令结构体的指针
* @param user_ctx 用户上下文(未使用)
* @return esp_err_t 处理结果
*/
static esp_err_t mqtt_control_command_handler(const mqtt_control_command_t *cmd, void *user_ctx)
{
(void)user_ctx;
ESP_RETURN_ON_FALSE(cmd != NULL, ESP_ERR_INVALID_ARG, TAG, "cmd is null");
esp_err_t final_ret = ESP_OK;
bool has_any_control = cmd->has_mode || cmd->has_thresholds || cmd->has_fan || cmd->has_light || cmd->has_hot || cmd->has_cool;
// 处理模式切换命令
if (cmd->has_mode)
{
s_auto_control_enabled = cmd->auto_mode;
ESP_LOGI(TAG, "MQTT 控制模式切换: %s", s_auto_control_enabled ? "auto" : "manual");
}
// 处理阈值更新命令
if (cmd->has_thresholds)
{
esp_err_t ret = auto_ctrl_thresholds_set_values(cmd->light_on_lux,
cmd->light_off_lux,
cmd->hot_on_temp_c,
cmd->hot_off_temp_c,
cmd->cool_on_temp_c,
cmd->cool_off_temp_c,
cmd->fan_on_hum_pct,
cmd->fan_off_hum_pct);
if (ret == ESP_OK)
{
ESP_LOGI(TAG,
"MQTT 更新阈值: light(%.1f/%.1f) hot(%.1f/%.1f) cool(%.1f/%.1f) fan_hum(%.1f/%.1f)",
cmd->light_on_lux,
cmd->light_off_lux,
cmd->hot_on_temp_c,
cmd->hot_off_temp_c,
cmd->cool_on_temp_c,
cmd->cool_off_temp_c,
cmd->fan_on_hum_pct,
cmd->fan_off_hum_pct);
}
else
{
ESP_LOGE(TAG, "设置阈值失败: %s", esp_err_to_name(ret));
final_ret = ret;
}
}
// 处理风扇控制命令
if (cmd->has_fan)
{
esp_err_t ret = io_device_control_set_fan(cmd->fan_on);
if (ret == ESP_OK)
{
s_fan_on = cmd->fan_on;
ESP_LOGI(TAG, "MQTT 控制风扇: %s", cmd->fan_on ? "on" : "off");
}
else
{
ESP_LOGE(TAG, "MQTT 控制风扇失败: %s", esp_err_to_name(ret));
final_ret = ret;
}
}
// 处理补光灯控制命令
if (cmd->has_light)
{
esp_err_t ret = io_device_control_set_light(cmd->light_on);
if (ret == ESP_OK)
{
s_light_on = cmd->light_on;
ESP_LOGI(TAG, "MQTT 控制补光灯: %s", cmd->light_on ? "on" : "off");
}
else
{
ESP_LOGE(TAG, "MQTT 控制补光灯失败: %s", esp_err_to_name(ret));
final_ret = ret;
}
}
if (cmd->has_hot)
{
esp_err_t ret = io_device_control_set_hot(cmd->hot_on);
if (ret == ESP_OK)
{
s_hot_on = cmd->hot_on;
if (s_hot_on) {
s_cool_on = false;
(void)io_device_control_set_cool(false);
}
ESP_LOGI(TAG, "MQTT 控制加热: %s", cmd->hot_on ? "on" : "off");
}
else
{
ESP_LOGE(TAG, "MQTT 控制加热失败: %s", esp_err_to_name(ret));
final_ret = ret;
}
}
if (cmd->has_cool)
{
esp_err_t ret = io_device_control_set_cool(cmd->cool_on);
if (ret == ESP_OK)
{
s_cool_on = cmd->cool_on;
if (s_cool_on) {
s_hot_on = false;
(void)io_device_control_set_hot(false);
}
ESP_LOGI(TAG, "MQTT 控制制冷: %s", cmd->cool_on ? "on" : "off");
}
else
{
ESP_LOGE(TAG, "MQTT 控制制冷失败: %s", esp_err_to_name(ret));
final_ret = ret;
}
}
// 任何控制指令处理后都立即上报最新状态(含阈值)作为回复。
if (has_any_control)
{
esp_err_t pub_ret = publish_telemetry_snapshot();
if (pub_ret != ESP_OK)
{
ESP_LOGW(TAG, "控制后立即上报失败: %s", esp_err_to_name(pub_ret));
if (final_ret == ESP_OK)
{
final_ret = pub_ret;
}
}
}
return final_ret;
}
/**
* @brief 将告警指标类型转换为字符串
*
* @param metric 告警指标类型
* @return const char* 对应的字符串表示
*/
static const char *alert_metric_text(auto_alert_metric_t metric)
{
switch (metric)
{
case AUTO_ALERT_METRIC_LIGHT_INTENSITY:
return "light";
default:
return "unknown";
}
}
/**
* @brief 将告警状态转换为字符串
*
* @param state 告警状态
* @return const char* 对应的字符串表示
*/
static const char *alert_state_text(auto_alert_state_t state)
{
switch (state)
{
case AUTO_ALERT_STATE_NORMAL:
return "normal";
case AUTO_ALERT_STATE_ALARM:
return "alarm";
default:
return "unknown";
}
}
/**
* @brief 自动告警 MQTT 回调函数
*
* 当自动告警模块触发事件时,通过此函数将告警信息以 JSON 格式发布到 MQTT。
*
* @param event 指向告警事件结构体的指针
* @param user_ctx 用户上下文(未使用)
*/
static void auto_alert_mqtt_callback(const auto_alert_event_t *event, void *user_ctx)
{
(void)user_ctx;
if (event == NULL)
{
return;
}
// 使用明文发送报警简单的 JSON 字符串,格式示例:{"metric":"light","state":"alarm"}
char payload[64] = {0};
int len = snprintf(payload,
sizeof(payload),
"{\"metric\":\"%s\",\"state\":\"%s\"}",
alert_metric_text(event->metric),
alert_state_text(event->state));
if (len <= 0 || len >= (int)sizeof(payload))
{
return;
}
if (!mqtt_control_is_connected())
{
return;
}
esp_err_t ret = mqtt_control_publish(BOTANY_MQTT_ALERT_TOPIC, payload, 1, 0);
if (ret != ESP_OK)
{
ESP_LOGE(TAG, "告警 MQTT 发布失败: %s", esp_err_to_name(ret));
}
}
/**
* @brief 自动控制逻辑更新函数
*
* 根据当前传感器数据和阈值,决定是否需要开启或关闭风扇、补光灯、加热和制冷。
*
* @param temp_valid 温度数据是否有效
* @param temp_c 当前温度(摄氏度)
* @param hum_valid 湿度数据是否有效
* @param hum_pct 当前湿度(%RH
* @param light_valid 光照数据是否有效
* @param light_lux 当前光照强度lux
* @param thresholds 指向阈值配置结构体的指针
* @param fan_on 指向当前风扇状态的指针(输入/输出)
* @param light_on 指向当前补光灯状态的指针(输入/输出)
* @param hot_on 指向当前加热状态的指针(输入/输出)
* @param cool_on 指向当前制冷状态的指针(输入/输出)
*/
static void auto_control_update(bool temp_valid,
float temp_c,
bool hum_valid,
float hum_pct,
bool light_valid,
float light_lux,
const auto_ctrl_thresholds_t *thresholds,
bool *fan_on,
bool *light_on,
bool *hot_on,
bool *cool_on)
{
bool desired_fan = *fan_on;
bool desired_light = *light_on;
bool desired_hot = *hot_on;
bool desired_cool = *cool_on;
// 根据湿度决定风扇状态
if (hum_valid)
{
if (!desired_fan && hum_pct > thresholds->fan_on_humidity_above_pct)
{
desired_fan = true;
}
else if (desired_fan && hum_pct < thresholds->fan_off_humidity_below_pct)
{
desired_fan = false;
}
}
// 根据温度决定加热/制冷状态
if (temp_valid)
{
if (!desired_hot && temp_c < thresholds->hot_on_temp_below_c)
{
desired_hot = true;
}
else if (desired_hot && temp_c > thresholds->hot_off_temp_above_c)
{
desired_hot = false;
}
if (!desired_cool && temp_c > thresholds->cool_on_temp_above_c)
{
desired_cool = true;
}
else if (desired_cool && temp_c < thresholds->cool_off_temp_below_c)
{
desired_cool = false;
}
}
// 防止加热与制冷同时开启
if (desired_hot)
{
desired_cool = false;
}
else if (desired_cool)
{
desired_hot = false;
}
// 根据光照强度决定补光灯状态
if (light_valid)
{
if (!desired_light && light_lux < thresholds->light_on_lux_below)
{
desired_light = true;
}
else if (desired_light && light_lux > thresholds->light_off_lux_above)
{
desired_light = false;
}
}
// 如果补光灯状态需要改变,则执行控制
if (desired_light != *light_on)
{
esp_err_t ret = io_device_control_set_light(desired_light);
if (ret == ESP_OK)
{
*light_on = desired_light;
ESP_LOGI(TAG,
"自动控制: 补光灯%s (光照=%.1f lux)",
desired_light ? "开启" : "关闭",
light_lux);
}
else
{
ESP_LOGE(TAG, "自动控制: 补光灯控制失败: %s", esp_err_to_name(ret));
}
}
if (desired_hot != *hot_on)
{
esp_err_t ret = io_device_control_set_hot(desired_hot);
if (ret == ESP_OK)
{
*hot_on = desired_hot;
ESP_LOGI(TAG,
"自动控制: 加热%s (温度=%.1f C)",
desired_hot ? "开启" : "关闭",
temp_c);
}
else
{
ESP_LOGE(TAG, "自动控制: 加热控制失败: %s", esp_err_to_name(ret));
}
}
if (desired_cool != *cool_on)
{
esp_err_t ret = io_device_control_set_cool(desired_cool);
if (ret == ESP_OK)
{
*cool_on = desired_cool;
ESP_LOGI(TAG,
"自动控制: 制冷%s (温度=%.1f C)",
desired_cool ? "开启" : "关闭",
temp_c);
}
else
{
ESP_LOGE(TAG, "自动控制: 制冷控制失败: %s", esp_err_to_name(ret));
}
}
if (desired_fan != *fan_on)
{
esp_err_t ret = io_device_control_set_fan(desired_fan);
if (ret == ESP_OK)
{
*fan_on = desired_fan;
ESP_LOGI(TAG,
"自动控制: 风扇%s (湿度=%.1f%%)",
desired_fan ? "开启" : "关闭",
hum_pct);
}
else
{
ESP_LOGE(TAG, "自动控制: 风扇控制失败: %s", esp_err_to_name(ret));
}
}
}
/**
* @brief UI 任务函数
*
* 单页面模式下仅刷新 UI多页面时每3秒切换一次。
*
* @param arg 任务参数(未使用)
*/
static void ui_task(void *arg)
{
(void)arg;
const bool multi_screen = (_SCREEN_ID_LAST > _SCREEN_ID_FIRST);
uint32_t elapsed_ms = 0;
enum ScreensEnum current = SCREEN_ID_MAIN;
const uint32_t switch_period_ms = 3000; // 每3秒切一次
for (;;)
{
lvgl_port_lock(0);
ui_tick();
elapsed_ms += 20;
if (multi_screen && elapsed_ms >= switch_period_ms) {
elapsed_ms = 0;
// 多页面时按顺序轮播
if (current >= _SCREEN_ID_LAST) {
current = _SCREEN_ID_FIRST;
} else {
current = (enum ScreensEnum)(current + 1);
}
loadScreen(current);
}
lvgl_port_unlock();
vTaskDelay(pdMS_TO_TICKS(20));
}
}
/**
* @brief 等待 Wi-Fi 连接成功
*
* 在初始化 console 之前,确保 Wi-Fi 已连接成功最多等待120秒。
*/
static void wait_for_wifi_connected(void)
{
const uint32_t timeout_s = 120;
uint32_t elapsed_half_s = 0;
ESP_LOGI(TAG, "等待 Wi-Fi 连接成功后再初始化 console...");
while (wifi_connect_get_status() != WIFI_CONNECT_STATUS_CONNECTED)
{
if (elapsed_half_s >= (timeout_s * 2))
{
ESP_LOGW(TAG, "等待 Wi-Fi 超时(%" PRIu32 "s继续初始化 console", timeout_s);
return;
}
vTaskDelay(pdMS_TO_TICKS(500));
elapsed_half_s++;
// 每 5 秒打印一次等待状态,避免日志刷屏。
if ((elapsed_half_s % 10) == 0)
{
ESP_LOGI(TAG, "仍在等待 Wi-Fi 连接(%" PRIu32 "s", elapsed_half_s / 2);
}
}
ESP_LOGI(TAG, "Wi-Fi 已连接,开始初始化 console");
}
/**
* @brief 主函数
*
* 系统启动入口,初始化所有组件并进入主循环。
*/
void app_main(void)
{
// 初始化 Wi-Fi 配网组件,支持长按按键进入配网
ESP_ERROR_CHECK(wifi_connect_init());
printf("设备启动完成:长按按键进入配网模式,手机连接 ESP32-* 后访问 http://192.168.4.1\n");
// 启动 LVGL 演示程序,显示简单的界面
ESP_ERROR_CHECK(start_lvgl_demo());
// 初始化 UI 组件(需在 LVGL 锁内进行对象创建)
lvgl_port_lock(0);
ui_init();
lvgl_port_unlock();
BaseType_t ui_task_ok = xTaskCreate(ui_task, "ui_task", 4096, NULL, 5, NULL);
ESP_ERROR_CHECK(ui_task_ok == pdPASS ? ESP_OK : ESP_FAIL);
// 初始化 IO 设备控制组件(风扇/补光灯/加热/制冷,高电平有效)
ESP_ERROR_CHECK(io_device_control_init());
i2c_master_messager_config_t i2c_cfg = {
.i2c_port = BOTANY_I2C_PORT,
.scl_io_num = BOTANY_I2C_SCL_GPIO,
.sda_io_num = BOTANY_I2C_SDA_GPIO,
.i2c_enable_internal_pullup = BOTANY_I2C_INTERNAL_PULLUP,
.bh1750_enable = BOTANY_BH1750_ENABLE,
.aht30_enable = BOTANY_AHT30_ENABLE,
.bh1750_read_period_ms = BOTANY_BH1750_PERIOD_MS,
.aht30_read_period_ms = BOTANY_AHT30_PERIOD_MS,
.bh1750_mode = BH1750_CONTINUE_1LX_RES,
};
bool i2c_ready = false;
esp_err_t ret = i2c_master_messager_init(&i2c_cfg);
if (ret == ESP_OK)
{
ret = i2c_master_messager_start();
}
if (ret != ESP_OK)
{
ESP_LOGE(TAG, "I2C 传感器管理启动失败: %s", esp_err_to_name(ret));
ESP_LOGW(TAG, "请检查 I2C 引脚/上拉电阻/端口占用情况,系统将继续运行但不采集传感器");
ESP_ERROR_CHECK(lvgl_st7735s_set_center_text("I2C init failed"));
}
else
{
i2c_ready = true;
s_i2c_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(status_web_register_control_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(console_cmd_init());
ESP_ERROR_CHECK(console_user_cmds_register());
ESP_ERROR_CHECK(console_cmd_all_register()); // 可选:自动注册插件命令
ESP_ERROR_CHECK(console_cmd_start());
auto_ctrl_thresholds_init_defaults();
auto_alerts_init();
ESP_ERROR_CHECK(auto_alerts_register_callback(auto_alert_mqtt_callback, NULL));
auto_ctrl_thresholds_t thresholds = {0};
auto_ctrl_thresholds_get(&thresholds);
uint32_t telemetry_elapsed_ms = 0;
ESP_LOGI(TAG,
"自动控制阈值: light(%.1f/%.1f) hot(%.1f/%.1f) cool(%.1f/%.1f) fan_hum(%.1f/%.1f)",
thresholds.light_on_lux_below,
thresholds.light_off_lux_above,
thresholds.hot_on_temp_below_c,
thresholds.hot_off_temp_above_c,
thresholds.cool_on_temp_above_c,
thresholds.cool_off_temp_below_c,
thresholds.fan_on_humidity_above_pct,
thresholds.fan_off_humidity_below_pct);
for (;;)
{
s_main_loop_counter++;
// 预留给 MQTT 回调动态更新阈值:每个周期读取最新配置。
auto_ctrl_thresholds_get(&thresholds);
bool light_valid = false;
float light_lux = 0.0f;
bool temp_valid = false;
float temp_c = 0.0f;
bool hum_valid = false;
float hum_pct = 0.0f;
i2c_master_messager_data_t sensor_data = {0};
if (i2c_ready && i2c_master_messager_get_data(&sensor_data) == ESP_OK)
{
// 读取成功
if (sensor_data.aht30.valid)
{
temp_valid = true;
hum_valid = true;
temp_c = sensor_data.aht30.temperature_c;
hum_pct = sensor_data.aht30.humidity_rh;
snprintf(s_air_temp, sizeof(s_air_temp), "%.1f", sensor_data.aht30.temperature_c);
set_var_air_temperature(s_air_temp);
snprintf(s_air_hum, sizeof(s_air_hum), "%.1f", sensor_data.aht30.humidity_rh);
set_var_air_humidity(s_air_hum);
}
if (sensor_data.bh1750.valid)
{
light_valid = true;
light_lux = sensor_data.bh1750.lux;
snprintf(s_lux, sizeof(s_lux), "%.0f", sensor_data.bh1750.lux);
set_var_light_intensity(s_lux);
}
}
if (s_auto_control_enabled)
{
auto_control_update(temp_valid,
temp_c,
hum_valid,
hum_pct,
light_valid,
light_lux,
&thresholds,
&s_fan_on,
&s_light_on,
&s_hot_on,
&s_cool_on);
}
// 预留给 MQTT回调注册后可在此处收到边沿告警事件并发布。
auto_alerts_evaluate(light_valid,
light_lux,
&thresholds);
update_status_web_snapshot();
telemetry_elapsed_ms += 1000;
if (telemetry_elapsed_ms >= BOTANY_MQTT_TELEMETRY_PERIOD_MS)
{
telemetry_elapsed_ms = 0;
esp_err_t pub_ret = publish_telemetry_snapshot();
if (pub_ret != ESP_OK && pub_ret != ESP_ERR_INVALID_STATE)
{
ESP_LOGW(TAG, "周期状态上报失败: %s", esp_err_to_name(pub_ret));
}
}
vTaskDelay(pdMS_TO_TICKS(1000));
}
}

584
main/status_web.c Normal file
View File

@@ -0,0 +1,584 @@
#include "status_web.h"
#include <stdio.h>
#include <string.h>
#include <strings.h>
#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 =
"<!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;}"
"input,select{margin-top:6px;border:1px solid #d1d5db;border-radius:8px;padding:8px 10px;background:#fff;color:#111827;}"
"button{margin-top:8px;border:none;border-radius:8px;background:#1d4ed8;color:#fff;padding:8px 12px;cursor:pointer;}"
"button:disabled{opacity:.45;cursor:not-allowed;}"
".btn-row{display:flex;gap:8px;margin-top:8px;}"
".btn{flex:1;background:#64748b;}"
".btn.on{background:#16a34a;}"
".btn.off{background:#dc2626;}"
".badge{display:inline-block;padding:3px 8px;border-radius:999px;font-size:12px;font-weight:600;}"
".badge.auto{background:#dcfce7;color:#166534;}"
".badge.manual{background:#dbeafe;color:#1e3a8a;}"
"</style></head><body><div class='wrap'>"
"<h1>智能粮仓终端设备状态总览</h1>"
"<div class='meta'>独立状态服务port 8080每3秒自动刷新</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='lux' class='v'>--</div></div>"
"<div class='card'><div class='k'>风扇</div><div id='fan' 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='hot' class='v'>--</div></div>"
"<div class='card'><div class='k'>制冷</div><div id='cool' class='v'>--</div></div>"
"<div class='card'><div class='k'>控制模式</div><div id='mode' class='v'>--</div><div id='mode_badge' class='badge manual'>manual</div></div>"
"<div class='card'><div class='k'>light_on/off</div><div id='light_th' class='v'>--</div></div>"
"<div class='card'><div class='k'>hot_on/off (C)</div><div id='hot_th' class='v'>--</div></div>"
"<div class='card'><div class='k'>cool_on/off (C)</div><div id='cool_th' class='v'>--</div></div>"
"<div class='card'><div class='k'>fan_hum_on/off (%)</div><div id='fan_hum_th' class='v'>--</div></div>"
"</div></div>"
"<div class='sec'><h2>参数设置</h2><div class='grid'>"
"<div class='card'><div class='k'>light_on</div><input id='f_light_on' type='number' step='0.1' style='width:100%'></div>"
"<div class='card'><div class='k'>light_off</div><input id='f_light_off' type='number' step='0.1' style='width:100%'></div>"
"<div class='card'><div class='k'>hot_on_temp</div><input id='f_hot_on' type='number' step='0.1' style='width:100%'></div>"
"<div class='card'><div class='k'>hot_off_temp</div><input id='f_hot_off' type='number' step='0.1' style='width:100%'></div>"
"<div class='card'><div class='k'>cool_on_temp</div><input id='f_cool_on' type='number' step='0.1' style='width:100%'></div>"
"<div class='card'><div class='k'>cool_off_temp</div><input id='f_cool_off' type='number' step='0.1' style='width:100%'></div>"
"<div class='card'><div class='k'>fan_on_hum</div><input id='f_fan_on' type='number' step='0.1' style='width:100%'></div>"
"<div class='card'><div class='k'>fan_off_hum</div><input id='f_fan_off' type='number' step='0.1' style='width:100%'></div>"
"</div><button onclick='saveCfg()'>保存参数</button> <span id='save_msg' class='meta'></span></div>"
"<div class='sec'><h2>快捷控制</h2><div class='grid'>"
"<div class='card'><div class='k'>模式</div><select id='mode_sel' style='width:100%'><option value='auto'>auto</option><option value='manual'>manual</option></select><button id='mode_btn' onclick='setMode()'>切换模式</button></div>"
"<div class='card'><div class='k'>风扇</div><div class='btn-row'><button id='fan_on_btn' class='btn' onclick='devCmd(\"fan\",true)'>ON</button><button id='fan_off_btn' class='btn' onclick='devCmd(\"fan\",false)'>OFF</button></div></div>"
"<div class='card'><div class='k'>补光灯</div><div class='btn-row'><button id='light_on_btn' class='btn' onclick='devCmd(\"light\",true)'>ON</button><button id='light_off_btn' class='btn' onclick='devCmd(\"light\",false)'>OFF</button></div></div>"
"<div class='card'><div class='k'>加热</div><div class='btn-row'><button id='hot_on_btn' class='btn' onclick='devCmd(\"hot\",true)'>ON</button><button id='hot_off_btn' class='btn' onclick='devCmd(\"hot\",false)'>OFF</button></div></div>"
"<div class='card'><div class='k'>制冷</div><div class='btn-row'><button id='cool_on_btn' class='btn' onclick='devCmd(\"cool\",true)'>ON</button><button id='cool_off_btn' class='btn' onclick='devCmd(\"cool\",false)'>OFF</button></div></div>"
"</div><span id='ctrl_msg' class='meta'></span></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'>运行时长</div><div id='uptime' class='v'>--</div></div>"
"</div></div>"
"<button onclick='loadStatus()'>立即刷新</button>"
"</div><script>"
"const $=(id)=>document.getElementById(id);const cfgIds=['f_light_on','f_light_off','f_hot_on','f_hot_off','f_cool_on','f_cool_off','f_fan_on','f_fan_off'];let busyCtrl=false;let busyCfg=false;let cfgDirty=false;"
"function onoff(v){return v?'on':'off';}"
"function yn(v){return v?'yes':'no';}"
"function setBinState(name,on){const bOn=document.getElementById(name+'_on_btn');const bOff=document.getElementById(name+'_off_btn');if(!bOn||!bOff)return;bOn.classList.remove('on','off');bOff.classList.remove('on','off');if(on){bOn.classList.add('on');bOff.classList.add('off');}else{bOn.classList.add('off');bOff.classList.add('on');}}"
"function setManualEnabled(enabled){['fan','light','hot','cool'].forEach((n)=>{const bOn=document.getElementById(n+'_on_btn');const bOff=document.getElementById(n+'_off_btn');if(bOn)bOn.disabled=!enabled||busyCtrl;if(bOff)bOff.disabled=!enabled||busyCtrl;});}"
"function setModeBadge(mode){const el=document.getElementById('mode_badge');if(!el)return;el.textContent=mode;el.className='badge '+(mode==='auto'?'auto':'manual');}"
"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';}"
"function markCfgDirty(){cfgDirty=true;}"
"function applyCfgFromStatus(d){const a=document.activeElement;const editing=a&&cfgIds.includes(a.id);if(editing||cfgDirty||busyCfg)return;$('f_light_on').value=d.light_on;$('f_light_off').value=d.light_off;$('f_hot_on').value=d.hot_on_temp;$('f_hot_off').value=d.hot_off_temp;$('f_cool_on').value=d.cool_on_temp;$('f_cool_off').value=d.cool_off_temp;$('f_fan_on').value=d.fan_on_hum;$('f_fan_off').value=d.fan_off_hum;}"
"async function loadStatus(){try{const r=await fetch('/api/status');if(!r.ok){throw new Error('HTTP '+r.status);}const d=await r.json();"
"$('temp').textContent=d.temp;$('hum').textContent=d.hum;$('lux').textContent=d.lux;"
"$('fan').textContent=d.fan;$('light').textContent=d.light;$('hot').textContent=d.hot;$('cool').textContent=d.cool;$('mode').textContent=d.mode;"
"setModeBadge(d.mode);setBinState('fan',d.fan==='on');setBinState('light',d.light==='on');setBinState('hot',d.hot==='on');setBinState('cool',d.cool==='on');"
"$('light_th').textContent=`${d.light_on}/${d.light_off}`;"
"$('hot_th').textContent=`${d.hot_on_temp}/${d.hot_off_temp}`;"
"$('cool_th').textContent=`${d.cool_on_temp}/${d.cool_off_temp}`;"
"$('fan_hum_th').textContent=`${d.fan_on_hum}/${d.fan_off_hum}`;"
"applyCfgFromStatus(d);"
"$('mode_sel').value=d.mode;setManualEnabled(d.mode!=='auto');"
"$('wifi').textContent=d.wifi_status;$('ip').textContent=d.sta_ip;$('mqtt').textContent=onoff(d.mqtt_connected);"
"$('i2c').textContent=yn(d.i2c_ready);"
"$('uptime').textContent=fmtMs(d.uptime_ms);"
"$('save_msg').textContent='';}catch(e){$('save_msg').textContent='读取失败: '+e;}}"
"async function sendControl(p){if(busyCtrl)return;busyCtrl=true;setManualEnabled($('mode_sel').value!=='auto');$('mode_btn').disabled=true;try{const r=await fetch('/api/control',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)});const d=await r.json();if(!r.ok||!d.ok){throw new Error(d.error||('HTTP '+r.status));}$('ctrl_msg').textContent='控制成功';await loadStatus();}"
"catch(e){$('ctrl_msg').textContent='控制失败: '+e;}"
"finally{busyCtrl=false;$('mode_btn').disabled=false;setManualEnabled($('mode_sel').value!=='auto');}}"
"function setMode(){sendControl({mode:$('mode_sel').value});}"
"function devCmd(name,on){if($('mode_sel').value==='auto'){$('ctrl_msg').textContent='auto 模式下请先切到 manual';return;}const p={};p[name]=on;sendControl(p);}"
"async function saveCfg(){const p={"
"light_on:parseFloat($('f_light_on').value),light_off:parseFloat($('f_light_off').value),"
"hot_on_temp:parseFloat($('f_hot_on').value),hot_off_temp:parseFloat($('f_hot_off').value),"
"cool_on_temp:parseFloat($('f_cool_on').value),cool_off_temp:parseFloat($('f_cool_off').value),"
"fan_on_hum:parseFloat($('f_fan_on').value),fan_off_hum:parseFloat($('f_fan_off').value)};"
"if(busyCfg)return;busyCfg=true;"
"try{const r=await fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)});const d=await r.json();if(!r.ok||!d.ok){throw new Error(d.error||('HTTP '+r.status));}cfgDirty=false;$('save_msg').textContent='保存成功';await loadStatus();}"
"catch(e){$('save_msg').textContent='保存失败: '+e;}finally{busyCfg=false;}}"
"cfgIds.forEach((id)=>{const el=$(id);if(el){el.addEventListener('input',markCfgDirty);el.addEventListener('change',markCfgDirty);}});setInterval(loadStatus,3000);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_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;
}

40
main/status_web.h Normal file
View File

@@ -0,0 +1,40 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "esp_err.h"
#include "mqtt_control.h"
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
char temp[16];
char hum[16];
char lux[16];
bool fan_on;
bool light_on;
bool hot_on;
bool cool_on;
bool auto_mode;
float light_on_threshold;
float light_off_threshold;
float hot_on_temp_threshold;
float hot_off_temp_threshold;
float cool_on_temp_threshold;
float cool_off_temp_threshold;
float fan_on_hum_threshold;
float fan_off_hum_threshold;
bool i2c_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);
esp_err_t status_web_register_control_handler(mqtt_control_command_handler_t handler, void *user_ctx);
#ifdef __cplusplus
}
#endif