From a1566f3dc6beb0a8760f4ad1cfa30b6f903b2597 Mon Sep 17 00:00:00 2001 From: Wang Beihong Date: Mon, 20 Apr 2026 13:11:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=20Wi-Fi=20=E8=BF=9E=E6=8E=A5=E7=AE=A1=E7=90=86=E7=BB=84?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 wifi-connect.c 中实现了新的 Wi-Fi 连接管理模块,负责配网、连接及状态上报。 添加了用于 Wi-Fi 配置和状态显示的 HTML 界面。 集成了 NVS 用于存储 Wi-Fi 凭证。 更新了 CMakeLists.txt 以包含新模块的依赖项。 修改了 main.c 以初始化 Wi-Fi 连接管理并等待连接成功。 --- README.md | 52 + components/bh1750/CMakeLists.txt | 3 + components/bh1750/README.md | 13 + components/bh1750/bh1750.c | 144 +++ components/bh1750/bh1750_use.c | 131 +++ components/bh1750/include/bh1750.h | 136 +++ components/bh1750/include/bh1750_use.h | 38 + components/relay_ctrl/CMakeLists.txt | 3 + components/relay_ctrl/include/relay_ctrl.h | 63 + components/relay_ctrl/relay_ctrl.c | 128 ++ components/sntp_time/CMakeLists.txt | 3 + components/sntp_time/include/sntp_time.h | 20 + components/sntp_time/sntp_time.c | 157 +++ components/wifi-connect/CMakeLists.txt | 5 + components/wifi-connect/Kconfig.projbuild | 18 + components/wifi-connect/README.md | 165 +++ .../wifi-connect/include/wifi-connect.h | 34 + components/wifi-connect/wifi-connect.c | 1028 +++++++++++++++++ main/CMakeLists.txt | 3 +- main/main.c | 21 +- 20 files changed, 2163 insertions(+), 2 deletions(-) create mode 100644 components/bh1750/CMakeLists.txt create mode 100644 components/bh1750/README.md create mode 100644 components/bh1750/bh1750.c create mode 100644 components/bh1750/bh1750_use.c create mode 100644 components/bh1750/include/bh1750.h create mode 100644 components/bh1750/include/bh1750_use.h create mode 100644 components/relay_ctrl/CMakeLists.txt create mode 100644 components/relay_ctrl/include/relay_ctrl.h create mode 100644 components/relay_ctrl/relay_ctrl.c create mode 100644 components/sntp_time/CMakeLists.txt create mode 100644 components/sntp_time/include/sntp_time.h create mode 100644 components/sntp_time/sntp_time.c create mode 100644 components/wifi-connect/CMakeLists.txt create mode 100644 components/wifi-connect/Kconfig.projbuild create mode 100644 components/wifi-connect/README.md create mode 100644 components/wifi-connect/include/wifi-connect.h create mode 100644 components/wifi-connect/wifi-connect.c diff --git a/README.md b/README.md index e69de29..5322915 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,52 @@ +## 引脚分配 + +### IIC设备 + +这类设备有:BH1750光照传感器、AHT30温湿度传感器 + +| 设备名称 | SCL引脚 | SDA引脚 | +| -------- | ------- | ------- | +| BH1750 | GPIO1 | GPIO2 | +| AHT30 | GPIO1 | GPIO2 | + +### 继电器 + +一共有4个继电器,分别控制4个设备的开关,从1号到4号依次为:风扇、照明灯、制冷片、加热器。 + +| 设备名称 | 继电器编号 | GPIO引脚 | +| -------- | ---------- | -------- | +| 风扇 | 1 | GPIO37 | +| 照明灯 | 2 | GPIO38 | +| 制冷片 | 3 | GPIO39 | +| 加热器 | 4 | GPIO40 | + +### ADC传感器 + +这个用的是ADC输入,连接到GPIO3引脚。 + +| 设备名称 | GPIO引脚 | +| ---------- | -------- | +| 火焰传感器 | GPIO3 | +| MQ-2 | GPIO8 | + + +### 串口设备 + +| 设备名称 | TX引脚 | RX引脚 | +| -------- | ------ | ------ | +| 气体传感器 | GPIO4 | GPIO5 | +| 语音模块 | GPIO6 | GPIO7 | + +### IO设备 + +| 设备名称 | GPIO引脚 | +| -------- | -------- | +| HC-SR312 | GPIO16 | +| 微动开关 | GPIO17 | + + +### 称重模块 + +| 设备名称 | GPIO引脚 | +| -------- | -------- | +| HX711 | GPIO35 | GPIO36 | diff --git a/components/bh1750/CMakeLists.txt b/components/bh1750/CMakeLists.txt new file mode 100644 index 0000000..18d746d --- /dev/null +++ b/components/bh1750/CMakeLists.txt @@ -0,0 +1,3 @@ +idf_component_register(SRCS "bh1750.c" "bh1750_use.c" + INCLUDE_DIRS "include" + REQUIRES "esp_driver_i2c") diff --git a/components/bh1750/README.md b/components/bh1750/README.md new file mode 100644 index 0000000..5a0782e --- /dev/null +++ b/components/bh1750/README.md @@ -0,0 +1,13 @@ +# 组件:BH1750 + +[![组件注册](https://components.espressif.com/components/espressif/bh1750/badge.svg)](https://components.espressif.com/components/espressif/bh1750) +![维护状态](https://img.shields.io/badge/maintenance-as--is-yellow.svg) + +:warning: **BH1750 组件按“现状”提供,不再进行后续开发及兼容性维护** + +* 本组件将向您展示如何使用 I2C 模块读取外部 I2C 传感器数据,此处以 BH1750 光传感器(GY-30 模块)为例。 +* BH1750 测量模式: + * 单次模式:BH1750 仅在接收到单次测量命令时测量一次,因此每次需要获取光照强度值时,都需要发送该命令。 + * 连续模式:BH1750 在接收到连续测量命令后将持续进行测量,只需发送一次该命令,之后反复调用 `bh1750_get_data()` 即可获取光照强度值。 +## 注意: +* BH1750 在不同测量模式下的测量时间不同。可通过调用 `bh1750_change_measure_time()` 更改测量时间。 \ No newline at end of file diff --git a/components/bh1750/bh1750.c b/components/bh1750/bh1750.c new file mode 100644 index 0000000..6e9db5f --- /dev/null +++ b/components/bh1750/bh1750.c @@ -0,0 +1,144 @@ +/* + * SPDX-FileCopyrightText: 2015-2025 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "bh1750.h" +#include "driver/i2c_master.h" +#include "freertos/FreeRTOS.h" +#include "freertos/projdefs.h" // for pdMS_TO_TICKS + +#define BH_1750_MEASUREMENT_ACCURACY 1.2 /*!< BH1750 传感器的典型测量精度因子 */ + +#define BH1750_POWER_DOWN 0x00 /*!< 设置为掉电模式的命令 */ +#define BH1750_POWER_ON 0x01 /*!< 设置为上电模式的命令 */ +#define I2C_CLK_SPEED 400000 /*!< I2C 通信时钟频率 (400kHz) */ + +/** + * @brief BH1750 设备私有结构体 + */ +typedef struct { + i2c_master_dev_handle_t i2c_handle; /*!< I2C 主设备句柄 */ +} bh1750_dev_t; + +/** + * @brief 向 BH1750 写入一个字节的辅助函数 + */ +static esp_err_t bh1750_write_byte(const bh1750_dev_t *const sens, const uint8_t byte) +{ + return i2c_master_transmit(sens->i2c_handle, &byte, 1, pdMS_TO_TICKS(1000)); +} + +/** + * @brief 创建并注册 BH1750 设备 + * + * @param i2c_bus I2C 总线句柄 + * @param dev_addr 传感器 I2C 地址 (一般为 0x23 或 0x5C) + * @param handle_ret [out] 返回创建好的设备句柄 + * @return esp_err_t 成功返回 ESP_OK + */ +esp_err_t bh1750_create(i2c_master_bus_handle_t i2c_bus, const uint8_t dev_addr, bh1750_handle_t *handle_ret) +{ + esp_err_t ret = ESP_OK; + bh1750_dev_t *sensor = (bh1750_dev_t *) calloc(1, sizeof(bh1750_dev_t)); + if (!sensor) { + return ESP_ERR_NO_MEM; + } + + // 配置并添加新的 I2C 设备到总线 + const i2c_device_config_t i2c_dev_cfg = { + .device_address = dev_addr, + .scl_speed_hz = I2C_CLK_SPEED, + }; + ret = i2c_master_bus_add_device(i2c_bus, &i2c_dev_cfg, &sensor->i2c_handle); + if (ret != ESP_OK) { + free(sensor); + return ret; + } + + assert(sensor->i2c_handle); + *handle_ret = sensor; + return ret; +} + +/** + * @brief 删除 BH1750 设备并释放资源 + */ +esp_err_t bh1750_delete(bh1750_handle_t sensor) +{ + bh1750_dev_t *sens = (bh1750_dev_t *) sensor; + if (sens->i2c_handle) { + i2c_master_bus_rm_device(sens->i2c_handle); + } + free(sens); + return ESP_OK; +} + +/** + * @brief 进入掉电模式(低功耗) + */ +esp_err_t bh1750_power_down(bh1750_handle_t sensor) +{ + bh1750_dev_t *sens = (bh1750_dev_t *) sensor; + return bh1750_write_byte(sens, BH1750_POWER_DOWN); +} + +/** + * @brief 唤醒并进入上电模式 + */ +esp_err_t bh1750_power_on(bh1750_handle_t sensor) +{ + bh1750_dev_t *sens = (bh1750_dev_t *) sensor; + return bh1750_write_byte(sens, BH1750_POWER_ON); +} + +/** + * @brief 设置测量时间倍率 (MTreg) + * 用于改变传感器的测量灵敏度 + */ +esp_err_t bh1750_set_measure_time(bh1750_handle_t sensor, const uint8_t measure_time) +{ + bh1750_dev_t *sens = (bh1750_dev_t *) sensor; + uint32_t i = 0; + uint8_t buf[2] = {0x40, 0x60}; // MTreg 常量部分 + buf[0] |= measure_time >> 5; + buf[1] |= measure_time & 0x1F; + for (i = 0; i < 2; i++) { + esp_err_t ret = bh1750_write_byte(sens, buf[i]); + if (ESP_OK != ret) { + return ret; + } + } + return ESP_OK; +} + +/** + * @brief 设置测量模式(连续测量或单词测量,以及分辨率选择) + */ +esp_err_t bh1750_set_measure_mode(bh1750_handle_t sensor, const bh1750_measure_mode_t cmd_measure) +{ + bh1750_dev_t *sens = (bh1750_dev_t *) sensor; + return bh1750_write_byte(sens, (uint8_t)cmd_measure); +} + +/** + * @brief 获取测量结果 + * + * @param sensor 设备句柄 + * @param data [out] 返回转换后的光照强度值 (单位: Lux) + * @return esp_err_t + */ +esp_err_t bh1750_get_data(bh1750_handle_t sensor, float *const data) +{ + bh1750_dev_t *sens = (bh1750_dev_t *) sensor; + uint8_t read_buffer[2]; + // 从 I2C 读取 2 字节原始数据 + esp_err_t ret = i2c_master_receive(sens->i2c_handle, read_buffer, sizeof(read_buffer), pdMS_TO_TICKS(1000)); + if (ESP_OK != ret) { + return ret; + } + // 将原始数据转换为 Lux (公式: (高8位 << 8 | 低8位) / 1.2) + *data = (( read_buffer[0] << 8 | read_buffer[1] ) / BH_1750_MEASUREMENT_ACCURACY); + return ESP_OK; +} diff --git a/components/bh1750/bh1750_use.c b/components/bh1750/bh1750_use.c new file mode 100644 index 0000000..20c49d2 --- /dev/null +++ b/components/bh1750/bh1750_use.c @@ -0,0 +1,131 @@ +#include +#include "esp_log.h" +#include "driver/i2c_master.h" +#include "bh1750.h" +#include "bh1750_use.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +static const char *TAG = "BH1750_USE"; + +#define BH1750_READ_RETRY_COUNT 3 +#define BH1750_MEASURE_DELAY_MS 200 +#define BH1750_RETRY_INTERVAL_MS 30 + +static i2c_master_bus_handle_t s_i2c_bus_handle = NULL; +static bh1750_handle_t s_bh1750_handle = NULL; + +/** + * @brief 初始化 BH1750 传感器及其所需的 I2C 总线 + */ +esp_err_t bh1750_user_init(void) +{ + // 1. 配置并初始化 I2C 总线 (Master Bus) + i2c_master_bus_config_t bus_config = { + .clk_source = I2C_CLK_SRC_DEFAULT, + .i2c_port = I2C_NUM_0, + .scl_io_num = BH1750_I2C_SCL_IO, + .sda_io_num = BH1750_I2C_SDA_IO, + .glitch_ignore_cnt = 7, + }; + + esp_err_t ret = i2c_new_master_bus(&bus_config, &s_i2c_bus_handle); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "I2C 总线初始化失败: %s", esp_err_to_name(ret)); + return ret; + } + + // 2. 创建 BH1750 设备句柄 (使用驱动默认地址 0x23) + ret = bh1750_create(s_i2c_bus_handle, BH1750_I2C_ADDRESS_DEFAULT, &s_bh1750_handle); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "BH1750 设备创建失败: %s", esp_err_to_name(ret)); + if (s_i2c_bus_handle) { + i2c_del_master_bus(s_i2c_bus_handle); + s_i2c_bus_handle = NULL; + } + return ret; + } + + // 3. 初始上电 + ret = bh1750_power_on(s_bh1750_handle); + if (ret == ESP_OK) { + ESP_LOGI(TAG, "BH1750 初始化成功"); + } + + return ret; +} + +/** + * @brief 读取一次光照强度数据 (Lux) + */ +esp_err_t bh1750_user_read(float *lux) +{ + if (lux == NULL) { + return ESP_ERR_INVALID_ARG; + } + + if (s_bh1750_handle == NULL) { + return ESP_ERR_INVALID_STATE; + } + + esp_err_t ret = ESP_FAIL; + for (int attempt = 1; attempt <= BH1750_READ_RETRY_COUNT; ++attempt) { + // 单次模式每次读取前都先上电,避免传感器处于掉电状态导致返回 0 + ret = bh1750_power_on(s_bh1750_handle); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "上电失败(第%d次): %s", attempt, esp_err_to_name(ret)); + vTaskDelay(pdMS_TO_TICKS(BH1750_RETRY_INTERVAL_MS)); + continue; + } + + // 设置测量模式:单次高分辨率模式 (1lx) + ret = bh1750_set_measure_mode(s_bh1750_handle, BH1750_ONETIME_1LX_RES); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "设置测量模式失败(第%d次): %s", attempt, esp_err_to_name(ret)); + vTaskDelay(pdMS_TO_TICKS(BH1750_RETRY_INTERVAL_MS)); + continue; + } + + // 根据数据手册,单次高分辨率模式需要约 120ms-180ms 测量时间 + vTaskDelay(pdMS_TO_TICKS(BH1750_MEASURE_DELAY_MS)); + + ret = bh1750_get_data(s_bh1750_handle, lux); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "数据读取失败(第%d次): %s", attempt, esp_err_to_name(ret)); + vTaskDelay(pdMS_TO_TICKS(BH1750_RETRY_INTERVAL_MS)); + continue; + } + + // 在强光/室内环境下长期 0 Lux 通常不合理,重试一次可规避偶发总线抖动 + if (*lux <= 0.0f && attempt < BH1750_READ_RETRY_COUNT) { + ESP_LOGW(TAG, "读取到 0 Lux,准备重试(第%d次)", attempt); + vTaskDelay(pdMS_TO_TICKS(BH1750_RETRY_INTERVAL_MS)); + continue; + } + + return ESP_OK; + } + + if (ret == ESP_OK && *lux <= 0.0f) { + ESP_LOGW(TAG, "连续读取均为 0 Lux,请优先检查 I2C 上拉电阻和传感器供电"); + return ESP_OK; + } + + return ret; +} + +/** + * @brief 释放 BH1750 相关资源 + */ +void bh1750_user_deinit(void) +{ + if (s_bh1750_handle) { + bh1750_delete(s_bh1750_handle); + s_bh1750_handle = NULL; + } + if (s_i2c_bus_handle) { + i2c_del_master_bus(s_i2c_bus_handle); + s_i2c_bus_handle = NULL; + } + ESP_LOGI(TAG, "资源已释放"); +} diff --git a/components/bh1750/include/bh1750.h b/components/bh1750/include/bh1750.h new file mode 100644 index 0000000..062f8e8 --- /dev/null +++ b/components/bh1750/include/bh1750.h @@ -0,0 +1,136 @@ +/* + * SPDX-FileCopyrightText: 2015-2025 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file + * @brief BH1750 driver + */ + +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +#include "driver/i2c_types.h" +#include "esp_err.h" + +typedef enum { + BH1750_CONTINUE_1LX_RES = 0x10, /*!< Command to set measure mode as Continuously H-Resolution mode*/ + BH1750_CONTINUE_HALFLX_RES = 0x11, /*!< Command to set measure mode as Continuously H-Resolution mode2*/ + BH1750_CONTINUE_4LX_RES = 0x13, /*!< Command to set measure mode as Continuously L-Resolution mode*/ + BH1750_ONETIME_1LX_RES = 0x20, /*!< Command to set measure mode as One Time H-Resolution mode*/ + BH1750_ONETIME_HALFLX_RES = 0x21, /*!< Command to set measure mode as One Time H-Resolution mode2*/ + BH1750_ONETIME_4LX_RES = 0x23, /*!< Command to set measure mode as One Time L-Resolution mode*/ +} bh1750_measure_mode_t; + +#define BH1750_I2C_ADDRESS_DEFAULT (0x23) +typedef void *bh1750_handle_t; + +/** + * @brief Set bh1750 as power down mode (low current) + * + * @param sensor object handle of bh1750 + * + * @return + * - ESP_OK Success + * - ESP_FAIL Fail + */ +esp_err_t bh1750_power_down(bh1750_handle_t sensor); + +/** + * @brief Set bh1750 as power on mode + * + * @param sensor object handle of bh1750 + * + * @return + * - ESP_OK Success + * - ESP_FAIL Fail + */ +esp_err_t bh1750_power_on(bh1750_handle_t sensor); + +/** + * @brief Get light intensity from bh1750 + * + * @param sensor object handle of bh1750 + * @param[in] cmd_measure the instruction to set measurement mode + * + * @note + * You should call this funtion to set measurement mode before call bh1750_get_data() to acquire data. + * If you set onetime mode, you just can get one measurement result. + * If you set continuous mode, you can call bh1750_get_data() to acquire data repeatedly. + * + * @return + * - ESP_OK Success + * - ESP_FAIL Fail + */ +esp_err_t bh1750_set_measure_mode(bh1750_handle_t sensor, const bh1750_measure_mode_t cmd_measure); + +/** + * @brief Get light intensity from BH1750 + * + * Returns light intensity in [lx] corrected by typical BH1750 Measurement Accuracy (= 1.2). + * + * @see BH1750 datasheet Rev. D page 2 + * + * @note + * You should acquire data from the sensor after the measurement time is over, + * so take care of measurement time in different modes. + * + * @param sensor object handle of bh1750 + * @param[out] data light intensity value got from bh1750 in [lx] + * + * @return + * - ESP_OK Success + * - ESP_FAIL Fail + */ +esp_err_t bh1750_get_data(bh1750_handle_t sensor, float *const data); + +/** + * @brief Set measurement time + * + * This function is used to adjust BH1750 sensitivity, i.e. compensating influence from optical window. + * + * @see BH1750 datasheet Rev. D page 11 + * + * @param sensor object handle of bh1750 + * @param[in] measure_time measurement time + * + * @return + * - ESP_OK Success + * - ESP_FAIL Fail + */ +esp_err_t bh1750_set_measure_time(bh1750_handle_t sensor, const uint8_t measure_time); + +/** + * @brief Create and init sensor object and return a sensor handle + * + * @param[in] i2c_bus I2C bus handle. Obtained from i2c_new_master_bus().s + * @param[in] dev_addr I2C device address of sensor. Use BH1750_I2C_ADDRESS_DEFAULT for default address. + * @param[out] handle_ret Handle to created BH1750 driver object. + * + * @return + * - ESP_OK Success + * - ESP_ERR_NO_MEM Not enough memory for the driver + * - ESP_ERR_NOT_FOUND Sensor not found on the I2C bus + * - Others Error from underlying I2C driver + */ +esp_err_t bh1750_create(i2c_master_bus_handle_t i2c_bus, const uint8_t dev_addr, bh1750_handle_t *handle_ret); + +/** + * @brief Delete and release a sensor object + * + * @param sensor object handle of bh1750 + * + * @return + * - ESP_OK Success + * - ESP_FAIL Fail + */ +esp_err_t bh1750_delete(bh1750_handle_t sensor); + +#ifdef __cplusplus +} +#endif diff --git a/components/bh1750/include/bh1750_use.h b/components/bh1750/include/bh1750_use.h new file mode 100644 index 0000000..3f01625 --- /dev/null +++ b/components/bh1750/include/bh1750_use.h @@ -0,0 +1,38 @@ +#ifndef BH1750_USE_H +#define BH1750_USE_H + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// 定义使用的 I2C 引脚(根据你的硬件实际连接修改) +#define BH1750_I2C_SCL_IO 1 // ESP32-S3 建议引脚 +#define BH1750_I2C_SDA_IO 2 // ESP32-S3 建议引脚 + +/** + * @brief 初始化 BH1750 传感器及其所需的 I2C 总线 + * + * @return esp_err_t 成功返回 ESP_OK + */ +esp_err_t bh1750_user_init(void); + +/** + * @brief 读取一次光照强度数据 (Lux) + * + * @param lux [out] 存储结果的指针 + * @return esp_err_t 成功返回 ESP_OK + */ +esp_err_t bh1750_user_read(float *lux); + +/** + * @brief 释放 BH1750 相关资源 + */ +void bh1750_user_deinit(void); + +#ifdef __cplusplus +} +#endif + +#endif // BH1750_USE_H diff --git a/components/relay_ctrl/CMakeLists.txt b/components/relay_ctrl/CMakeLists.txt new file mode 100644 index 0000000..fe24dc1 --- /dev/null +++ b/components/relay_ctrl/CMakeLists.txt @@ -0,0 +1,3 @@ +idf_component_register(SRCS "relay_ctrl.c" + INCLUDE_DIRS "include" + REQUIRES esp_driver_gpio) \ No newline at end of file diff --git a/components/relay_ctrl/include/relay_ctrl.h b/components/relay_ctrl/include/relay_ctrl.h new file mode 100644 index 0000000..25cb4a9 --- /dev/null +++ b/components/relay_ctrl/include/relay_ctrl.h @@ -0,0 +1,63 @@ +#pragma once + +#include + +#include "driver/gpio.h" +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + RELAY_CTRL_ID_1 = 0, + RELAY_CTRL_ID_2 = 1, + RELAY_CTRL_ID_3 = 2, + RELAY_CTRL_ID_4 = 3, + RELAY_CTRL_ID_MAX, +} relay_ctrl_id_t; + +/** + * @brief 初始化四路继电器控制模块。 + * + * @param relay1_gpio 继电器1控制引脚 + * @param relay2_gpio 继电器2控制引脚 + * @param relay3_gpio 继电器3控制引脚 + * @param relay4_gpio 继电器4控制引脚 + * @param active_high 继电器有效电平,true=高电平吸合,false=低电平吸合 + */ +esp_err_t relay_ctrl_init(gpio_num_t relay1_gpio, + gpio_num_t relay2_gpio, + gpio_num_t relay3_gpio, + gpio_num_t relay4_gpio, + bool active_high); + +/** + * @brief 设置指定继电器状态。 + * + * @param relay_id 继电器编号 + * @param on true=吸合,false=断开 + */ +esp_err_t relay_ctrl_set(relay_ctrl_id_t relay_id, bool on); + +/** + * @brief 翻转指定继电器状态。 + */ +esp_err_t relay_ctrl_toggle(relay_ctrl_id_t relay_id); + +/** + * @brief 获取指定继电器状态。 + * + * @param relay_id 继电器编号 + * @param on_out [out] 当前逻辑状态,true=吸合,false=断开 + */ +esp_err_t relay_ctrl_get(relay_ctrl_id_t relay_id, bool *on_out); + +/** + * @brief 一次性设置四个继电器状态。 + */ +esp_err_t relay_ctrl_set_all(bool relay1_on, bool relay2_on, bool relay3_on, bool relay4_on); + +#ifdef __cplusplus +} +#endif diff --git a/components/relay_ctrl/relay_ctrl.c b/components/relay_ctrl/relay_ctrl.c new file mode 100644 index 0000000..585a13f --- /dev/null +++ b/components/relay_ctrl/relay_ctrl.c @@ -0,0 +1,128 @@ +#include "relay_ctrl.h" + +#include + +#include "esp_check.h" +#include "esp_log.h" + +static const char *TAG = "relay_ctrl"; + +typedef struct { + bool inited; + bool active_high; + gpio_num_t pins[RELAY_CTRL_ID_MAX]; + bool states[RELAY_CTRL_ID_MAX]; +} relay_ctx_t; + +static relay_ctx_t s_ctx; + +static inline int relay_level_from_state(bool on) +{ + return (on == s_ctx.active_high) ? 1 : 0; +} + +static esp_err_t relay_validate_id(relay_ctrl_id_t relay_id) +{ + ESP_RETURN_ON_FALSE(relay_id >= RELAY_CTRL_ID_1 && relay_id < RELAY_CTRL_ID_MAX, + ESP_ERR_INVALID_ARG, TAG, "invalid relay id"); + return ESP_OK; +} + +esp_err_t relay_ctrl_init(gpio_num_t relay1_gpio, + gpio_num_t relay2_gpio, + gpio_num_t relay3_gpio, + gpio_num_t relay4_gpio, + bool active_high) +{ + ESP_RETURN_ON_FALSE(GPIO_IS_VALID_OUTPUT_GPIO(relay1_gpio), ESP_ERR_INVALID_ARG, TAG, "relay1 gpio invalid"); + ESP_RETURN_ON_FALSE(GPIO_IS_VALID_OUTPUT_GPIO(relay2_gpio), ESP_ERR_INVALID_ARG, TAG, "relay2 gpio invalid"); + ESP_RETURN_ON_FALSE(GPIO_IS_VALID_OUTPUT_GPIO(relay3_gpio), ESP_ERR_INVALID_ARG, TAG, "relay3 gpio invalid"); + ESP_RETURN_ON_FALSE(GPIO_IS_VALID_OUTPUT_GPIO(relay4_gpio), ESP_ERR_INVALID_ARG, TAG, "relay4 gpio invalid"); + + const gpio_num_t relay_gpios[RELAY_CTRL_ID_MAX] = { + relay1_gpio, + relay2_gpio, + relay3_gpio, + relay4_gpio, + }; + + s_ctx.active_high = active_high; + + uint64_t pin_bit_mask = 0; + for (int i = 0; i < RELAY_CTRL_ID_MAX; ++i) { + s_ctx.pins[i] = relay_gpios[i]; + pin_bit_mask |= (1ULL << relay_gpios[i]); + } + + const gpio_config_t io_cfg = { + .pin_bit_mask = pin_bit_mask, + .mode = GPIO_MODE_OUTPUT, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .pull_up_en = GPIO_PULLUP_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + ESP_RETURN_ON_ERROR(gpio_config(&io_cfg), TAG, "relay gpio config failed"); + + // 默认上电全部断开 + for (int i = 0; i < RELAY_CTRL_ID_MAX; ++i) { + s_ctx.states[i] = false; + ESP_RETURN_ON_ERROR(gpio_set_level(relay_gpios[i], relay_level_from_state(false)), + TAG, + "relay set init level failed"); + } + + s_ctx.inited = true; + ESP_LOGI(TAG, + "继电器初始化完成: relay1=GPIO%d relay2=GPIO%d relay3=GPIO%d relay4=GPIO%d active_high=%d", + relay1_gpio, + relay2_gpio, + relay3_gpio, + relay4_gpio, + active_high); + return ESP_OK; +} + +esp_err_t relay_ctrl_set(relay_ctrl_id_t relay_id, bool on) +{ + ESP_RETURN_ON_FALSE(s_ctx.inited, ESP_ERR_INVALID_STATE, TAG, "relay not initialized"); + ESP_RETURN_ON_ERROR(relay_validate_id(relay_id), TAG, "invalid relay id"); + + ESP_RETURN_ON_ERROR(gpio_set_level(s_ctx.pins[relay_id], relay_level_from_state(on)), + TAG, + "relay set level failed"); + s_ctx.states[relay_id] = on; + ESP_LOGI(TAG, "继电器%d -> %s (GPIO%d level=%d)", + relay_id + 1, + on ? "ON" : "OFF", + s_ctx.pins[relay_id], + relay_level_from_state(on)); + return ESP_OK; +} + +esp_err_t relay_ctrl_toggle(relay_ctrl_id_t relay_id) +{ + ESP_RETURN_ON_FALSE(s_ctx.inited, ESP_ERR_INVALID_STATE, TAG, "relay not initialized"); + ESP_RETURN_ON_ERROR(relay_validate_id(relay_id), TAG, "invalid relay id"); + + return relay_ctrl_set(relay_id, !s_ctx.states[relay_id]); +} + +esp_err_t relay_ctrl_get(relay_ctrl_id_t relay_id, bool *on_out) +{ + ESP_RETURN_ON_FALSE(s_ctx.inited, ESP_ERR_INVALID_STATE, TAG, "relay not initialized"); + ESP_RETURN_ON_FALSE(on_out != NULL, ESP_ERR_INVALID_ARG, TAG, "on_out is null"); + ESP_RETURN_ON_ERROR(relay_validate_id(relay_id), TAG, "invalid relay id"); + + *on_out = s_ctx.states[relay_id]; + return ESP_OK; +} + +esp_err_t relay_ctrl_set_all(bool relay1_on, bool relay2_on, bool relay3_on, bool relay4_on) +{ + ESP_RETURN_ON_FALSE(s_ctx.inited, ESP_ERR_INVALID_STATE, TAG, "relay not initialized"); + ESP_RETURN_ON_ERROR(relay_ctrl_set(RELAY_CTRL_ID_1, relay1_on), TAG, "set relay1 failed"); + ESP_RETURN_ON_ERROR(relay_ctrl_set(RELAY_CTRL_ID_2, relay2_on), TAG, "set relay2 failed"); + ESP_RETURN_ON_ERROR(relay_ctrl_set(RELAY_CTRL_ID_3, relay3_on), TAG, "set relay3 failed"); + ESP_RETURN_ON_ERROR(relay_ctrl_set(RELAY_CTRL_ID_4, relay4_on), TAG, "set relay4 failed"); + return ESP_OK; +} diff --git a/components/sntp_time/CMakeLists.txt b/components/sntp_time/CMakeLists.txt new file mode 100644 index 0000000..a15cdf4 --- /dev/null +++ b/components/sntp_time/CMakeLists.txt @@ -0,0 +1,3 @@ +idf_component_register(SRCS "sntp_time.c" + INCLUDE_DIRS "include" + REQUIRES esp_timer esp_event esp_netif lwip) diff --git a/components/sntp_time/include/sntp_time.h b/components/sntp_time/include/sntp_time.h new file mode 100644 index 0000000..ef4ed84 --- /dev/null +++ b/components/sntp_time/include/sntp_time.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief 初始化 SNTP 并等待首次对时完成 + * + * @param timeout_ms 等待首次同步的超时时间(毫秒) + * @return esp_err_t ESP_OK 表示已完成同步 + */ +esp_err_t sntp_timp_sync_time(uint32_t timeout_ms); + +#ifdef __cplusplus +} +#endif diff --git a/components/sntp_time/sntp_time.c b/components/sntp_time/sntp_time.c new file mode 100644 index 0000000..894c261 --- /dev/null +++ b/components/sntp_time/sntp_time.c @@ -0,0 +1,157 @@ +#include +#include +#include +#include "sntp_time.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_log.h" +#include "esp_idf_version.h" +#include "esp_sntp.h" +#include "esp_netif_sntp.h" +#include "sys/time.h" +#include "esp_timer.h" + +static const char *TAG = "sntp_timp"; + +#define SNTP_TIME_VALID_UNIX_TS 1700000000 +#define SNTP_WAIT_POLL_MS 200 +#define SNTP_REFRESH_PERIOD_MS 1000 + +extern void set_var_sntp_time(const char *value) __attribute__((weak)); + +static TaskHandle_t s_time_refresh_task = NULL; + +static time_t get_current_time(void); + +static void publish_sntp_time_var(const char *value) +{ + if (set_var_sntp_time != NULL) { + set_var_sntp_time(value); + } +} + +static void format_current_time(char *buffer, size_t buffer_size) +{ + time_t now = get_current_time(); + struct tm timeinfo; + + localtime_r(&now, &timeinfo); + strftime(buffer, buffer_size, "%Y-%m-%d %H:%M:%S", &timeinfo); +} + +static void sntp_time_refresh_task(void *arg) +{ + (void)arg; + + char time_text[32]; + for (;;) { + format_current_time(time_text, sizeof(time_text)); + publish_sntp_time_var(time_text); + vTaskDelay(pdMS_TO_TICKS(SNTP_REFRESH_PERIOD_MS)); + } +} + +// =========================== 时间相关函数 =========================== +static void set_timezone(void) +{ + // 设置中国标准时间(北京时间) + setenv("TZ", "CST-8", 1); + tzset(); + ESP_LOGI(TAG, "时区设置为北京时间 (CST-8)"); +} + +static time_t get_current_time(void) +{ + // 使用POSIX函数获取时间 + return time(NULL); +} + +static void print_current_time(void) +{ + char buffer[64]; + format_current_time(buffer, sizeof(buffer)); + + ESP_LOGI(TAG, "当前时间: %s", buffer); +} + +static esp_err_t start_time_refresh_task_if_needed(void) +{ + if (s_time_refresh_task != NULL) { + return ESP_OK; + } + + BaseType_t ok = xTaskCreate(sntp_time_refresh_task, + "sntp_time", + 3072, + NULL, + 3, + &s_time_refresh_task); + return (ok == pdPASS) ? ESP_OK : ESP_ERR_NO_MEM; +} + +static void configure_sntp_servers(void) +{ + ESP_LOGI(TAG, "初始化SNTP服务"); + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0) + esp_sntp_setoperatingmode(SNTP_OPMODE_POLL); + esp_sntp_setservername(0, "cn.pool.ntp.org"); // 中国 NTP 服务器 + esp_sntp_setservername(1, "ntp1.aliyun.com"); // 阿里云 NTP 服务器 +#else + sntp_setoperatingmode(SNTP_OPMODE_POLL); + sntp_setservername(0, "cn.pool.ntp.org"); + sntp_setservername(1, "cn.pool.ntp.org"); + sntp_setservername(2, "ntp1.aliyun.com"); +#endif +} + +static esp_err_t wait_for_time_sync(uint32_t timeout_ms) +{ + int64_t start_ms = esp_timer_get_time() / 1000; + for (;;) { + time_t now = get_current_time(); + if (now >= SNTP_TIME_VALID_UNIX_TS) { + return ESP_OK; + } + + int64_t elapsed_ms = (esp_timer_get_time() / 1000) - start_ms; + if (elapsed_ms >= (int64_t)timeout_ms) { + return ESP_ERR_TIMEOUT; + } + + vTaskDelay(pdMS_TO_TICKS(SNTP_WAIT_POLL_MS)); + } +} + +esp_err_t sntp_timp_sync_time(uint32_t timeout_ms) +{ + if (timeout_ms == 0) { + timeout_ms = 10000; + } + + set_timezone(); + + if (esp_sntp_enabled()) { + esp_sntp_stop(); + } + + configure_sntp_servers(); + esp_sntp_init(); + + esp_err_t ret = wait_for_time_sync(timeout_ms); + if (ret == ESP_OK) { + print_current_time(); + char time_text[32]; + format_current_time(time_text, sizeof(time_text)); + publish_sntp_time_var(time_text); + + esp_err_t task_ret = start_time_refresh_task_if_needed(); + if (task_ret != ESP_OK) { + ESP_LOGW(TAG, "创建时间刷新任务失败: %s", esp_err_to_name(task_ret)); + } + } else { + ESP_LOGW(TAG, "SNTP 对时超时(%lu ms)", (unsigned long)timeout_ms); + } + + return ret; +} diff --git a/components/wifi-connect/CMakeLists.txt b/components/wifi-connect/CMakeLists.txt new file mode 100644 index 0000000..0437836 --- /dev/null +++ b/components/wifi-connect/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "wifi-connect.c" + INCLUDE_DIRS "include" + REQUIRES esp_wifi esp_timer esp_event esp_netif nvs_flash esp_http_server lwip driver +) diff --git a/components/wifi-connect/Kconfig.projbuild b/components/wifi-connect/Kconfig.projbuild new file mode 100644 index 0000000..7f69a3d --- /dev/null +++ b/components/wifi-connect/Kconfig.projbuild @@ -0,0 +1,18 @@ +menu "WiFi 连接" + +config WIFI_CONNECT_CONNECT_TIMEOUT_SEC + int "Wi-Fi 连接超时 (秒)" + range 5 180 + default 30 + +config WIFI_CONNECT_MAX_SCAN_RESULTS + int "最大扫描结果数" + range 5 50 + default 20 + +config WIFI_CONNECT_AP_MAX_CONNECTIONS + int "软AP最大连接数" + range 1 10 + default 4 + +endmenu diff --git a/components/wifi-connect/README.md b/components/wifi-connect/README.md new file mode 100644 index 0000000..865c19c --- /dev/null +++ b/components/wifi-connect/README.md @@ -0,0 +1,165 @@ +# wifi-connect 组件说明 + +`wifi-connect` 是一个基于 ESP-IDF 的 Wi-Fi 配网组件,支持: + +- 长按按键进入配网模式 +- 启动 SoftAP + Captive Portal(网页配网) +- 手机连接热点后,通过网页扫描并选择路由器 +- 保存 Wi-Fi 凭据到 NVS +- 下次开机自动重连 +- 支持两种配网模式:按键触发 / 常驻配网 + +面向最终用户的一页版操作说明见:`USER_GUIDE.md` +现场打印张贴版(四步卡)见:`QUICK_POSTER.md` + +--- + +## 目录结构 + +- `wifi-connect.c`:组件主实现(按键、APSTA、HTTP、DNS、状态机) +- `include/wifi-connect.h`:对外 API +- `Kconfig.projbuild`:组件配置项 +- `CMakeLists.txt`:组件构建依赖 + +--- + +## 对外 API + +头文件:`include/wifi-connect.h` + +- `esp_err_t wifi_connect_init(void);` + - 初始化组件(NVS、Wi-Fi、事件、按键任务等) + - 尝试自动连接已保存网络 + +- `esp_err_t wifi_connect_start(void);` + - 启动配网(APSTA + HTTP + DNS) + +- `esp_err_t wifi_connect_stop(void);` + - 停止配网(关闭热点与相关服务) + +- `wifi_connect_status_t wifi_connect_get_status(void);` + - 获取当前状态:`idle / provisioning / connecting / connected / failed / timeout` + +- `esp_err_t wifi_connect_get_config(wifi_connect_config_t *config);` + - 读取已保存的 Wi-Fi 凭据 + +- `esp_err_t wifi_connect_clear_config(void);` + - 清除已保存的 Wi-Fi 凭据(SSID/密码) + +--- + +## 快速使用 + +在 `main/main.c`: + +```c +#include "esp_check.h" +#include "wifi-connect.h" + +void app_main(void) +{ + ESP_ERROR_CHECK(wifi_connect_init()); +} +``` + +运行后: + +1. 选择配网模式: + - 按键触发模式:长按配置按键进入配网 + - 常驻配网模式:上电自动进入配网 +2. 手机连接 `ESP32-xxxxxx` 热点 +3. 打开 `http://192.168.4.1` +4. 选择 Wi-Fi 并输入密码提交 +5. 配网行为: + - 按键触发模式:连接成功后按配置自动关闭热点 + - 常驻配网模式:配网热点保持开启,不自动关闭 + +如需清空历史凭据,可在配网页面点击“清除已保存”。 + +--- + +## Kconfig 配置项 + +在 `idf.py menuconfig` 中:`WiFi Connect` 菜单 + +- `Provisioning mode`:配网模式(二选一) + - `Button triggered`:按键触发配网(默认) + - `Always-on provisioning`:常驻配网(上电自动进入且不自动关闭) + +- `WIFI_CONNECT_BUTTON_GPIO`:进入配网的按键 GPIO +- `WIFI_CONNECT_BUTTON_ACTIVE_LEVEL`:按键有效电平 +- `WIFI_CONNECT_DEBOUNCE_MS`:按键去抖时间 +- `WIFI_CONNECT_LONG_PRESS_MS`:长按触发时长 +- `WIFI_CONNECT_BUTTON_STARTUP_GUARD_MS`:上电保护窗口(该时间内忽略长按检测) +- `WIFI_CONNECT_BUTTON_RELEASE_ARM_MS`:松手解锁时间(先稳定松手再允许长按触发) +- `WIFI_CONNECT_CONNECT_TIMEOUT_SEC`:连接路由器超时 +- `WIFI_CONNECT_IDLE_TIMEOUT_SEC`:配网页面空闲超时 +- `WIFI_CONNECT_MAX_SCAN_RESULTS`:扫描网络最大数量 +- `WIFI_CONNECT_AP_MAX_CONNECTIONS`:SoftAP 最大连接数 +- `WIFI_CONNECT_AP_GRACEFUL_STOP_SEC`:联网成功后 AP 延迟关闭秒数 + +--- + +## 日志与状态说明(中文) + +组件会输出统一中文状态日志,例如: + +- `【状态】wifi-connect 初始化完成` +- `【状态】检测到按键长按:开始进入配网模式` +- `【状态】配网已启动:配网热点已开启,SSID=...` +- `【状态】开始连接路由器:收到配网请求,目标网络=...` +- `【状态】联网成功:已连接 ...,获取 IP=...` +- `【状态】配网已停止:热点已关闭,设备继续以 STA 模式运行` + +说明:ESP-IDF 驱动层(如 `wifi:`、`esp_netif_lwip:`)仍会输出英文日志,这是框架默认行为。 + +--- + +## 常见问题 + +### 1) 手机连上热点但不自动弹出页面 + +- 手动访问:`http://192.168.4.1` +- 确认手机没有强制使用 HTTPS +- 查看串口是否有 `配网已启动`、`DNS 劫持服务已启动` 日志 + +### 2) 提交后连接失败 + +- 检查密码是否正确 +- 查看日志中的失败原因码(`连接失败,原因=...`) +- 检查路由器是否禁用了新设备接入 +- 若曾保存过旧配置,可先在页面点击“清除已保存”后再重试 + +### 4) 按键未按下却误触发配网 + +- 常见原因是按键引脚与 LCD/外设复用,初始化期间电平抖动被误判为长按 +- 可增大 `WIFI_CONNECT_BUTTON_STARTUP_GUARD_MS`(如 8000~10000) +- 可增大 `WIFI_CONNECT_BUTTON_RELEASE_ARM_MS`(如 300~500) +- 若硬件允许,优先给配网按键使用独立 GPIO + +### 5) 成功后热点消失是否正常 + +- 在按键触发模式下:正常,可通过 `WIFI_CONNECT_AP_GRACEFUL_STOP_SEC` 调整关闭延时 +- 在常驻配网模式下:热点不会自动关闭 + +--- + +## 依赖 + +由 `CMakeLists.txt` 声明: + +- `esp_wifi` +- `esp_timer` +- `esp_event` +- `esp_netif` +- `nvs_flash` +- `esp_http_server` +- `lwip` +- `driver` + +--- + +## 版本建议 + +- 推荐 ESP-IDF `v5.5.x` +- 当前项目验证环境:`esp-idf v5.5.2`(ESP32-C3) diff --git a/components/wifi-connect/include/wifi-connect.h b/components/wifi-connect/include/wifi-connect.h new file mode 100644 index 0000000..37a325f --- /dev/null +++ b/components/wifi-connect/include/wifi-connect.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + WIFI_CONNECT_STATUS_IDLE = 0, + WIFI_CONNECT_STATUS_PROVISIONING, + WIFI_CONNECT_STATUS_CONNECTING, + WIFI_CONNECT_STATUS_CONNECTED, + WIFI_CONNECT_STATUS_FAILED, + WIFI_CONNECT_STATUS_TIMEOUT, +} wifi_connect_status_t; + +typedef struct { + bool has_config; + char ssid[33]; + char password[65]; +} wifi_connect_config_t; + +esp_err_t wifi_connect_init(void); +esp_err_t wifi_connect_start(void); +esp_err_t wifi_connect_stop(void); +wifi_connect_status_t wifi_connect_get_status(void); +esp_err_t wifi_connect_get_config(wifi_connect_config_t *config); +esp_err_t wifi_connect_clear_config(void); + +#ifdef __cplusplus +} +#endif diff --git a/components/wifi-connect/wifi-connect.c b/components/wifi-connect/wifi-connect.c new file mode 100644 index 0000000..7d5db7d --- /dev/null +++ b/components/wifi-connect/wifi-connect.c @@ -0,0 +1,1028 @@ +#include +#include +#include +#include +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/semphr.h" + +#include "driver/gpio.h" +#include "esp_check.h" +#include "esp_event.h" +#include "esp_http_server.h" +#include "esp_log.h" +#include "esp_mac.h" +#include "esp_netif.h" +#include "esp_netif_ip_addr.h" +#include "esp_timer.h" +#include "esp_system.h" +#include "esp_wifi.h" +#include "nvs.h" +#include "nvs_flash.h" + +#include "lwip/sockets.h" +#include "lwip/inet.h" + +#include "wifi-connect.h" + +#define WIFI_CONNECT_NVS_NAMESPACE "wifi_connect" +#define WIFI_CONNECT_NVS_KEY_SSID "ssid" +#define WIFI_CONNECT_NVS_KEY_PASS "pass" + +#define WIFI_CONNECT_HTTP_BUF_SIZE 256 + +static const char *TAG = "wifi_connect"; + +static void wifi_connect_log_state_i(const char *state, const char *detail) +{ + if (detail != NULL && detail[0] != '\0') + ESP_LOGI(TAG, "【状态】%s:%s", state, detail); + else + ESP_LOGI(TAG, "【状态】%s", state); +} + +static void wifi_connect_log_state_w(const char *state, const char *detail) +{ + if (detail != NULL && detail[0] != '\0') + ESP_LOGW(TAG, "【状态】%s:%s", state, detail); + else + ESP_LOGW(TAG, "【状态】%s", state); +} + +static void wifi_connect_log_state_e(const char *state, const char *detail) +{ + if (detail != NULL && detail[0] != '\0') + ESP_LOGE(TAG, "【状态】%s:%s", state, detail); + else + ESP_LOGE(TAG, "【状态】%s", state); +} + +typedef struct +{ + wifi_connect_status_t status; + bool initialized; + bool wifi_started; + bool provisioning_active; + bool sta_connected; + bool sta_connect_requested; + bool auto_connecting; + + esp_netif_t *sta_netif; + esp_netif_t *ap_netif; + httpd_handle_t http_server; + esp_event_handler_instance_t wifi_event_instance; + esp_event_handler_instance_t ip_event_instance; + + TaskHandle_t dns_task; + SemaphoreHandle_t lock; + + esp_timer_handle_t connect_timer; + + int dns_sock; + bool dns_running; + + char ap_ssid[32]; + char pending_ssid[33]; + char pending_password[65]; + char last_error[96]; + + char sta_ip[16]; + esp_timer_handle_t ap_stop_timer; +} wifi_connect_ctx_t; + +static wifi_connect_ctx_t s_ctx = { + .status = WIFI_CONNECT_STATUS_IDLE, + .dns_sock = -1, +}; + +static const char *s_html_page = + "" + "" + "设备联网控制台" + "
" + "

