ESP32-C3 DHT11 温湿度监控系统实现

系统概述

本系统基于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. 数据流程

  1. DHT11传感器采集温湿度数据
  2. 数据存储到内存和SPIFFS文件系统
  3. 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 ℃ &nbsp;&nbsp;&nbsp;<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> &nbsp;"
        "<label>温度下限: <input type='number' id='tempMin' value='10' step='0.1'></label> &nbsp;"
        "<label>湿度上限: <input type='number' id='humMax' value='80' step='0.1'></label> &nbsp;"
        "<label>湿度下限: <input type='number' id='humMin' value='30' step='0.1'></label> &nbsp;"
        "<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> &nbsp;"
        "<label>服务器1: <input type='text' id='ntpServer1' value='pool.ntp.org' style='width:150px;'></label> &nbsp;"
        "<label>服务器2: <input type='text' id='ntpServer2' value='cn.ntp.org.cn' style='width:150px;'></label> &nbsp;"
        "<label>服务器3: <input type='text' id='ntpServer3' value='ntp.aliyun.com' style='width:150px;'></label> &nbsp;"
        "<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);
    }
}
Share

You may also like...

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理。