系统概述
本系统基于ESP32-C3开发板和DHT11温湿度传感器,实现了一个完整的温湿度监控解决方案。系统通过Web界面实时显示温湿度数据、历史趋势图表,并提供数据导出、远程控制等功能。
硬件要求
- ESP32-C3开发板
- DHT11温湿度传感器
- 4.7KΩ上拉电阻(DHT11数据引脚)
- WiFi网络连接
软件功能
1. 核心功能
- 温湿度数据采集 :使用DHT11传感器采集环境温湿度数据,支持小数显示
- 数据存储 :将数据存储到SPIFFS文件系统,支持历史数据查询
- 分级采样 :24小时内每10分钟采样一次,超过24小时每1小时采样一次
- Web界面 :提供响应式Web界面,显示实时数据和历史趋势图表
2. 高级功能
- 阈值报警 :可设置温度和湿度的上下限,超出范围时发出报警
- 数据导出 :支持导出CSV格式的历史数据
- 远程控制 :支持远程重启设备和清除历史记录
- NTP时间同步 :自动从NTP服务器同步时间,支持多个NTP服务器配置
- 时间显示 :大字体显示即时时间,每秒自动更新
- 版权信息 :底部显示版权信息,包含可点击的超链接
3. 技术特点
- 多NTP服务器 :配置了多个NTP服务器,提高时间同步的可靠性
- 缓存控制 :添加了缓存控制头,确保时间实时更新
- 错误处理 :完善的错误处理机制,确保系统稳定运行
- 内存管理 :合理的内存分配和释放,避免内存泄漏
- 响应式设计 :Web界面适配不同屏幕尺寸
使用说明
1. 硬件连接
- DHT11 VCC 接 5V 电源
- DHT11 GND 接地
- DHT11 DATA 接 GPIO14,并串联 4.7KΩ 上拉电阻
2. 配置修改
- 修改 WIFI_SSID 和 WIFI_PASS 为实际的WiFi名称和密码
- 可根据需要调整 DHT11_GPIO_PIN 为其他GPIO引脚
3. 系统启动
- 编译并烧录程序到ESP32-C3
- 设备会自动连接WiFi并启动Web服务器
- 通过串口监视器查看设备IP地址
- 在浏览器中访问设备IP地址,即可看到Web界面
4. Web界面操作
- 阈值设置 :设置温度和湿度的上下限
- NTP设置 :设置NTP同步间隔和服务器地址
- 数据导出 :点击”导出CSV数据”下载历史数据
- 远程控制 :点击”重启设备”或”清除历史记录”执行相应操作
技术实现
1. 核心模块
- DHT11驱动 :实现DHT11传感器的初始化和数据读取
- 数据存储 :使用SPIFFS文件系统存储历史数据
- Web服务器 :基于ESP-IDF的HTTP服务器实现
- NTP同步 :使用ESP-IDF的SNTP库实现时间同步
2. 任务管理
- dht11_task :负责DHT11数据采集和存储
- HTTP服务器任务 :处理Web请求
- FreeRTOS任务 :系统自动管理的其他任务
3. 数据流程
- DHT11传感器采集温湿度数据
- 数据存储到内存和SPIFFS文件系统
- Web服务器提供数据查询和控制接口
源代码
#include <stdio.h>
#include <string.h>
#include <time.h>
#include <sys/time.h>
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_log.h"
#include "esp_system.h"
#include "nvs_flash.h"
#include "esp_netif.h"
#include "esp_http_server.h"
#include "driver/gpio.h"
#include "esp_spiffs.h"
#include "esp_sntp.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/event_groups.h"
// 配置参数
#define TAG "DHT11_FINAL"
#define WIFI_SSID "****" // 替换为实际WiFi名称
#define WIFI_PASS "****" // 替换为实际WiFi密码
#define WIFI_MAX_RETRY 5
#define DHT11_GPIO_PIN GPIO_NUM_14 // DHT11数据引脚
#define RECORD_INTERVAL 60 // 基础采集间隔(1分钟=60秒)
#define SAMPLING_24H_INTERVAL 60 // 24小时内采样间隔(1分钟=60秒)
#define SAMPLING_HISTORY_INTERVAL 3600 // 历史采样间隔(1小时=3600秒)
#define MAX_TOTAL_RECORDS 200 // 最大记录数
#define CHART_24H_POINTS 144 // 24小时图表最大点数(10分钟/点)
#define CHART_HISTORY_POINTS 100 // 历史图表最大点数
#define SPIFFS_MAX_USAGE 80.0f // SPIFFS最大使用率(%)
#define RECORD_FILE_PATH "/spiffs/dht11_records.csv"
#define LOCAL_TIME_FORMAT "%Y-%m-%d %H:%M:%S"
// NTP服务器配置
#define NTP_SERVER1 "ntp.aliyun.com"
#define NTP_SERVER2 "cn.ntp.org.cn"
#define NTP_SERVER3 "pool.ntp.org"
// 温湿度记录结构体
typedef struct {
time_t timestamp;
float temperature;
float humidity;
char time_str[32];
} dht11_record_t;
// 事件标志位
#define WIFI_CONNECTED_BIT BIT0
#define WIFI_FAIL_BIT BIT1
// 全局变量
static EventGroupHandle_t s_wifi_event_group;
static int s_retry_num = 0;
static httpd_handle_t server = NULL;
static float g_current_temp = -1.0f;
static float g_current_hum = -1.0f;
static int g_record_count = 0;
static dht11_record_t g_all_records[MAX_TOTAL_RECORDS];
// 阈值设置
static float g_temp_max = 30.0f;
static float g_temp_min = 10.0f;
static float g_hum_max = 80.0f;
static float g_hum_min = 30.0f;
// NTP设置
static int g_ntp_interval = 3600; // NTP同步间隔(秒)
static char g_ntp_server1[64] = NTP_SERVER1;
static char g_ntp_server2[64] = NTP_SERVER2;
static char g_ntp_server3[64] = NTP_SERVER3;
// 函数声明
static esp_err_t thresholds_handler(httpd_req_t *req);
static esp_err_t export_handler(httpd_req_t *req);
static esp_err_t restart_handler(httpd_req_t *req);
static esp_err_t clear_handler(httpd_req_t *req);
static esp_err_t favicon_handler(httpd_req_t *req);
static esp_err_t time_handler(httpd_req_t *req);
static esp_err_t ntp_handler(httpd_req_t *req);
/**
* @brief NTP时间初始化
*/
static void ntp_time_init(void)
{
setenv("TZ", "CST-8", 1); // 设置时区为东八区
tzset();
esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL);
// 添加多个NTP服务器,提高同步可靠性
esp_sntp_setservername(0, NTP_SERVER1);
esp_sntp_setservername(1, NTP_SERVER2);
esp_sntp_setservername(2, NTP_SERVER3);
esp_sntp_init();
// 等待NTP同步
int retry = 0;
time_t now = 0;
struct tm timeinfo = {0};
while (timeinfo.tm_year < (2020 - 1900) && retry < 10) {
vTaskDelay(1000 / portTICK_PERIOD_MS);
time(&now);
localtime_r(&now, &timeinfo);
retry++;
}
if (timeinfo.tm_year >= (2020 - 1900)) {
char time_buf[32];
strftime(time_buf, sizeof(time_buf), LOCAL_TIME_FORMAT, &timeinfo);
ESP_LOGI(TAG, "NTP同步成功 → 当前时间: %s", time_buf);
} else {
ESP_LOGE(TAG, "NTP同步失败");
}
}
/**
* @brief 获取格式化的当前时间字符串
*/
static void get_current_time_str(char *time_str, size_t len)
{
time_t now;
struct tm timeinfo;
time(&now);
localtime_r(&now, &timeinfo);
strftime(time_str, len, LOCAL_TIME_FORMAT, &timeinfo);
}
/**
* @brief 初始化SPIFFS文件系统
*/
static esp_err_t spiffs_init(void)
{
esp_vfs_spiffs_conf_t conf = {
.base_path = "/spiffs",
.partition_label = NULL,
.max_files = 5,
.format_if_mount_failed = true // 挂载失败则格式化
};
esp_err_t ret = esp_vfs_spiffs_register(&conf);
if (ret != ESP_OK) {
if (ret == ESP_FAIL) {
ESP_LOGE(TAG, "SPIFFS挂载失败,无法格式化");
} else if (ret == ESP_ERR_NOT_FOUND) {
ESP_LOGE(TAG, "SPIFFS分区未找到(检查分区表)");
} else {
ESP_LOGE(TAG, "SPIFFS初始化失败: %s", esp_err_to_name(ret));
}
return ret;
}
// 打印SPIFFS使用情况
size_t total = 0, used = 0;
ret = esp_spiffs_info(NULL, &total, &used);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "获取SPIFFS信息失败: %s", esp_err_to_name(ret));
} else {
ESP_LOGI(TAG, "SPIFFS初始化成功 → 总大小: %dKB, 已用: %dKB (%.1f%%)",
total/1024, used/1024, (float)used/total*100);
}
return ESP_OK;
}
/**
* @brief 清理过期记录(按自定义条数+空间双重限制)
*/
static void clean_expired_records(void)
{
// 1. 按条数限制清理
if (g_record_count > MAX_TOTAL_RECORDS) {
int delete_count = g_record_count - MAX_TOTAL_RECORDS;
memmove(g_all_records, g_all_records + delete_count,
(g_record_count - delete_count) * sizeof(dht11_record_t));
g_record_count -= delete_count;
ESP_LOGI(TAG, "记录数超限(%d>%d),删除%d条旧记录",
g_record_count + delete_count, MAX_TOTAL_RECORDS, delete_count);
}
// 2. 按SPIFFS空间清理
size_t total, used;
esp_spiffs_info(NULL, &total, &used);
if ((float)used/total*100 > SPIFFS_MAX_USAGE) {
// 删除最早的20%记录
int delete_count = g_record_count * 0.2;
if (delete_count < 1) delete_count = 1;
memmove(g_all_records, g_all_records + delete_count,
(g_record_count - delete_count) * sizeof(dht11_record_t));
g_record_count -= delete_count;
ESP_LOGI(TAG, "SPIFFS空间不足(%.1f%%),删除%d条旧记录",
(float)used/total*100, delete_count);
}
// 3. 保存清理后的记录到文件
FILE *f = fopen(RECORD_FILE_PATH, "w");
if (f) {
for (int i = 0; i < g_record_count; i++) {
// 修复:%lu → %llu(适配64位time_t)
fprintf(f, "%llu,%.1f,%.1f,%s\n",
(unsigned long long)g_all_records[i].timestamp,
g_all_records[i].temperature,
g_all_records[i].humidity,
g_all_records[i].time_str);
}
fclose(f);
}
}
/**
* @brief 加载历史记录从SPIFFS
*/
static void load_records_from_spiffs(void)
{
FILE *f = fopen(RECORD_FILE_PATH, "r");
if (!f) {
ESP_LOGW(TAG, "记录文件不存在,创建新文件");
f = fopen(RECORD_FILE_PATH, "w");
if (f) fclose(f);
return;
}
char line[128];
g_record_count = 0;
while (fgets(line, sizeof(line), f) && g_record_count < MAX_TOTAL_RECORDS) {
dht11_record_t rec;
// 修复:%lu → %llu(适配64位time_t)
if (sscanf(line, "%llu,%f,%f,%[^\n]",
(unsigned long long*)&rec.timestamp, &rec.temperature, &rec.humidity, rec.time_str) == 4) {
g_all_records[g_record_count++] = rec;
}
}
fclose(f);
ESP_LOGI(TAG, "从SPIFFS加载%d条历史记录", g_record_count);
}
/**
* @brief 保存单条记录到内存+SPIFFS
*/
static void save_single_record(float temp, float hum)
{
if (temp < 0 || hum < 0) return;
// 校验时间有效性(防止无效时间戳)
time_t now = time(NULL);
if (now < 1609459200) { // 拒绝2021年之前的无效时间
ESP_LOGW(TAG, "无效时间戳,跳过保存记录");
return;
}
// 获取当前时间
dht11_record_t rec;
rec.timestamp = now;
get_current_time_str(rec.time_str, sizeof(rec.time_str));
rec.temperature = temp;
rec.humidity = hum;
// 添加到内存
if (g_record_count < MAX_TOTAL_RECORDS) {
g_all_records[g_record_count++] = rec;
} else {
// 满了则替换最旧的
memmove(g_all_records, g_all_records + 1, (MAX_TOTAL_RECORDS - 1) * sizeof(dht11_record_t));
g_all_records[MAX_TOTAL_RECORDS - 1] = rec;
}
// 追加到文件
FILE *f = fopen(RECORD_FILE_PATH, "a");
if (f) {
// 修复:%lu → %llu(适配64位time_t)
fprintf(f, "%llu,%.1f,%.1f,%s\n",
(unsigned long long)rec.timestamp,
rec.temperature,
rec.humidity,
rec.time_str);
fclose(f);
}
// 检查并清理过期记录
clean_expired_records();
}
/**
* @brief DHT11读取函数
*/
static bool dht11_read_data(float *temp, float *hum)
{
uint8_t data[5] = {0};
gpio_set_direction(DHT11_GPIO_PIN, GPIO_MODE_OUTPUT);
gpio_set_level(DHT11_GPIO_PIN, 0);
esp_rom_delay_us(20000);
gpio_set_level(DHT11_GPIO_PIN, 1);
esp_rom_delay_us(30);
gpio_set_direction(DHT11_GPIO_PIN, GPIO_MODE_INPUT);
int retry = 0;
while (gpio_get_level(DHT11_GPIO_PIN) == 1 && retry < 100) {
esp_rom_delay_us(1);
retry++;
}
if (retry >= 100) return false;
esp_rom_delay_us(80);
if (gpio_get_level(DHT11_GPIO_PIN) == 0) return false;
esp_rom_delay_us(80);
for (int i = 0; i < 40; i++) {
retry = 0;
while (gpio_get_level(DHT11_GPIO_PIN) == 0 && retry < 100) {
esp_rom_delay_us(1);
retry++;
}
if (retry >= 100) return false;
esp_rom_delay_us(40);
data[i/8] <<= 1;
if (gpio_get_level(DHT11_GPIO_PIN) == 1) data[i/8] |= 1;
// 添加重试次数限制,避免无限循环
retry = 0;
while (gpio_get_level(DHT11_GPIO_PIN) == 1 && retry < 100) {
esp_rom_delay_us(1);
retry++;
}
if (retry >= 100) return false;
}
if ((data[0] + data[1] + data[2] + data[3]) != data[4]) return false;
// 计算温湿度值,包括小数部分
*hum = (float)data[0] + (float)data[1] / 10.0f;
*temp = (float)data[2] + (float)data[3] / 10.0f;
return true;
}
/**
* @brief DHT11采集任务(新增分级采样)
*/
static void dht11_task(void *arg)
{
if (spiffs_init() != ESP_OK) {
ESP_LOGE(TAG, "SPIFFS初始化失败,采集任务退出");
vTaskDelete(NULL);
return;
}
// 加载历史记录
load_records_from_spiffs();
// 等待WiFi连接后同步NTP
EventBits_t bits = xEventGroupWaitBits(s_wifi_event_group,
WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, pdFALSE, pdFALSE, portMAX_DELAY);
if (bits & WIFI_CONNECTED_BIT) {
ntp_time_init();
}
// 采样时间戳记录
time_t last_24h_sample = 0;
time_t last_history_sample = 0;
while (1) {
float temp, hum;
if (dht11_read_data(&temp, &hum)) {
g_current_temp = temp;
g_current_hum = hum;
time_t now = time(NULL);
// 阈值报警检查
if (temp > g_temp_max) {
ESP_LOGE(TAG, "⚠️ 温度报警:当前温度 %.1f℃ 超过上限 %.1f℃", temp, g_temp_max);
} else if (temp < g_temp_min) {
ESP_LOGE(TAG, "⚠️ 温度报警:当前温度 %.1f℃ 低于下限 %.1f℃", temp, g_temp_min);
}
if (hum > g_hum_max) {
ESP_LOGE(TAG, "⚠️ 湿度报警:当前湿度 %.1f%% 超过上限 %.1f%%", hum, g_hum_max);
} else if (hum < g_hum_min) {
ESP_LOGE(TAG, "⚠️ 湿度报警:当前湿度 %.1f%% 低于下限 %.1f%%", hum, g_hum_min);
}
// 分级采样逻辑
bool need_save = false;
// 1. 近24小时内:每10分钟采样1条
time_t twenty_four_hours_ago = now - 24 * 3600;
if (g_record_count == 0 || now - last_24h_sample >= SAMPLING_24H_INTERVAL) {
need_save = true;
last_24h_sample = now;
}
// 2. 超过24小时:每1小时采样1条
else if (g_record_count > 0 && g_all_records[0].timestamp < twenty_four_hours_ago &&
now - last_history_sample >= SAMPLING_HISTORY_INTERVAL) {
need_save = true;
last_history_sample = now;
}
// 保存采样记录
if (need_save) {
save_single_record(temp, hum);
ESP_LOGI(TAG, "保存采样记录 → %s | 温度:%.1f℃ 湿度:%.1f%%RH",
g_all_records[g_record_count-1].time_str, temp, hum);
}
} else {
g_current_temp = -1.0f;
g_current_hum = -1.0f;
}
// 每30秒强制写入一次(避免缓存未写)
static int write_counter = 0;
write_counter++;
if (write_counter >= 15 && g_current_temp >= 0) { // 15*2秒=30秒,且数据有效
save_single_record(g_current_temp, g_current_hum);
write_counter = 0;
}
vTaskDelay(RECORD_INTERVAL * 1000 / portTICK_PERIOD_MS);
}
}
/**
* @brief 构建分级图表数据(增加缓冲区越界检查)
*/
static void build_chart_data(char *chart_24h, char *chart_history, size_t len)
{
time_t now = time(NULL);
time_t twenty_four_hours_ago = now - 24 * 3600;
// 初始化数据字符串(安全长度检查)
snprintf(chart_24h, len, "var chart24h = {time:[], temp:[], hum:[]};");
snprintf(chart_history, len, "var chartHistory = {time:[], temp:[], hum:[]};");
// 临时存储图表数据
char temp_buf[16], hum_buf[16];
int count_24h = 0, count_history = 0;
// 临时存储24小时数据的数组,用于保持时间顺序
typedef struct {
char time_str[32];
float temperature;
float humidity;
} chart_point_t;
chart_point_t chart_24h_points[CHART_24H_POINTS];
chart_point_t chart_history_points[CHART_HISTORY_POINTS];
// 从最新的记录开始遍历,忽略旧数据
for (int i = g_record_count - 1; i >= 0; i--) {
dht11_record_t *rec = &g_all_records[i];
// 1. 近24小时数据(最多1440个点)
if (rec->timestamp >= twenty_four_hours_ago && count_24h < CHART_24H_POINTS) {
// 保存到临时数组,稍后按时间顺序添加
strncpy(chart_24h_points[count_24h].time_str, rec->time_str, sizeof(chart_24h_points[count_24h].time_str) - 1);
chart_24h_points[count_24h].temperature = rec->temperature;
chart_24h_points[count_24h].humidity = rec->humidity;
count_24h++;
}
// 2. 历史数据(超过24小时,最多100个点)
else if (rec->timestamp < twenty_four_hours_ago && count_history < CHART_HISTORY_POINTS) {
// 保存到临时数组,稍后按时间顺序添加
strncpy(chart_history_points[count_history].time_str, rec->time_str, sizeof(chart_history_points[count_history].time_str) - 1);
chart_history_points[count_history].temperature = rec->temperature;
chart_history_points[count_history].humidity = rec->humidity;
count_history++;
}
}
// 按时间顺序(从远到近)添加24小时数据到图表
for (int i = count_24h - 1; i >= 0; i--) {
size_t current_len = strlen(chart_24h);
if (current_len + 128 < len) {
strncat(chart_24h, "chart24h.time.push('", len - current_len - 1);
strncat(chart_24h, chart_24h_points[i].time_str, len - strlen(chart_24h) - 1);
strncat(chart_24h, "');", len - strlen(chart_24h) - 1);
snprintf(temp_buf, sizeof(temp_buf), "%.1f", chart_24h_points[i].temperature);
strncat(chart_24h, "chart24h.temp.push(", len - strlen(chart_24h) - 1);
strncat(chart_24h, temp_buf, len - strlen(chart_24h) - 1);
strncat(chart_24h, ");", len - strlen(chart_24h) - 1);
snprintf(hum_buf, sizeof(hum_buf), "%.1f", chart_24h_points[i].humidity);
strncat(chart_24h, "chart24h.hum.push(", len - strlen(chart_24h) - 1);
strncat(chart_24h, hum_buf, len - strlen(chart_24h) - 1);
strncat(chart_24h, ");", len - strlen(chart_24h) - 1);
}
}
// 按时间顺序(从远到近)添加历史数据到图表
for (int i = count_history - 1; i >= 0; i--) {
size_t current_len = strlen(chart_history);
if (current_len + 128 < len) {
strncat(chart_history, "chartHistory.time.push('", len - current_len - 1);
strncat(chart_history, chart_history_points[i].time_str, len - strlen(chart_history) - 1);
strncat(chart_history, "');", len - strlen(chart_history) - 1);
snprintf(temp_buf, sizeof(temp_buf), "%.1f", chart_history_points[i].temperature);
strncat(chart_history, "chartHistory.temp.push(", len - strlen(chart_history) - 1);
strncat(chart_history, temp_buf, len - strlen(chart_history) - 1);
strncat(chart_history, ");", len - strlen(chart_history) - 1);
snprintf(hum_buf, sizeof(hum_buf), "%.1f", chart_history_points[i].humidity);
strncat(chart_history, "chartHistory.hum.push(", len - strlen(chart_history) - 1);
strncat(chart_history, hum_buf, len - strlen(chart_history) - 1);
strncat(chart_history, ");", len - strlen(chart_history) - 1);
}
}
// 添加最新的实时数据(如果有)
if (g_current_temp >= 0) {
char current_time_str[32];
get_current_time_str(current_time_str, sizeof(current_time_str));
size_t current_len = strlen(chart_24h);
if (current_len + 128 < len) {
strncat(chart_24h, "chart24h.time.push('", len - current_len - 1);
strncat(chart_24h, current_time_str, len - strlen(chart_24h) - 1);
strncat(chart_24h, "');", len - strlen(chart_24h) - 1);
snprintf(temp_buf, sizeof(temp_buf), "%.1f", g_current_temp);
strncat(chart_24h, "chart24h.temp.push(", len - strlen(chart_24h) - 1);
strncat(chart_24h, temp_buf, len - strlen(chart_24h) - 1);
strncat(chart_24h, ");", len - strlen(chart_24h) - 1);
snprintf(hum_buf, sizeof(hum_buf), "%.1f", g_current_hum);
strncat(chart_24h, "chart24h.hum.push(", len - strlen(chart_24h) - 1);
strncat(chart_24h, hum_buf, len - strlen(chart_24h) - 1);
strncat(chart_24h, ");", len - strlen(chart_24h) - 1);
count_24h++;
}
}
ESP_LOGI(TAG, "图表数据构建完成 → 24小时:%d点, 历史:%d点", count_24h, count_history);
}
/**
* @brief Web根页面处理(双图表+在线ECharts,堆内存分配)
*/
static esp_err_t root_handler(httpd_req_t *req)
{
// WiFi状态
wifi_ap_record_t ap_info;
char rssi_str[64] = "未知";
char ssid_str[64] = "未连接";
if (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK) {
snprintf(ssid_str, sizeof(ssid_str), "%s", ap_info.ssid);
snprintf(rssi_str, sizeof(rssi_str), "%d dBm", ap_info.rssi);
}
// 当前时间
char current_time[32];
get_current_time_str(current_time, sizeof(current_time));
// 堆内存分配图表缓冲区(避免栈溢出)
char *chart_24h = (char *)malloc(16384);
char *chart_history = (char *)malloc(16384);
char *html = (char *)malloc(65536);
// 内存分配失败处理
if (!chart_24h || !chart_history || !html) {
ESP_LOGE(TAG, "堆内存分配失败");
free(chart_24h);
free(chart_history);
free(html);
httpd_resp_send_500(req);
return ESP_FAIL;
}
// 初始化缓冲区
memset(chart_24h, 0, 16384);
memset(chart_history, 0, 16384);
memset(html, 0, 65536);
// 构建图表数据
build_chart_data(chart_24h, chart_history, 16384);
// 构建当前温湿度字符串
char temp_hum_str[128] = {0};
if (g_current_temp >= 0) {
snprintf(temp_hum_str, sizeof(temp_hum_str),
"<b>当前温度:</b>%.1f ℃ <b>当前湿度:</b>%.1f %%RH",
g_current_temp, g_current_hum);
} else {
snprintf(temp_hum_str, sizeof(temp_hum_str),
"<div class='error'>DHT11读取失败(检查接线)</div>");
}
// 构建HTML(一次性拼接,避免strcat溢出)
snprintf(html, 65536,
"<!DOCTYPE html>"
"<html lang='zh-CN'>"
"<head>"
"<meta charset='UTF-8'>"
"<meta http-equiv='refresh' content='60'>" // 1分钟刷新一次
"<title>ESP32-C3 温湿度监控</title>"
// 改用在线ECharts CDN(无需本地文件)
"<script src='https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js'></script>"
"<style>"
"body {margin:20px; font-family:Arial; max-width:1200px; margin:0 auto;}"
".header {text-align:center; margin:20px 0;}"
".current-data {font-size:22px; margin:20px 0; color:#2c3e50;}"
".error {color:#e74c3c;}"
".chart-container {width:100%%; height:400px; margin:30px 0;}"
".info {font-size:14px; color:#7f8c8d; margin:10px 0;}"
"</style>"
"</head>"
"<body>"
"<div class='header'>"
"<h1>ESP32-C3 DHT11 温湿度监控</h1>"
"<div id='currentTime' style='font-size:24px; font-weight:bold; text-align:center; margin:20px 0; color:#2c3e50;'>%s</div>"
"<p class='info'>更新时间: %s | WiFi: %s (信号: %s) | 总记录数: %d</p>"
"</div>"
"<div class='current-data'>%s</div>"
"<h2>阈值设置</h2>"
"<div class='settings'>"
"<form id='thresholdForm'>"
"<label>温度上限: <input type='number' id='tempMax' value='30' step='0.1'></label> "
"<label>温度下限: <input type='number' id='tempMin' value='10' step='0.1'></label> "
"<label>湿度上限: <input type='number' id='humMax' value='80' step='0.1'></label> "
"<label>湿度下限: <input type='number' id='humMin' value='30' step='0.1'></label> "
"<button type='button' onclick='saveThresholds()'>保存阈值</button>"
"</form>"
"<div id='thresholdStatus' style='margin-top:10px; color:green;'></div>"
"</div>"
"<h2>NTP设置</h2>"
"<div class='settings'>"
"<form id='ntpForm'>"
"<label>同步间隔(秒): <input type='number' id='ntpInterval' value='3600' min='60' max='86400'></label> "
"<label>服务器1: <input type='text' id='ntpServer1' value='pool.ntp.org' style='width:150px;'></label> "
"<label>服务器2: <input type='text' id='ntpServer2' value='cn.ntp.org.cn' style='width:150px;'></label> "
"<label>服务器3: <input type='text' id='ntpServer3' value='ntp.aliyun.com' style='width:150px;'></label> "
"<button type='button' onclick='saveNtpSettings()'>保存NTP设置</button>"
"</form>"
"<div id='ntpStatus' style='margin-top:10px; color:green;'></div>"
"</div>"
"<h2>数据导出</h2>"
"<div class='export'>"
"<a href='/export' target='_blank'>导出CSV数据</a>"
"</div>"
"<h2>远程控制</h2>"
"<div class='control'>"
"<button onclick='restartDevice()'>重启设备</button>"
"<button onclick='clearRecords()'>清除历史记录</button>"
"<div id='controlStatus' style='margin-top:10px; color:green;'></div>"
"</div>"
"<h2>近24小时温湿度曲线(每10分钟1条)</h2>"
"<div id='chart24h' class='chart-container'></div>"
"<h2>历史温湿度曲线(超过1天,每1小时1条)</h2>"
"<div id='chartHistory' class='chart-container'></div>"
"<script>"
"%s"
"%s"
// 初始化24小时图表
"var chart24hObj = echarts.init(document.getElementById('chart24h'));"
"chart24hObj.setOption({"
" title: {text: '近24小时温湿度变化', left: 'center'},"
" tooltip: {trigger: 'axis'},"
" legend: {data: ['温度(℃)', '湿度(%%RH)'], bottom: 0},"
" xAxis: {type: 'category', data: chart24h.time, axisLabel: {rotate: 45}},"
" yAxis: {type: 'value'},"
" series: ["
" {name: '温度(℃)', type: 'line', data: chart24h.temp, smooth: true},"
" {name: '湿度(%%RH)', type: 'line', data: chart24h.hum, smooth: true}"
" ]"
"});"
// 初始化历史图表
"var chartHistoryObj = echarts.init(document.getElementById('chartHistory'));"
"chartHistoryObj.setOption({"
" title: {text: '历史温湿度变化', left: 'center'},"
" tooltip: {trigger: 'axis'},"
" legend: {data: ['温度(℃)', '湿度(%%RH)'], bottom: 0},"
" xAxis: {type: 'category', data: chartHistory.time, axisLabel: {rotate: 45}},"
" yAxis: {type: 'value'},"
" series: ["
" {name: '温度(℃)', type: 'line', data: chartHistory.temp, smooth: true},"
" {name: '湿度(%%RH)', type: 'line', data: chartHistory.hum, smooth: true}"
" ]"
"});"
// 自适应窗口大小
"window.addEventListener('resize', function() {"
" chart24hObj.resize();"
" chartHistoryObj.resize();"
"});"
""
"// 保存阈值设置"
"function saveThresholds() {"
" const tempMax = document.getElementById('tempMax').value;"
" const tempMin = document.getElementById('tempMin').value;"
" const humMax = document.getElementById('humMax').value;"
" const humMin = document.getElementById('humMin').value;"
" "
" fetch('/thresholds?tempMax=' + tempMax + '&tempMin=' + tempMin + '&humMax=' + humMax + '&humMin=' + humMin)"
" .then(response => response.text())"
" .then(data => {"
" document.getElementById('thresholdStatus').textContent = '阈值保存成功!';"
" setTimeout(() => {"
" document.getElementById('thresholdStatus').textContent = '';"
" }, 3000);"
" })"
" .catch(error => {"
" document.getElementById('thresholdStatus').textContent = '保存失败,请重试';"
" });"
"}"
""
"// 重启设备"
"function restartDevice() {"
" if (confirm('确定要重启设备吗?')) {"
" fetch('/restart')"
" .then(response => response.text())"
" .then(data => {"
" document.getElementById('controlStatus').textContent = '设备正在重启...';"
" });"
" }"
"}"
""
"// 清除历史记录"
"function clearRecords() {"
" if (confirm('确定要清除所有历史记录吗?')) {"
" fetch('/clear')"
" .then(response => response.text())"
" .then(data => {"
" document.getElementById('controlStatus').textContent = '历史记录已清除!';"
" setTimeout(() => {"
" location.reload();"
" }, 1000);"
" });"
" }"
"}"
""
"// 保存NTP设置"
"function saveNtpSettings() {"
" const interval = document.getElementById('ntpInterval').value;"
" const server1 = document.getElementById('ntpServer1').value;"
" const server2 = document.getElementById('ntpServer2').value;"
" const server3 = document.getElementById('ntpServer3').value;"
" "
" fetch('/ntp?interval=' + interval + '&server1=' + encodeURIComponent(server1) + '&server2=' + encodeURIComponent(server2) + '&server3=' + encodeURIComponent(server3))"
" .then(response => response.text())"
" .then(data => {"
" document.getElementById('ntpStatus').textContent = 'NTP设置保存成功!';"
" setTimeout(() => {"
" document.getElementById('ntpStatus').textContent = '';"
" }, 3000);"
" })"
" .catch(error => {"
" document.getElementById('ntpStatus').textContent = '保存失败,请重试';"
" });"
"}"
""
"// 实时更新时间"
"function updateTime() {"
" fetch('/time')"
" .then(response => response.text())"
" .then(data => {"
" // 更新大字体时间显示"
" const currentTimeElement = document.getElementById('currentTime');"
" if (currentTimeElement) {"
" currentTimeElement.textContent = data;"
" }"
" // 更新信息栏时间"
" const timeElement = document.querySelector('.info');"
" if (timeElement) {"
" const parts = timeElement.textContent.split('|');"
" if (parts.length > 0) {"
" timeElement.textContent = '更新时间: ' + data + ' |' + parts.slice(1).join('|');"
" }"
" }"
" });"
"}"
""
"// 每秒钟更新一次时间"
"setInterval(updateTime, 1000);"
"// 页面加载时立即更新一次"
"updateTime();"
"</script>"
"<div style='text-align:center; margin-top:50px; padding:20px; font-size:14px; color:#7f8c8d; border-top:1px solid #eee;'>"
"Copyright 2026 <a href='https://www.isunding.com' style='color:#3498db; text-decoration:none;' target='_blank'>iRabbit</a> All Rights Reserved."
"</div>"
"</body>"
"</html>",
current_time, current_time, ssid_str, rssi_str, g_record_count,
temp_hum_str, chart_24h, chart_history);
// 添加缓存控制头,确保浏览器不会缓存页面内容
httpd_resp_set_hdr(req, "Cache-Control", "no-cache, no-store, must-revalidate");
httpd_resp_set_hdr(req, "Pragma", "no-cache");
httpd_resp_set_hdr(req, "Expires", "0");
// 发送HTTP响应
httpd_resp_send(req, html, HTTPD_RESP_USE_STRLEN);
// 释放堆内存(防止内存泄漏)
free(chart_24h);
free(chart_history);
free(html);
return ESP_OK;
}
/**
* @brief WiFi事件处理+Web服务器启动(增大服务器栈空间)
*/
static void event_handler(void* arg, esp_event_base_t event_base,
int32_t event_id, void* event_data)
{
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
if (s_retry_num < WIFI_MAX_RETRY) {
esp_wifi_connect();
s_retry_num++;
ESP_LOGI(TAG, "WiFi断开,正在重连(%d/%d)", s_retry_num, WIFI_MAX_RETRY);
} else {
xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT);
}
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t* event = (ip_event_got_ip_t*) event_data;
ESP_LOGI(TAG, "WiFi连接成功 → IP: " IPSTR, IP2STR(&event->ip_info.ip));
s_retry_num = 0;
xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
if (server == NULL) {
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.server_port = 80;
config.stack_size = 16384; // 增大HTTP服务器栈空间到16KB
if (httpd_start(&server, &config) == ESP_OK) {
// 注册路由
httpd_uri_t root_uri = {
.uri = "/",
.method = HTTP_GET,
.handler = root_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(server, &root_uri);
// 阈值设置路由
httpd_uri_t thresholds_uri = {
.uri = "/thresholds",
.method = HTTP_GET,
.handler = thresholds_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(server, &thresholds_uri);
// 数据导出路由
httpd_uri_t export_uri = {
.uri = "/export",
.method = HTTP_GET,
.handler = export_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(server, &export_uri);
// 设备重启路由
httpd_uri_t restart_uri = {
.uri = "/restart",
.method = HTTP_GET,
.handler = restart_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(server, &restart_uri);
// 清除记录路由
httpd_uri_t clear_uri = {
.uri = "/clear",
.method = HTTP_GET,
.handler = clear_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(server, &clear_uri);
// 处理favicon.ico请求,避免404警告
httpd_uri_t favicon_uri = {
.uri = "/favicon.ico",
.method = HTTP_GET,
.handler = favicon_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(server, &favicon_uri);
// 处理时间请求,用于网页实时更新时间
httpd_uri_t time_uri = {
.uri = "/time",
.method = HTTP_GET,
.handler = time_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(server, &time_uri);
// 处理NTP设置请求
httpd_uri_t ntp_uri = {
.uri = "/ntp",
.method = HTTP_GET,
.handler = ntp_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(server, &ntp_uri);
ESP_LOGI(TAG, "Web服务器启动 → 访问地址: http://" IPSTR, IP2STR(&event->ip_info.ip));
}
}
}
}
/**
* @brief WiFi初始化
*/
void wifi_init_sta(void)
{
s_wifi_event_group = xEventGroupCreate();
esp_err_t ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
ret = nvs_flash_init();
}
ESP_ERROR_CHECK(ret);
ESP_ERROR_CHECK(esp_netif_init());
// 尝试创建默认事件循环,如果已经存在则忽略错误
esp_err_t err = esp_event_loop_create_default();
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
ESP_ERROR_CHECK(err);
}
esp_netif_create_default_wifi_sta();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
esp_event_handler_instance_t instance_any_id, instance_got_ip;
ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT,
ESP_EVENT_ANY_ID, &event_handler, NULL, &instance_any_id));
ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT,
IP_EVENT_STA_GOT_IP, &event_handler, NULL, &instance_got_ip));
wifi_config_t wifi_config = {
.sta = {
.ssid = WIFI_SSID, // WiFi名称
.password = WIFI_PASS, // WiFi密码
},
};
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
ESP_ERROR_CHECK(esp_wifi_start());
ESP_LOGI(TAG, "WiFi初始化完成,等待连接...");
}
/**
* @brief DHT11引脚初始化(补充完整)
*/
void dht11_init(gpio_num_t gpio_num)
{
gpio_config_t io_conf = {
.pin_bit_mask = (1ULL << gpio_num), // 适配传入的D12(GPIO12)
.mode = GPIO_MODE_INPUT_OUTPUT_OD, // 开漏模式(DHT11通信需要)
.pull_up_en = GPIO_PULLUP_DISABLE, // 禁用内部上拉(DHT11自带电阻)
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
gpio_config(&io_conf);
// 初始电平置高(符合DHT11空闲状态)
gpio_set_level(gpio_num, 1);
ESP_LOGI(TAG, "DHT11 GPIO%d 初始化完成(禁用内部上拉,使用DHT11自带电阻)", gpio_num);
}
/**
* @brief 阈值设置处理函数
*/
static esp_err_t thresholds_handler(httpd_req_t *req)
{
char query_str[128];
httpd_req_get_url_query_str(req, query_str, sizeof(query_str));
// 解析查询参数
float tempMax, tempMin, humMax, humMin;
if (sscanf(query_str, "tempMax=%f&tempMin=%f&humMax=%f&humMin=%f",
&tempMax, &tempMin, &humMax, &humMin) == 4) {
g_temp_max = tempMax;
g_temp_min = tempMin;
g_hum_max = humMax;
g_hum_min = humMin;
ESP_LOGI(TAG, "阈值设置更新: 温度 %.1f-%.1f℃, 湿度 %.1f-%.1f%%",
tempMin, tempMax, humMin, humMax);
}
httpd_resp_send(req, "Thresholds saved", HTTPD_RESP_USE_STRLEN);
return ESP_OK;
}
/**
* @brief 数据导出处理函数
*/
static esp_err_t export_handler(httpd_req_t *req)
{
// 设置响应头
httpd_resp_set_type(req, "text/csv");
httpd_resp_set_hdr(req, "Content-Disposition", "attachment; filename=dht11_data.csv");
// 发送CSV表头
const char *header = "Timestamp,Temperature(℃),Humidity(%%RH),Time\n";
httpd_resp_send_chunk(req, header, strlen(header));
// 发送数据
char line[128];
for (int i = 0; i < g_record_count; i++) {
dht11_record_t *rec = &g_all_records[i];
snprintf(line, sizeof(line), "%llu,%.1f,%.1f,%s\n",
(unsigned long long)rec->timestamp,
rec->temperature, rec->humidity, rec->time_str);
httpd_resp_send_chunk(req, line, strlen(line));
}
httpd_resp_send_chunk(req, NULL, 0);
return ESP_OK;
}
/**
* @brief 设备重启处理函数
*/
static esp_err_t restart_handler(httpd_req_t *req)
{
httpd_resp_send(req, "Restarting device...", HTTPD_RESP_USE_STRLEN);
// 延迟重启,确保响应发送完成
vTaskDelay(pdMS_TO_TICKS(1000));
esp_restart();
return ESP_OK;
}
/**
* @brief 清除记录处理函数
*/
static esp_err_t clear_handler(httpd_req_t *req)
{
// 清空内存中的记录
g_record_count = 0;
// 清空文件
FILE *f = fopen(RECORD_FILE_PATH, "w");
if (f) fclose(f);
ESP_LOGI(TAG, "历史记录已清除");
httpd_resp_send(req, "Records cleared", HTTPD_RESP_USE_STRLEN);
return ESP_OK;
}
/**
* @brief 处理favicon.ico请求,避免404警告
*/
static esp_err_t favicon_handler(httpd_req_t *req)
{
httpd_resp_set_status(req, "404 Not Found");
httpd_resp_send(req, NULL, 0);
return ESP_OK;
}
/**
* @brief 处理时间请求,返回当前时间
*/
static esp_err_t time_handler(httpd_req_t *req)
{
char time_str[32];
get_current_time_str(time_str, sizeof(time_str));
// 添加缓存控制头,确保浏览器不会缓存时间响应
httpd_resp_set_hdr(req, "Cache-Control", "no-cache, no-store, must-revalidate");
httpd_resp_set_hdr(req, "Pragma", "no-cache");
httpd_resp_set_hdr(req, "Expires", "0");
httpd_resp_send(req, time_str, HTTPD_RESP_USE_STRLEN);
return ESP_OK;
}
/**
* @brief 处理NTP设置请求
*/
static esp_err_t ntp_handler(httpd_req_t *req)
{
char query_str[256];
httpd_req_get_url_query_str(req, query_str, sizeof(query_str));
// 解析查询参数
int interval;
char server1[64], server2[64], server3[64];
if (sscanf(query_str, "interval=%d&server1=%63[^&]&server2=%63[^&]&server3=%63s",
&interval, server1, server2, server3) >= 1) {
// 更新NTP同步间隔
if (interval >= 60 && interval <= 86400) {
g_ntp_interval = interval;
}
// 更新NTP服务器地址
if (strlen(server1) > 0) {
strncpy(g_ntp_server1, server1, sizeof(g_ntp_server1) - 1);
}
if (strlen(server2) > 0) {
strncpy(g_ntp_server2, server2, sizeof(g_ntp_server2) - 1);
}
if (strlen(server3) > 0) {
strncpy(g_ntp_server3, server3, sizeof(g_ntp_server3) - 1);
}
// 重启NTP服务以应用新设置
esp_sntp_stop();
esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL);
esp_sntp_setservername(0, g_ntp_server1);
esp_sntp_setservername(1, g_ntp_server2);
esp_sntp_setservername(2, g_ntp_server3);
esp_sntp_init();
ESP_LOGI(TAG, "NTP设置更新: 间隔 %d秒, 服务器1: %s, 服务器2: %s, 服务器3: %s",
g_ntp_interval, g_ntp_server1, g_ntp_server2, g_ntp_server3);
}
httpd_resp_send(req, "NTP settings saved", HTTPD_RESP_USE_STRLEN);
return ESP_OK;
}
/**
* @brief 主函数
*/
void app_main(void)
{
ESP_LOGI(TAG, "ESP32-C3 DHT11 监控系统启动");
// 初始化WiFi
wifi_init_sta();
// 初始化DHT11引脚
dht11_init(DHT11_GPIO_PIN);
// 创建DHT11采集任务(增大栈空间到16KB避免溢出)
xTaskCreate(dht11_task, "dht11_task", 16384, NULL, 1, NULL);
ESP_LOGI(TAG, "DHT11采集任务已启动");
// 主循环
while (1) {
vTaskDelay(10000 / portTICK_PERIOD_MS);
}
}