设备配网

" + "
" + "

请选择网络并输入密码连接路由器。

" + "
" + "" + "" + "
" + "
" + "" + "
" + "
"; + +static void wifi_connect_set_status_locked(wifi_connect_status_t status) +{ + s_ctx.status = status; +} + +static void wifi_connect_set_error_locked(const char *message) +{ + if (message == NULL) + { + s_ctx.last_error[0] = '\0'; + return; + } + snprintf(s_ctx.last_error, sizeof(s_ctx.last_error), "%s", message); +} + +static esp_err_t wifi_connect_save_credentials(const char *ssid, const char *password) +{ + nvs_handle_t handle; + ESP_RETURN_ON_ERROR(nvs_open(WIFI_CONNECT_NVS_NAMESPACE, NVS_READWRITE, &handle), TAG, "open nvs failed"); + esp_err_t err = nvs_set_str(handle, WIFI_CONNECT_NVS_KEY_SSID, ssid); + if (err == ESP_OK) + err = nvs_set_str(handle, WIFI_CONNECT_NVS_KEY_PASS, password); + if (err == ESP_OK) + err = nvs_commit(handle); + nvs_close(handle); + return err; +} + +esp_err_t wifi_connect_clear_config(void) +{ + nvs_handle_t handle; + ESP_RETURN_ON_ERROR(nvs_open(WIFI_CONNECT_NVS_NAMESPACE, NVS_READWRITE, &handle), TAG, "open nvs failed"); + + esp_err_t err_ssid = nvs_erase_key(handle, WIFI_CONNECT_NVS_KEY_SSID); + esp_err_t err_pass = nvs_erase_key(handle, WIFI_CONNECT_NVS_KEY_PASS); + + if (err_ssid != ESP_OK && err_ssid != ESP_ERR_NVS_NOT_FOUND) + { + nvs_close(handle); + return err_ssid; + } + if (err_pass != ESP_OK && err_pass != ESP_ERR_NVS_NOT_FOUND) + { + nvs_close(handle); + return err_pass; + } + + esp_err_t err = nvs_commit(handle); + nvs_close(handle); + if (err != ESP_OK) + return err; + + if (s_ctx.initialized && s_ctx.lock != NULL) + { + bool should_disconnect = false; + xSemaphoreTake(s_ctx.lock, portMAX_DELAY); + s_ctx.pending_ssid[0] = '\0'; + s_ctx.pending_password[0] = '\0'; + wifi_connect_set_error_locked(NULL); + if (s_ctx.provisioning_active) + wifi_connect_set_status_locked(WIFI_CONNECT_STATUS_PROVISIONING); + if (s_ctx.status == WIFI_CONNECT_STATUS_CONNECTING) + { + s_ctx.sta_connect_requested = false; + s_ctx.auto_connecting = false; + wifi_connect_set_status_locked(s_ctx.provisioning_active ? WIFI_CONNECT_STATUS_PROVISIONING : WIFI_CONNECT_STATUS_IDLE); + should_disconnect = true; + } + xSemaphoreGive(s_ctx.lock); + + if (should_disconnect) + esp_wifi_disconnect(); + } + + wifi_connect_log_state_i("已清除保存的 Wi-Fi 配置", "下次上电将不会自动重连"); + return ESP_OK; +} + +esp_err_t wifi_connect_get_config(wifi_connect_config_t *config) +{ + ESP_RETURN_ON_FALSE(config != NULL, ESP_ERR_INVALID_ARG, TAG, "config is null"); + memset(config, 0, sizeof(*config)); + + nvs_handle_t handle; + esp_err_t err = nvs_open(WIFI_CONNECT_NVS_NAMESPACE, NVS_READONLY, &handle); + if (err != ESP_OK) + return err; + + size_t ssid_len = sizeof(config->ssid); + size_t pass_len = sizeof(config->password); + err = nvs_get_str(handle, WIFI_CONNECT_NVS_KEY_SSID, config->ssid, &ssid_len); + if (err == ESP_OK) + err = nvs_get_str(handle, WIFI_CONNECT_NVS_KEY_PASS, config->password, &pass_len); + nvs_close(handle); + config->has_config = (err == ESP_OK && config->ssid[0] != '\0'); + return err; +} + +static const char *wifi_connect_status_to_string(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"; + } +} + +wifi_connect_status_t wifi_connect_get_status(void) +{ + if (!s_ctx.initialized || s_ctx.lock == NULL) + return WIFI_CONNECT_STATUS_IDLE; + + wifi_connect_status_t status; + xSemaphoreTake(s_ctx.lock, portMAX_DELAY); + status = s_ctx.status; + xSemaphoreGive(s_ctx.lock); + return status; +} + +static esp_err_t wifi_connect_send_json(httpd_req_t *req, const char *json) +{ + httpd_resp_set_type(req, "application/json"); + return httpd_resp_sendstr(req, json); +} + +static void wifi_connect_json_escape(const char *src, char *dst, size_t dst_size) +{ + size_t j = 0; + for (size_t i = 0; src != NULL && src[i] != '\0' && j + 2 < dst_size; ++i) + { + char c = src[i]; + if (c == '"' || c == '\\') + { + dst[j++] = '\\'; + dst[j++] = c; + } + else if ((unsigned char)c >= 32 && (unsigned char)c <= 126) + dst[j++] = c; + } + dst[j] = '\0'; +} + +static const char *wifi_connect_auth_to_string(wifi_auth_mode_t auth) +{ + switch (auth) + { + case WIFI_AUTH_OPEN: + return "OPEN"; + case WIFI_AUTH_WEP: + return "WEP"; + case WIFI_AUTH_WPA_PSK: + return "WPA"; + case WIFI_AUTH_WPA2_PSK: + return "WPA2"; + case WIFI_AUTH_WPA_WPA2_PSK: + return "WPA/WPA2"; + case WIFI_AUTH_WPA2_ENTERPRISE: + return "WPA2-ENT"; + case WIFI_AUTH_WPA3_PSK: + return "WPA3"; + case WIFI_AUTH_WPA2_WPA3_PSK: + return "WPA2/WPA3"; + default: + return "UNKNOWN"; + } +} + +static esp_err_t wifi_connect_http_scan_handler(httpd_req_t *req) +{ + wifi_scan_config_t scan_cfg = {.show_hidden = false}; + esp_err_t err = esp_wifi_scan_start(&scan_cfg, true); + if (err != ESP_OK) + return wifi_connect_send_json(req, "{\"networks\":[],\"error\":\"scan_failed\"}"); + + uint16_t count = CONFIG_WIFI_CONNECT_MAX_SCAN_RESULTS; + wifi_ap_record_t *records = calloc(count, sizeof(wifi_ap_record_t)); + if (records == NULL) + return ESP_ERR_NO_MEM; + + err = esp_wifi_scan_get_ap_records(&count, records); + if (err != ESP_OK) + { + free(records); + return wifi_connect_send_json(req, "{\"networks\":[],\"error\":\"scan_read_failed\"}"); + } + + size_t out_size = 512 + count * 96; + char *out = calloc(1, out_size); + if (out == NULL) + { + free(records); + return ESP_ERR_NO_MEM; + } + + size_t used = snprintf(out, out_size, "{\"networks\":["); + for (uint16_t i = 0; i < count && used + 96 < out_size; ++i) + { + char ssid[65] = {0}; + char escaped[130] = {0}; + snprintf(ssid, sizeof(ssid), "%s", (char *)records[i].ssid); + wifi_connect_json_escape(ssid, escaped, sizeof(escaped)); + used += snprintf(out + used, out_size - used, + "%s{\"ssid\":\"%s\",\"rssi\":%d,\"auth\":\"%s\"}", + (i == 0 ? "" : ","), escaped, records[i].rssi, + wifi_connect_auth_to_string(records[i].authmode)); + } + snprintf(out + used, out_size - used, "]}"); + + err = wifi_connect_send_json(req, out); + free(out); + free(records); + return err; +} + +static bool wifi_connect_extract_json_string(const char *json, const char *key, char *out, size_t out_len) +{ + char pattern[32]; + snprintf(pattern, sizeof(pattern), "\"%s\":\"", key); + const char *start = strstr(json, pattern); + if (start == NULL) + return false; + start += strlen(pattern); + size_t idx = 0; + while (*start != '\0' && *start != '"' && idx + 1 < out_len) + { + if (*start == '\\' && *(start + 1) != '\0') + start++; + out[idx++] = *start++; + } + out[idx] = '\0'; + return idx > 0 || strcmp(key, "password") == 0; +} + +static esp_err_t wifi_connect_apply_sta_credentials(const char *ssid, const char *password) +{ + wifi_config_t sta_cfg = {0}; + snprintf((char *)sta_cfg.sta.ssid, sizeof(sta_cfg.sta.ssid), "%s", ssid); + snprintf((char *)sta_cfg.sta.password, sizeof(sta_cfg.sta.password), "%s", password); + sta_cfg.sta.scan_method = WIFI_FAST_SCAN; + sta_cfg.sta.threshold.authmode = WIFI_AUTH_OPEN; + sta_cfg.sta.pmf_cfg.capable = true; + sta_cfg.sta.pmf_cfg.required = false; + + esp_err_t dis_err = esp_wifi_disconnect(); + if (dis_err != ESP_OK && dis_err != ESP_ERR_WIFI_NOT_CONNECT) + ESP_LOGW(TAG, "Disconnect STA failed: %s", esp_err_to_name(dis_err)); + + ESP_RETURN_ON_ERROR(esp_wifi_set_config(WIFI_IF_STA, &sta_cfg), TAG, "set sta config failed"); + ESP_RETURN_ON_ERROR(esp_wifi_connect(), TAG, "wifi connect failed"); + return ESP_OK; +} + +static esp_err_t wifi_connect_http_connect_handler(httpd_req_t *req) +{ + if (req->content_len <= 0 || req->content_len >= WIFI_CONNECT_HTTP_BUF_SIZE) + return wifi_connect_send_json(req, "{\"ok\":false,\"error\":\"invalid_payload\"}"); + + char body[WIFI_CONNECT_HTTP_BUF_SIZE] = {0}; + int received = httpd_req_recv(req, body, sizeof(body) - 1); + if (received <= 0) + return wifi_connect_send_json(req, "{\"ok\":false,\"error\":\"read_failed\"}"); + body[received] = '\0'; + + char ssid[33] = {0}; + char password[65] = {0}; + if (!wifi_connect_extract_json_string(body, "ssid", ssid, sizeof(ssid))) + return wifi_connect_send_json(req, "{\"ok\":false,\"error\":\"ssid_missing\"}"); + wifi_connect_extract_json_string(body, "password", password, sizeof(password)); + + char req_msg[96] = {0}; + snprintf(req_msg, sizeof(req_msg), "收到配网请求,目标网络:%s", ssid); + wifi_connect_log_state_i("开始连接路由器", req_msg); + + xSemaphoreTake(s_ctx.lock, portMAX_DELAY); + snprintf(s_ctx.pending_ssid, sizeof(s_ctx.pending_ssid), "%s", ssid); + snprintf(s_ctx.pending_password, sizeof(s_ctx.pending_password), "%s", password); + wifi_connect_set_status_locked(WIFI_CONNECT_STATUS_CONNECTING); + wifi_connect_set_error_locked(NULL); + s_ctx.sta_connect_requested = true; + s_ctx.auto_connecting = false; + xSemaphoreGive(s_ctx.lock); + + esp_err_t err = wifi_connect_apply_sta_credentials(ssid, password); + if (err != ESP_OK) + { + xSemaphoreTake(s_ctx.lock, portMAX_DELAY); + wifi_connect_set_status_locked(WIFI_CONNECT_STATUS_FAILED); + wifi_connect_set_error_locked("启动连接失败"); + xSemaphoreGive(s_ctx.lock); + return wifi_connect_send_json(req, "{\"ok\":false,\"error\":\"connect_start_failed\"}"); + } + + esp_timer_stop(s_ctx.connect_timer); + esp_timer_start_once(s_ctx.connect_timer, (uint64_t)CONFIG_WIFI_CONNECT_CONNECT_TIMEOUT_SEC * 1000000ULL); + return wifi_connect_send_json(req, "{\"ok\":true}"); +} + +static esp_err_t wifi_connect_http_status_handler(httpd_req_t *req) +{ + wifi_connect_status_t status; + char error[96] = {0}; + xSemaphoreTake(s_ctx.lock, portMAX_DELAY); + status = s_ctx.status; + snprintf(error, sizeof(error), "%s", s_ctx.last_error); + xSemaphoreGive(s_ctx.lock); + + char escaped[192] = {0}; + wifi_connect_json_escape(error, escaped, sizeof(escaped)); + + char payload[260]; + snprintf(payload, sizeof(payload), "{\"status\":\"%s\",\"error\":\"%s\"}", + wifi_connect_status_to_string(status), escaped); + return wifi_connect_send_json(req, payload); +} + +static esp_err_t wifi_connect_http_clear_handler(httpd_req_t *req) +{ + esp_err_t err = wifi_connect_clear_config(); + if (err != ESP_OK) + return wifi_connect_send_json(req, "{\"ok\":false,\"error\":\"clear_failed\"}"); + return wifi_connect_send_json(req, "{\"ok\":true}"); +} + +static esp_err_t wifi_connect_http_sysinfo_handler(httpd_req_t *req) +{ + char payload[128]; + snprintf(payload, sizeof(payload), "{\"uptime\":%lld,\"free_heap\":%" PRIu32 "}", esp_timer_get_time() / 1000000ULL, esp_get_free_heap_size() / 1024); + return wifi_connect_send_json(req, payload); +} + +static esp_err_t wifi_connect_http_index_handler(httpd_req_t *req) +{ + httpd_resp_set_hdr(req, "Cache-Control", "no-store, no-cache, must-revalidate, max-age=0"); + httpd_resp_set_hdr(req, "Pragma", "no-cache"); + httpd_resp_set_type(req, "text/html"); + return httpd_resp_send(req, s_html_page, HTTPD_RESP_USE_STRLEN); +} + +static void wifi_connect_get_ap_http_url(char *out, size_t out_len) +{ + esp_netif_ip_info_t ip_info = {0}; + if (s_ctx.ap_netif != NULL && esp_netif_get_ip_info(s_ctx.ap_netif, &ip_info) == ESP_OK) + { + uint32_t ip = ntohl(ip_info.ip.addr); + snprintf(out, out_len, "http://%" PRIu32 ".%" PRIu32 ".%" PRIu32 ".%" PRIu32 "/", + (ip >> 24) & 0xFF, (ip >> 16) & 0xFF, (ip >> 8) & 0xFF, ip & 0xFF); + return; + } + snprintf(out, out_len, "http://192.168.4.1/"); +} + +static esp_err_t wifi_connect_http_probe_handler(httpd_req_t *req) +{ + char location[48] = {0}; + wifi_connect_get_ap_http_url(location, sizeof(location)); + httpd_resp_set_status(req, "302 Found"); + httpd_resp_set_hdr(req, "Location", location); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + return httpd_resp_send(req, NULL, 0); +} + +static esp_err_t wifi_connect_http_start(void); +static void wifi_connect_http_stop(void); + +static esp_err_t wifi_connect_http_start(void) +{ + if (s_ctx.http_server != NULL) + return ESP_OK; + + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.max_uri_handlers = 20; + config.uri_match_fn = httpd_uri_match_wildcard; + config.max_open_sockets = 2; + ESP_RETURN_ON_ERROR(httpd_start(&s_ctx.http_server, &config), TAG, "start http server failed"); + + const httpd_uri_t handlers[] = { + {"/", HTTP_GET, wifi_connect_http_index_handler, NULL}, + {"/api/scan", HTTP_GET, wifi_connect_http_scan_handler, NULL}, + {"/api/connect", HTTP_POST, wifi_connect_http_connect_handler, NULL}, + {"/api/status", HTTP_GET, wifi_connect_http_status_handler, NULL}, + {"/api/clear", HTTP_POST, wifi_connect_http_clear_handler, NULL}, + {"/api/sysinfo", HTTP_GET, wifi_connect_http_sysinfo_handler, NULL}, + {"/generate_204", HTTP_GET, wifi_connect_http_probe_handler, NULL}, + {"/hotspot-detect.html", HTTP_GET, wifi_connect_http_probe_handler, NULL}, + {"/ncsi.txt", HTTP_GET, wifi_connect_http_probe_handler, NULL}, + {"/connecttest.txt", HTTP_GET, wifi_connect_http_probe_handler, NULL}, + {"/redirect", HTTP_GET, wifi_connect_http_probe_handler, NULL}, + {"/canonical.html", HTTP_GET, wifi_connect_http_probe_handler, NULL}, + {"/mobile/status.php", HTTP_GET, wifi_connect_http_probe_handler, NULL}, + {"/success.txt", HTTP_GET, wifi_connect_http_probe_handler, NULL}, + {"/library/test/success.html", HTTP_GET, wifi_connect_http_probe_handler, NULL}, + {"/*", HTTP_GET, wifi_connect_http_probe_handler, NULL}, + }; + + for (size_t i = 0; i < sizeof(handlers) / sizeof(handlers[0]); ++i) + { + if (httpd_register_uri_handler(s_ctx.http_server, &handlers[i]) != ESP_OK) + { + httpd_stop(s_ctx.http_server); + s_ctx.http_server = NULL; + return ESP_FAIL; + } + } + return ESP_OK; +} + +static void wifi_connect_http_stop(void) +{ + if (s_ctx.http_server != NULL) + { + httpd_stop(s_ctx.http_server); + s_ctx.http_server = NULL; + } +} + +static size_t wifi_connect_build_dns_response(const uint8_t *req, size_t req_len, uint8_t *resp, size_t resp_max, uint32_t ip_addr) +{ + if (req_len < 12 || resp_max < 64) + return 0; + + const size_t q_offset = 12; + size_t q_name_end = q_offset; + while (q_name_end < req_len && req[q_name_end] != 0) + q_name_end += req[q_name_end] + 1; + + if (q_name_end + 5 >= req_len) + return 0; + size_t question_len = (q_name_end + 5) - q_offset; + + resp[0] = req[0]; + resp[1] = req[1]; + resp[2] = 0x81; + resp[3] = 0x80; + resp[4] = 0x00; + resp[5] = 0x01; + resp[6] = 0x00; + resp[7] = 0x01; + resp[8] = 0x00; + resp[9] = 0x00; + resp[10] = 0x00; + resp[11] = 0x00; + + memcpy(&resp[12], &req[q_offset], question_len); + size_t pos = 12 + question_len; + if (pos + 16 > resp_max) + return 0; + + resp[pos++] = 0xC0; + resp[pos++] = 0x0C; + resp[pos++] = 0x00; + resp[pos++] = 0x01; + resp[pos++] = 0x00; + resp[pos++] = 0x01; + resp[pos++] = 0x00; + resp[pos++] = 0x00; + resp[pos++] = 0x00; + resp[pos++] = 0x3C; + resp[pos++] = 0x00; + resp[pos++] = 0x04; + resp[pos++] = (ip_addr >> 24) & 0xFF; + resp[pos++] = (ip_addr >> 16) & 0xFF; + resp[pos++] = (ip_addr >> 8) & 0xFF; + resp[pos++] = (ip_addr) & 0xFF; + + return pos; +} + +static void wifi_connect_dns_task(void *arg) +{ + uint8_t rx_buf[256], tx_buf[512]; + struct sockaddr_in addr = {.sin_family = AF_INET, .sin_port = htons(53), .sin_addr.s_addr = htonl(INADDR_ANY)}; + + s_ctx.dns_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); + if (s_ctx.dns_sock < 0) + { + s_ctx.dns_running = false; + vTaskDelete(NULL); + return; + } + + if (bind(s_ctx.dns_sock, (struct sockaddr *)&addr, sizeof(addr)) != 0) + { + close(s_ctx.dns_sock); + s_ctx.dns_sock = -1; + s_ctx.dns_running = false; + vTaskDelete(NULL); + return; + } + + esp_netif_ip_info_t ip_info; + esp_netif_get_ip_info(s_ctx.ap_netif, &ip_info); + uint32_t ip = ntohl(ip_info.ip.addr); + + while (s_ctx.dns_running) + { + struct sockaddr_in from_addr; + socklen_t from_len = sizeof(from_addr); + struct timeval tv = {.tv_sec = 1, .tv_usec = 0}; + setsockopt(s_ctx.dns_sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + int len = recvfrom(s_ctx.dns_sock, rx_buf, sizeof(rx_buf), 0, (struct sockaddr *)&from_addr, &from_len); + if (len > 0) + { + size_t resp_len = wifi_connect_build_dns_response(rx_buf, (size_t)len, tx_buf, sizeof(tx_buf), ip); + if (resp_len > 0) + sendto(s_ctx.dns_sock, tx_buf, resp_len, 0, (struct sockaddr *)&from_addr, from_len); + } + } + close(s_ctx.dns_sock); + s_ctx.dns_sock = -1; + vTaskDelete(NULL); +} + +static esp_err_t wifi_connect_dns_start(void) +{ + if (s_ctx.dns_running) + return ESP_OK; + s_ctx.dns_running = true; + if (xTaskCreate(wifi_connect_dns_task, "wifi_dns", 3072, NULL, 4, &s_ctx.dns_task) != pdPASS) + { + s_ctx.dns_running = false; + return ESP_ERR_NO_MEM; + } + return ESP_OK; +} + +static void wifi_connect_dns_stop(void) +{ + if (!s_ctx.dns_running) + return; + s_ctx.dns_running = false; + if (s_ctx.dns_sock >= 0) + shutdown(s_ctx.dns_sock, 0); + s_ctx.dns_task = NULL; +} + +static void wifi_connect_connect_timeout_cb(void *arg) +{ + xSemaphoreTake(s_ctx.lock, portMAX_DELAY); + if (s_ctx.status == WIFI_CONNECT_STATUS_CONNECTING) + { + if (s_ctx.auto_connecting) + { + wifi_connect_set_status_locked(WIFI_CONNECT_STATUS_IDLE); + wifi_connect_set_error_locked(NULL); + s_ctx.auto_connecting = false; + wifi_connect_log_state_w("自动重连超时", "回到待机状态"); + } + else + { + wifi_connect_set_status_locked(WIFI_CONNECT_STATUS_FAILED); + wifi_connect_set_error_locked("连接超时"); + wifi_connect_log_state_w("连接路由器超时", "请确认密码和信号"); + } + s_ctx.sta_connect_requested = false; + esp_wifi_disconnect(); + } + xSemaphoreGive(s_ctx.lock); +} + +static void wifi_connect_ap_stop_timer_cb(void *arg) +{ + xSemaphoreTake(s_ctx.lock, portMAX_DELAY); + if (s_ctx.status == WIFI_CONNECT_STATUS_CONNECTED && s_ctx.provisioning_active) + { + wifi_connect_dns_stop(); + esp_wifi_set_mode(WIFI_MODE_STA); + s_ctx.provisioning_active = false; + wifi_connect_log_state_i("自动关闭热点", "已切换至纯 STA 模式,HTTP 服务继续运行在 STA IP 上"); + } + xSemaphoreGive(s_ctx.lock); +} + +static void wifi_connect_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data) +{ + if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) + { + ip_event_got_ip_t *got_ip = (ip_event_got_ip_t *)event_data; + xSemaphoreTake(s_ctx.lock, portMAX_DELAY); + bool should_save = (s_ctx.status == WIFI_CONNECT_STATUS_CONNECTING || s_ctx.status == WIFI_CONNECT_STATUS_PROVISIONING); + s_ctx.sta_connected = true; + s_ctx.sta_connect_requested = false; + s_ctx.auto_connecting = false; + wifi_connect_set_status_locked(WIFI_CONNECT_STATUS_CONNECTED); + wifi_connect_set_error_locked(NULL); + char ssid[33], password[65]; + snprintf(s_ctx.sta_ip, sizeof(s_ctx.sta_ip), IPSTR, IP2STR(&got_ip->ip_info.ip)); + snprintf(ssid, sizeof(ssid), "%s", s_ctx.pending_ssid); + snprintf(password, sizeof(password), "%s", s_ctx.pending_password); + xSemaphoreGive(s_ctx.lock); + + char success_msg[128] = {0}; + snprintf(success_msg, sizeof(success_msg), "已连接 %s,获取 IP=%s", ssid, s_ctx.sta_ip); + wifi_connect_log_state_i("联网成功", success_msg); + + esp_timer_stop(s_ctx.connect_timer); + if (should_save) + wifi_connect_save_credentials(ssid, password); + + // 启动定时器,5秒后关闭AP热点和DNS,仅保留STA和HTTP服务 + esp_timer_stop(s_ctx.ap_stop_timer); + esp_timer_start_once(s_ctx.ap_stop_timer, 5000000ULL); + wifi_connect_log_state_i("网络状态", "5秒后将自动关闭配网热点"); + return; + } + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) + { + wifi_event_sta_disconnected_t *dis = (wifi_event_sta_disconnected_t *)event_data; + xSemaphoreTake(s_ctx.lock, portMAX_DELAY); + bool connecting = (s_ctx.status == WIFI_CONNECT_STATUS_CONNECTING); + bool auto_connecting = s_ctx.auto_connecting; + s_ctx.sta_connected = false; + if (connecting) + { + if (auto_connecting) + { + wifi_connect_set_status_locked(WIFI_CONNECT_STATUS_IDLE); + wifi_connect_set_error_locked(NULL); + s_ctx.auto_connecting = false; + wifi_connect_log_state_w("自动重连中断", "连接丢失"); + } + else + { + wifi_connect_set_status_locked(WIFI_CONNECT_STATUS_FAILED); + snprintf(s_ctx.last_error, sizeof(s_ctx.last_error), "连接失败,原因=%d", dis->reason); + wifi_connect_log_state_w("连接路由器失败", s_ctx.last_error); + } + s_ctx.sta_connect_requested = false; + esp_timer_stop(s_ctx.connect_timer); + } + xSemaphoreGive(s_ctx.lock); + } +} + +static void wifi_connect_generate_ap_ssid(char *out, size_t out_len) +{ + uint8_t mac[6] = {0}; + esp_wifi_get_mac(WIFI_IF_STA, mac); + snprintf(out, out_len, "ESP32-%02X%02X%02X%02X%02X%02X-192.168.4.1", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); +} + +static esp_err_t wifi_connect_start_apsta_locked(void) +{ + wifi_config_t ap_cfg = {0}; + wifi_connect_generate_ap_ssid(s_ctx.ap_ssid, sizeof(s_ctx.ap_ssid)); + snprintf((char *)ap_cfg.ap.ssid, sizeof(ap_cfg.ap.ssid), "%s", s_ctx.ap_ssid); + ap_cfg.ap.ssid_len = strlen(s_ctx.ap_ssid); + ap_cfg.ap.channel = 1; + ap_cfg.ap.authmode = WIFI_AUTH_OPEN; + ap_cfg.ap.max_connection = CONFIG_WIFI_CONNECT_AP_MAX_CONNECTIONS; + ap_cfg.ap.pmf_cfg.required = false; + + ESP_RETURN_ON_ERROR(esp_wifi_set_mode(WIFI_MODE_APSTA), TAG, "set mode apsta failed"); + ESP_RETURN_ON_ERROR(esp_wifi_set_config(WIFI_IF_AP, &ap_cfg), TAG, "set ap config failed"); + + if (!s_ctx.wifi_started) + { + ESP_RETURN_ON_ERROR(esp_wifi_start(), TAG, "wifi start failed"); + s_ctx.wifi_started = true; + } + return ESP_OK; +} + +esp_err_t wifi_connect_start(void) +{ + ESP_RETURN_ON_FALSE(s_ctx.initialized, ESP_ERR_INVALID_STATE, TAG, "not initialized"); + + xSemaphoreTake(s_ctx.lock, portMAX_DELAY); + if (s_ctx.provisioning_active) + { + xSemaphoreGive(s_ctx.lock); + return ESP_OK; + } + if (wifi_connect_start_apsta_locked() != ESP_OK || wifi_connect_http_start() != ESP_OK || wifi_connect_dns_start() != ESP_OK) + { + wifi_connect_http_stop(); + xSemaphoreGive(s_ctx.lock); + return ESP_FAIL; + } + + s_ctx.provisioning_active = true; + s_ctx.sta_connect_requested = false; + s_ctx.auto_connecting = false; + wifi_connect_set_status_locked(WIFI_CONNECT_STATUS_PROVISIONING); + wifi_connect_set_error_locked(NULL); + xSemaphoreGive(s_ctx.lock); + + char ap_msg[96] = {0}; + snprintf(ap_msg, sizeof(ap_msg), "常驻配网热点已开启,SSID=%s,访问 http://192.168.4.1", s_ctx.ap_ssid); + wifi_connect_log_state_i("配网已启动", ap_msg); + return ESP_OK; +} + +esp_err_t wifi_connect_stop(void) +{ + if (!s_ctx.initialized) + { + return ESP_ERR_INVALID_STATE; + } + + xSemaphoreTake(s_ctx.lock, portMAX_DELAY); + s_ctx.provisioning_active = false; + s_ctx.sta_connect_requested = false; + s_ctx.auto_connecting = false; + + esp_timer_stop(s_ctx.connect_timer); + + wifi_connect_http_stop(); + wifi_connect_dns_stop(); + + if (s_ctx.sta_connected) + { + esp_wifi_set_mode(WIFI_MODE_STA); + wifi_connect_set_status_locked(WIFI_CONNECT_STATUS_CONNECTED); + } + else if (s_ctx.status != WIFI_CONNECT_STATUS_TIMEOUT) + { + wifi_connect_set_status_locked(WIFI_CONNECT_STATUS_IDLE); + } + + xSemaphoreGive(s_ctx.lock); + wifi_connect_log_state_i("配网已停止", "热点已关闭,设备继续以 STA 模式运行"); + return ESP_OK; +} + +static esp_err_t wifi_connect_try_auto_connect(void) +{ + wifi_connect_config_t config = {0}; + esp_err_t err = wifi_connect_get_config(&config); + if (err != ESP_OK || !config.has_config) + { + wifi_connect_log_state_i("未发现已保存的 Wi-Fi 配置", "设备保持待机"); + return ESP_OK; + } + + xSemaphoreTake(s_ctx.lock, portMAX_DELAY); + snprintf(s_ctx.pending_ssid, sizeof(s_ctx.pending_ssid), "%s", config.ssid); + snprintf(s_ctx.pending_password, sizeof(s_ctx.pending_password), "%s", config.password); + wifi_connect_set_status_locked(WIFI_CONNECT_STATUS_CONNECTING); + wifi_connect_set_error_locked(NULL); + s_ctx.sta_connect_requested = true; + s_ctx.auto_connecting = true; + xSemaphoreGive(s_ctx.lock); + + err = wifi_connect_apply_sta_credentials(config.ssid, config.password); + if (err != ESP_OK) + { + xSemaphoreTake(s_ctx.lock, portMAX_DELAY); + s_ctx.sta_connect_requested = false; + s_ctx.auto_connecting = false; + wifi_connect_set_status_locked(WIFI_CONNECT_STATUS_IDLE); + wifi_connect_set_error_locked(NULL); + xSemaphoreGive(s_ctx.lock); + return err; + } + + esp_timer_stop(s_ctx.connect_timer); + esp_timer_start_once(s_ctx.connect_timer, (uint64_t)CONFIG_WIFI_CONNECT_CONNECT_TIMEOUT_SEC * 1000000ULL); + char msg[96] = {0}; + snprintf(msg, sizeof(msg), "尝试连接已保存网络:%s", config.ssid); + wifi_connect_log_state_i("自动重连中", msg); + return ESP_OK; +} + +esp_err_t wifi_connect_init(void) +{ + if (s_ctx.initialized) + return ESP_OK; + + esp_err_t err = nvs_flash_init(); + if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) + { + ESP_ERROR_CHECK(nvs_flash_erase()); + err = nvs_flash_init(); + } + ESP_RETURN_ON_ERROR(err, TAG, "nvs init failed"); + + ESP_RETURN_ON_ERROR(esp_netif_init(), TAG, "netif init failed"); + err = esp_event_loop_create_default(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) + ESP_RETURN_ON_ERROR(err, TAG, "event loop create failed"); + + wifi_init_config_t wifi_init_cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_RETURN_ON_ERROR(esp_wifi_init(&wifi_init_cfg), TAG, "wifi init failed"); + ESP_RETURN_ON_ERROR(esp_wifi_set_storage(WIFI_STORAGE_RAM), TAG, "wifi storage set failed"); + + s_ctx.sta_netif = esp_netif_create_default_wifi_sta(); + s_ctx.ap_netif = esp_netif_create_default_wifi_ap(); + + ESP_RETURN_ON_ERROR(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, + &wifi_connect_event_handler, NULL, &s_ctx.wifi_event_instance), + TAG, "register wifi handler failed"); + ESP_RETURN_ON_ERROR(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, + &wifi_connect_event_handler, NULL, &s_ctx.ip_event_instance), + TAG, "register ip handler failed"); + + ESP_RETURN_ON_ERROR(esp_wifi_set_mode(WIFI_MODE_STA), TAG, "set mode sta failed"); + ESP_RETURN_ON_ERROR(esp_wifi_start(), TAG, "wifi start failed"); + s_ctx.wifi_started = true; + + s_ctx.lock = xSemaphoreCreateMutex(); + ESP_RETURN_ON_FALSE(s_ctx.lock != NULL, ESP_ERR_NO_MEM, TAG, "create lock failed"); + + esp_timer_create_args_t connect_timer_args = { + .callback = wifi_connect_connect_timeout_cb, + .name = "wifi_conn_to", + }; + ESP_RETURN_ON_ERROR(esp_timer_create(&connect_timer_args, &s_ctx.connect_timer), TAG, "connect timer create failed"); + + esp_timer_create_args_t ap_stop_timer_args = { + .callback = wifi_connect_ap_stop_timer_cb, + .name = "wifi_ap_stop", + }; + ESP_RETURN_ON_ERROR(esp_timer_create(&ap_stop_timer_args, &s_ctx.ap_stop_timer), TAG, "ap stop timer create failed"); + + s_ctx.initialized = true; + + wifi_connect_log_state_i("配网模式", "常驻配网(上电自动开启且不会自动关闭)"); + wifi_connect_try_auto_connect(); + + return wifi_connect_start(); +} diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index cf2c455..f897d4a 100755 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,2 +1,3 @@ idf_component_register(SRCS "main.c" - INCLUDE_DIRS ".") + INCLUDE_DIRS "." + REQUIRES nvs_flash esp_wifi esp_event esp_system wifi-connect) diff --git a/main/main.c b/main/main.c index 7b66f33..58a7d43 100755 --- a/main/main.c +++ b/main/main.c @@ -1,6 +1,25 @@ #include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "wifi-connect.h" + +// 等待 Wi-Fi 连接成功,超时后返回当前连接状态 +static bool wait_for_wifi_connected(TickType_t timeout_ticks) +{ + const TickType_t start_ticks = xTaskGetTickCount(); + while ((xTaskGetTickCount() - start_ticks) < timeout_ticks) + { + if (wifi_connect_get_status() == WIFI_CONNECT_STATUS_CONNECTED) + { + return true; + } + vTaskDelay(pdMS_TO_TICKS(200)); + } + return wifi_connect_get_status() == WIFI_CONNECT_STATUS_CONNECTED; +} void app_main(void) { - + ESP_ERROR_CHECK(wifi_connect_init()); // 初始化 Wi-Fi 配网模块 }