From 8548f04733ff53320cde1997df7134f30cc15d15 Mon Sep 17 00:00:00 2001 From: Wang Beihong Date: Sun, 19 Apr 2026 19:30:52 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E4=BA=86WIFI=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=EF=BC=8CMQTT=E8=AE=A2=E9=98=85=E5=8F=91=E5=B8=83?= =?UTF-8?q?=EF=BC=8C=E5=85=89=E7=85=A7=E7=9B=91=E6=B5=8B=EF=BC=8C=E6=B8=A9?= =?UTF-8?q?=E6=B9=BF=E5=BA=A6=E6=95=B0=E6=8D=AE=EF=BC=8C=E7=BB=A7=E7=94=B5?= =?UTF-8?q?=E5=99=A8=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .clangd | 2 + .devcontainer/Dockerfile | 13 + .devcontainer/devcontainer.json | 19 + .gitignore | 78 ++ .vscode/c_cpp_properties.json | 19 + .vscode/launch.json | 10 + .vscode/settings.json | 18 + CMakeLists.txt | 6 + README.md | 211 ++++ components/agri_env/CMakeLists.txt | 5 + components/agri_env/Kconfig.projbuild | 26 + components/agri_env/agri_env.c | 253 ++++ components/agri_env/include/agri_env.h | 23 + 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 | 55 + components/relay_ctrl/relay_ctrl.c | 102 ++ 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 +++++++++++++++++ dependencies.lock | 73 ++ main/CMakeLists.txt | 3 + main/Kconfig.projbuild | 31 + main/idf_component.yml | 20 + main/main.c | 146 +++ 32 files changed, 2831 insertions(+) create mode 100644 .clangd create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 .gitignore create mode 100644 .vscode/c_cpp_properties.json create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100755 CMakeLists.txt create mode 100644 README.md create mode 100644 components/agri_env/CMakeLists.txt create mode 100644 components/agri_env/Kconfig.projbuild create mode 100644 components/agri_env/agri_env.c create mode 100644 components/agri_env/include/agri_env.h 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/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 create mode 100644 dependencies.lock create mode 100755 main/CMakeLists.txt create mode 100644 main/Kconfig.projbuild create mode 100644 main/idf_component.yml create mode 100755 main/main.c diff --git a/.clangd b/.clangd new file mode 100644 index 0000000..437f255 --- /dev/null +++ b/.clangd @@ -0,0 +1,2 @@ +CompileFlags: + Remove: [-f*, -m*] diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..dafb8ad --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,13 @@ +ARG DOCKER_TAG=latest +FROM espressif/idf:${DOCKER_TAG} + +ENV LC_ALL=C.UTF-8 +ENV LANG=C.UTF-8 + +RUN apt-get update -y && apt-get install udev -y + +RUN echo "source /opt/esp/idf/export.sh > /dev/null 2>&1" >> ~/.bashrc + +ENTRYPOINT [ "/opt/esp/entrypoint.sh" ] + +CMD ["/bin/bash", "-c"] \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..246b79f --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,19 @@ +{ + "name": "ESP-IDF QEMU", + "build": { + "dockerfile": "Dockerfile" + }, + "customizations": { + "vscode": { + "settings": { + "terminal.integrated.defaultProfile.linux": "bash", + "idf.gitPath": "/usr/bin/git" + }, + "extensions": [ + "espressif.esp-idf-extension", + "espressif.esp-idf-web" + ] + } + }, + "runArgs": ["--privileged"] +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7805557 --- /dev/null +++ b/.gitignore @@ -0,0 +1,78 @@ +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Directory metadata +.directory + +# Temporary files +*~ +*.swp +*.swo +*.bak +*.tmp + +# Log files +*.log + +# Build artifacts and directories +**/build/ +build/ +*.o +*.a +*.out +*.exe # For any host-side utilities compiled on Windows + +# ESP-IDF specific build outputs +*.bin +*.elf +*.map +flasher_args.json # Generated in build directory +sdkconfig.old +sdkconfig + +# ESP-IDF dependencies +# For older versions or manual component management +/components/.idf/ +**/components/.idf/ +# For modern ESP-IDF component manager +managed_components/ +# If ESP-IDF tools are installed/referenced locally to the project +.espressif/ + +# CMake generated files +CMakeCache.txt +CMakeFiles/ +cmake_install.cmake +install_manifest.txt +CTestTestfile.cmake + +# Python environment files +*.pyc +*.pyo +*.pyd +__pycache__/ +*.egg-info/ +dist/ + +# Virtual environment folders +venv/ +.venv/ +env/ + +# Language Servers +.clangd/ +.ccls-cache/ +compile_commands.json + +# Windows specific +Thumbs.db +ehthumbs.db +Desktop.ini + +# User-specific configuration files +*.user +*.workspace # General workspace files, can be from various tools +*.suo # Visual Studio Solution User Options +*.sln.docstates # Visual Studio diff --git a/.vscode/c_cpp_properties.json b/.vscode/c_cpp_properties.json new file mode 100644 index 0000000..cf1970a --- /dev/null +++ b/.vscode/c_cpp_properties.json @@ -0,0 +1,19 @@ +{ + "configurations": [ + { + "name": "ESP-IDF", + "compilerPath": "/home/beihong/.espressif/tools/riscv32-esp-elf/esp-14.2.0_20251107/riscv32-esp-elf/bin/riscv32-esp-elf-gcc", + "compileCommands": "${config:idf.buildPath}/compile_commands.json", + "includePath": [ + "${workspaceFolder}/**" + ], + "browse": { + "path": [ + "${workspaceFolder}" + ], + "limitSymbolsToIncludedHeaders": true + } + } + ], + "version": 4 +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..3694ae4 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,10 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "gdbtarget", + "request": "attach", + "name": "Eclipse CDT GDB Adapter" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ff33bc2 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,18 @@ +{ + "C_Cpp.intelliSenseEngine": "default", + "idf.openOcdConfigs": [ + "board/esp32c3-builtin.cfg" + ], + "idf.port": "/dev/ttyACM0", + "idf.currentSetup": "/home/beihong/esp/v5.5.2/esp-idf", + "idf.customExtraVars": { + "IDF_TARGET": "esp32c3" + }, + "clangd.path": "/home/beihong/.espressif/tools/esp-clang/esp-19.1.2_20250312/esp-clang/bin/clangd", + "clangd.arguments": [ + "--background-index", + "--query-driver=**", + "--compile-commands-dir=/home/beihong/esp_projects/AgriEnv_Monitor/build" + ], + "idf.flashType": "UART" +} diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100755 index 0000000..4a1a11b --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,6 @@ +# The following five lines of boilerplate have to be in your project's +# CMakeLists in this exact order for cmake to work correctly +cmake_minimum_required(VERSION 3.16) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(AgriEnv_Monitor) diff --git a/README.md b/README.md new file mode 100644 index 0000000..c344dc0 --- /dev/null +++ b/README.md @@ -0,0 +1,211 @@ +# AgriEnv_Monitor + +本项目基于 ESP-IDF,新增了一个统一环境采集与控制组件 `agri_env`,实现以下功能: + +1. DHT11 温湿度采集(DATA -> GPIO10) +2. BH1750 光照采集(I2C 地址 0x23,SDA -> GPIO4,SCL -> GPIO5) +3. MQ135 数据采集(AO -> GPIO1) +4. 双路继电器控制 +5. 统一数据结构与获取接口 +6. MQTT 连接(MQTT 3.1 + 用户名密码) +7. MQTT 订阅主题与发布主题 +8. 定时任务采样、打印和上报 +9. cJSON 数据包构建用于上报 + +## 目录说明 + +新增组件位于: + +- `components/agri_env/include/agri_env.h` +- `components/agri_env/agri_env.c` +- `components/agri_env/CMakeLists.txt` + +`main/main.c` 已接入初始化与示例读取逻辑。 + +## 硬件接线 + +### 1. DHT11 + +- VCC -> 3.3V +- GND -> GND +- DATA -> GPIO10 +- 推荐 DATA 加 4.7k~10k 上拉电阻到 3.3V + +### 2. BH1750 + +- VCC -> 3.3V +- GND -> GND +- SDA -> GPIO4 +- SCL -> GPIO5 +- 地址:0x23(ADDR 引脚悬空或接地) + +### 3. MQ135 + +- DO(数字输出)-> GPIO10 + +注意:当前工程使用 MQ135 的 DO 数字输出,不做 ADC 采样。 + +- 组件直接读取 `mq135_digital_level`(0/1) + +如需模拟量采集,需要单独引入 AO 接线和 ADC 逻辑(当前版本未启用)。 + +### 4. 两路继电器 + +- 风扇继电器 IN -> GPIO6(高电平有效) +- 灯光继电器 IN -> GPIO7(高电平有效) + +## 软件实现概览 + +### 1. 统一数据结构 + +定义在 `agri_env.h`: + +- `temperature_c` +- `humidity_percent` +- `illuminance_lux` +- `mq135_digital_level` +- `timestamp_ms` + +### 2. 传感器读取接口 + +- `agri_env_init()`:初始化 I2C、BH1750、MQ135、继电器 +- `agri_env_read_sensors(agri_env_data_t *out_data)`:一次读取并刷新缓存 +- `agri_env_get_latest_data(agri_env_data_t *out_data)`:读取最近缓存 + +### 3. 继电器控制接口 + +- `agri_env_set_fan_relay(bool on)` +- `agri_env_set_light_relay(bool on)` + +### 4. MQTT 接口(MQTT 3.1 + 用户名密码) + +- `agri_env_mqtt_start()` +- `agri_env_mqtt_stop()` +- `agri_env_mqtt_is_connected()` +- `agri_env_mqtt_publish_latest(const char *topic, int qos, int retain)` + +组件会在 MQTT 连接成功后自动订阅 `CONFIG_AGRI_ENV_MQTT_SUBSCRIBE_TOPIC`。 + +默认订阅主题与发布主题来自 menuconfig: + +- `AGRI_ENV_MQTT_SUBSCRIBE_TOPIC` +- `AGRI_ENV_MQTT_PUBLISH_TOPIC` + +订阅到的 JSON 命令支持如下字段: + +- `fan_relay`: `true` / `false` +- `light_relay`: `true` / `false` + +### 5. cJSON 数据打包 + +- `char *agri_env_build_data_json(const agri_env_data_t *data)` + +说明:返回值是动态分配字符串,使用完后需要 `free()`。 + +## menuconfig 配置方法 + +在 `idf.py menuconfig` 中打开 `AgriEnv Monitor` 菜单,配置以下项: + +- `AGRI_ENV_SAMPLE_PERIOD_MS`:采样周期 +- `AGRI_ENV_MQTT_BROKER_URI`:MQTT 服务器地址 +- `AGRI_ENV_MQTT_USERNAME`:MQTT 用户名 +- `AGRI_ENV_MQTT_PASSWORD`:MQTT 密码 +- `AGRI_ENV_MQTT_CLIENT_ID`:客户端 ID +- `AGRI_ENV_MQTT_PUBLISH_TOPIC`:发布主题 +- `AGRI_ENV_MQTT_SUBSCRIBE_TOPIC`:订阅主题 + +示例: + +```text +AGRI_ENV_SAMPLE_PERIOD_MS=5000 +AGRI_ENV_MQTT_BROKER_URI=mqtt://192.168.1.100:1883 +AGRI_ENV_MQTT_USERNAME=your_user +AGRI_ENV_MQTT_PASSWORD=your_pass +AGRI_ENV_MQTT_CLIENT_ID=agri-env-monitor +AGRI_ENV_MQTT_PUBLISH_TOPIC=agri/env/data +AGRI_ENV_MQTT_SUBSCRIBE_TOPIC=agri/env/cmd +``` + +协议版本固定为 `MQTT_PROTOCOL_V_3_1`。 + +## 在 main 中的使用示例 + +当前 `main/main.c` 已包含如下流程: + +1. 初始化 Wi-Fi 配网:`wifi_connect_init()` +2. 初始化农业环境组件:`agri_env_init()` +3. 组件内部周期任务自动读取传感器数据并打印 +4. 若 MQTT 参数已填写,则自动连接并按主题上报 + +你也可以继续在业务层调用 `agri_env_read_sensors()` 和 `agri_env_mqtt_publish_latest()` 做自定义上报。 + +## 构建与烧录步骤(详细) + +### 1. 进入工程 + +```bash +cd /home/beihong/esp_projects/AgriEnv_Monitor +``` + +### 2. 配置目标芯片(如果尚未设置) + +```bash +idf.py set-target esp32c3 +``` + +### 3. menuconfig(可选) + +```bash +idf.py menuconfig +``` + +按需配置串口、日志等级、Wi-Fi 配网组件参数。 + +### 4. 编译 + +```bash +idf.py build +``` + +### 5. 烧录 + +```bash +idf.py -p /dev/ttyUSB0 flash +``` + +### 6. 查看日志 + +```bash +idf.py -p /dev/ttyUSB0 monitor +``` + +## 常见问题 + +### 1. DHT11 读取失败 + +- 检查 DATA 是否正确接 GPIO10 +- 确保上拉电阻存在 +- 线长过长时需降低干扰 + +### 2. BH1750 无数据 + +- 确认地址是 0x23 +- 检查 SDA/SCL 是否与 GPIO4/GPIO5 对应 +- 检查模块供电是否 3.3V + +### 3. MQ135 只有数字量 + +- 当前版本设计即为数字量模式,只输出 `mq135_digital_level` +- 若 DO 一直不变,检查 MQ135 模块阈值电位器和 DO 接线 + +### 4. MQTT 无法连接 + +- 确认 `MQTT_BROKER_URI`/用户名/密码已填写 +- 确认设备已联网 +- 检查 broker 是否允许 MQTT 3.1 + +## 后续建议 + +1. 增加 FreeRTOS 周期任务,定时调用 `agri_env_read_sensors()` 与 `agri_env_mqtt_publish_latest()`。 +2. 将 MQTT 参数改为 Kconfig 配置,避免每次改源码。 +3. 对 MQ135 增加校准模型(洁净空气基线和 ppm 拟合)。 diff --git a/components/agri_env/CMakeLists.txt b/components/agri_env/CMakeLists.txt new file mode 100644 index 0000000..620c461 --- /dev/null +++ b/components/agri_env/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "agri_env.c" + INCLUDE_DIRS "include" + REQUIRES driver mqtt cjson esp_timer esp_event +) diff --git a/components/agri_env/Kconfig.projbuild b/components/agri_env/Kconfig.projbuild new file mode 100644 index 0000000..5020c71 --- /dev/null +++ b/components/agri_env/Kconfig.projbuild @@ -0,0 +1,26 @@ +menu "MQTT 配置参数" +config AGRI_ENV_MQTT_BROKER_URI + string "MQTT 服务器地址" + default "" + +config AGRI_ENV_MQTT_USERNAME + string "MQTT 用户名" + default "" + +config AGRI_ENV_MQTT_PASSWORD + string "MQTT 密码" + default "" + +config AGRI_ENV_MQTT_CLIENT_ID + string "MQTT Client ID" + default "agri-env-monitor" + +config AGRI_ENV_MQTT_PUBLISH_TOPIC + string "MQTT 发布主题" + default "agri/env/data" + +config AGRI_ENV_MQTT_SUBSCRIBE_TOPIC + string "MQTT 订阅主题" + default "agri/env/cmd" + +endmenu diff --git a/components/agri_env/agri_env.c b/components/agri_env/agri_env.c new file mode 100644 index 0000000..5be135b --- /dev/null +++ b/components/agri_env/agri_env.c @@ -0,0 +1,253 @@ +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +#include "esp_check.h" +#include "esp_err.h" +#include "esp_event.h" +#include "esp_log.h" +#include "mqtt_client.h" + +#include "agri_env.h" + +static const char *TAG = "agri_env"; + +/** + * @brief 农业环境模块上下文结构体 + * 包含 MQTT 客户端句柄、连接状态以及用于线程安全的互斥锁 + */ +typedef struct { + SemaphoreHandle_t lock; /*!< 互斥锁,保护共享资源 */ + + esp_mqtt_client_handle_t mqtt_client; /*!< ESP MQTT 客户端句柄 */ + bool mqtt_connected; /*!< MQTT 连接状态标识 */ +} agri_env_ctx_t; + +static agri_env_ctx_t s_ctx; + +/** + * @brief 规范化 MQTT 代理 URI + * + * 将 menuconfig 中的原始字符串处理为标准形式(例如加上 mqtt:// 前缀或默认端口 1883) + * + * @param buffer 存储结果的缓冲区 + * @param buffer_size 缓冲区大小 + * @param was_prefixed [out] 如果进行了修整或添加前缀,则设为 true + * @return const char* 返回规范化后的字符串指针,失败返回 NULL + */ +static const char *agri_env_get_normalized_mqtt_uri(char *buffer, size_t buffer_size, bool *was_prefixed) +{ + const char *uri = CONFIG_AGRI_ENV_MQTT_BROKER_URI; + if (was_prefixed != NULL) { + *was_prefixed = false; + } + + if (uri == NULL || uri[0] == '\0') { + return NULL; + } + + // 去除前导空白字符 + while (*uri == ' ' || *uri == '\t' || *uri == '\r' || *uri == '\n') { + ++uri; + } + + size_t uri_len = strlen(uri); + // 去除尾部空白字符 + while (uri_len > 0 && (uri[uri_len - 1] == ' ' || uri[uri_len - 1] == '\t' || uri[uri_len - 1] == '\r' || uri[uri_len - 1] == '\n')) { + --uri_len; + } + + if (uri_len == 0) { + return NULL; + } + + // 如果已经包含协议前缀 (如 mqtt://), 直接复制并返回 + if (strstr(uri, "://") != NULL) { + if (uri_len + 1 >= buffer_size) { + return NULL; + } + memcpy(buffer, uri, uri_len); + buffer[uri_len] = '\0'; + return buffer; + } + + // 自动补全协议前缀和默认端口 + const bool has_port = memchr(uri, ':', uri_len) != NULL; + int written; + if (has_port) { + written = snprintf(buffer, buffer_size, "mqtt://%.*s", (int)uri_len, uri); + } else { + written = snprintf(buffer, buffer_size, "mqtt://%.*s:1883", (int)uri_len, uri); + } + + if (written < 0 || written >= (int)buffer_size) { + return NULL; + } + + if (was_prefixed != NULL) { + *was_prefixed = true; + } + + return buffer; +} + +/** + * @brief MQTT 事件处理回调函数 + * + * 处理连接、断开、接收数据等事件 + */ +static void agri_env_mqtt_event_handler(void *handler_args, esp_event_base_t base, int32_t event_id, void *event_data) +{ + (void)handler_args; + (void)base; + + esp_mqtt_event_handle_t event = (esp_mqtt_event_handle_t)event_data; + if (event == NULL) { + return; + } + + if (event_id == MQTT_EVENT_CONNECTED) { + xSemaphoreTake(s_ctx.lock, portMAX_DELAY); + s_ctx.mqtt_connected = true; + xSemaphoreGive(s_ctx.lock); + ESP_LOGI(TAG, "MQTT 已连接"); + + // 连接成功后订阅配置的主题 + if (strlen(CONFIG_AGRI_ENV_MQTT_SUBSCRIBE_TOPIC) > 0) { + esp_mqtt_client_subscribe(s_ctx.mqtt_client, CONFIG_AGRI_ENV_MQTT_SUBSCRIBE_TOPIC, 0); + ESP_LOGI(TAG, "已订阅主题: %s", CONFIG_AGRI_ENV_MQTT_SUBSCRIBE_TOPIC); + } + } else if (event_id == MQTT_EVENT_DISCONNECTED) { + xSemaphoreTake(s_ctx.lock, portMAX_DELAY); + s_ctx.mqtt_connected = false; + xSemaphoreGive(s_ctx.lock); + ESP_LOGW(TAG, "MQTT 已断开连接"); + } else if (event_id == MQTT_EVENT_DATA) { + ESP_LOGI(TAG, "收到 MQTT 消息: topic=%.*s payload=%.*s", + event->topic_len, + event->topic, + event->data_len, + event->data); + } +} + +/** + * @brief 启动 MQTT 客户端 + */ +esp_err_t agri_env_mqtt_start(void) +{ + if (s_ctx.lock == NULL) { + s_ctx.lock = xSemaphoreCreateMutex(); + ESP_RETURN_ON_FALSE(s_ctx.lock != NULL, ESP_ERR_NO_MEM, TAG, "互斥锁创建失败"); + } + + if (s_ctx.mqtt_client != NULL) { + return ESP_OK; + } + + char mqtt_uri[256]; + bool mqtt_uri_prefixed = false; + const char *normalized_uri = agri_env_get_normalized_mqtt_uri(mqtt_uri, sizeof(mqtt_uri), &mqtt_uri_prefixed); + + if (normalized_uri == NULL) { + ESP_LOGW(TAG, "MQTT Broker URI 为空,请在 menuconfig 中填写"); + return ESP_ERR_INVALID_STATE; + } + + if (mqtt_uri_prefixed) { + ESP_LOGW(TAG, "MQTT Broker URI 已规范化为: %s", normalized_uri); + } + + // MQTT 客户端配置 + const esp_mqtt_client_config_t mqtt_cfg = { + .broker.address.uri = normalized_uri, + .credentials.client_id = CONFIG_AGRI_ENV_MQTT_CLIENT_ID, + .credentials.username = CONFIG_AGRI_ENV_MQTT_USERNAME, + .credentials.authentication.password = CONFIG_AGRI_ENV_MQTT_PASSWORD, + .session.protocol_ver = MQTT_PROTOCOL_V_3_1, // 使用 MQTT v3.1 协议 + }; + + s_ctx.mqtt_client = esp_mqtt_client_init(&mqtt_cfg); + ESP_RETURN_ON_FALSE(s_ctx.mqtt_client != NULL, ESP_FAIL, TAG, "MQTT 客户端初始化失败"); + + // 注册所有 MQTT 事件的回调 + ESP_RETURN_ON_ERROR( + esp_mqtt_client_register_event(s_ctx.mqtt_client, MQTT_EVENT_ANY, agri_env_mqtt_event_handler, NULL), + TAG, + "MQTT 注册事件失败"); + + // 启动 MQTT 客户端任务 + ESP_RETURN_ON_ERROR(esp_mqtt_client_start(s_ctx.mqtt_client), TAG, "MQTT 启动失败"); + return ESP_OK; +} + +/** + * @brief 停止并销毁 MQTT 客户端 + */ +esp_err_t agri_env_mqtt_stop(void) +{ + if (s_ctx.mqtt_client == NULL) { + return ESP_OK; + } + + esp_err_t err = esp_mqtt_client_stop(s_ctx.mqtt_client); + if (err != ESP_OK) { + return err; + } + + err = esp_mqtt_client_destroy(s_ctx.mqtt_client); + if (err != ESP_OK) { + return err; + } + + xSemaphoreTake(s_ctx.lock, portMAX_DELAY); + s_ctx.mqtt_client = NULL; + s_ctx.mqtt_connected = false; + xSemaphoreGive(s_ctx.lock); + + return ESP_OK; +} + +/** + * @brief 检查 MQTT 是否已成功连接 + * + * @return true 已连接, false 未连接 + */ +bool agri_env_mqtt_is_connected(void) +{ + bool connected = false; + if (s_ctx.lock == NULL) { + return false; + } + + xSemaphoreTake(s_ctx.lock, portMAX_DELAY); + connected = s_ctx.mqtt_connected; + xSemaphoreGive(s_ctx.lock); + + return connected; +} + +/** + * @brief 发布数据到指定主题 + * + * 当前处于“仅 MQTT”模式,发布内容为固定的 JSON 数据:{"mode":"mqtt_only"} + * + * @param topic 目标主题 + * @param qos 服务质量等级 + * @param retain 保留消息标识 + * @return esp_err_t 成功返回 ESP_OK,失败返回相应错误码 + */ +esp_err_t agri_env_mqtt_publish_latest(const char *topic, int qos, int retain) +{ + ESP_RETURN_ON_FALSE(topic != NULL && topic[0] != '\0', ESP_ERR_INVALID_ARG, TAG, "主题为空"); + ESP_RETURN_ON_FALSE(s_ctx.mqtt_client != NULL, ESP_ERR_INVALID_STATE, TAG, "MQTT 客户端未启动"); + ESP_RETURN_ON_FALSE(agri_env_mqtt_is_connected(), ESP_ERR_INVALID_STATE, TAG, "MQTT 未连接"); + + static const char *payload = "{\"mode\":\"mqtt_only\"}"; + int msg_id = esp_mqtt_client_publish(s_ctx.mqtt_client, topic, payload, 0, qos, retain); + + ESP_RETURN_ON_FALSE(msg_id >= 0, ESP_FAIL, TAG, "MQTT 发布失败"); + return ESP_OK; +} diff --git a/components/agri_env/include/agri_env.h b/components/agri_env/include/agri_env.h new file mode 100644 index 0000000..7fbc90a --- /dev/null +++ b/components/agri_env/include/agri_env.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* 启动 MQTT 客户端连接。 */ +esp_err_t agri_env_mqtt_start(void); +/* 停止 MQTT 客户端连接并释放资源。 */ +esp_err_t agri_env_mqtt_stop(void); +/* 查询 MQTT 当前是否已连接。 */ +bool agri_env_mqtt_is_connected(void); +/* 发布固定 MQTT-only 心跳载荷到指定主题。 */ +esp_err_t agri_env_mqtt_publish_latest(const char *topic, int qos, int retain); + +#ifdef __cplusplus +} +#endif 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..0276918 --- /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 5 // ESP32-C3 建议引脚 +#define BH1750_I2C_SDA_IO 4 // ESP32-C3 建议引脚 + +/** + * @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..ed145ef --- /dev/null +++ b/components/relay_ctrl/include/relay_ctrl.h @@ -0,0 +1,55 @@ +#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_MAX, +} relay_ctrl_id_t; + +/** + * @brief 初始化双继电器控制模块。 + * + * @param relay1_gpio 继电器1控制引脚 + * @param relay2_gpio 继电器2控制引脚 + * @param active_high 继电器有效电平,true=高电平吸合,false=低电平吸合 + */ +esp_err_t relay_ctrl_init(gpio_num_t relay1_gpio, gpio_num_t relay2_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); + +#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..03b3d3a --- /dev/null +++ b/components/relay_ctrl/relay_ctrl.c @@ -0,0 +1,102 @@ +#include "relay_ctrl.h" + +#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, 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"); + + s_ctx.active_high = active_high; + s_ctx.pins[RELAY_CTRL_ID_1] = relay1_gpio; + s_ctx.pins[RELAY_CTRL_ID_2] = relay2_gpio; + + const gpio_config_t io_cfg = { + .pin_bit_mask = (1ULL << relay1_gpio) | (1ULL << relay2_gpio), + .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"); + + // 默认上电全部断开 + s_ctx.states[RELAY_CTRL_ID_1] = false; + s_ctx.states[RELAY_CTRL_ID_2] = false; + ESP_RETURN_ON_ERROR(gpio_set_level(relay1_gpio, relay_level_from_state(false)), TAG, "relay1 set init level failed"); + ESP_RETURN_ON_ERROR(gpio_set_level(relay2_gpio, relay_level_from_state(false)), TAG, "relay2 set init level failed"); + + s_ctx.inited = true; + ESP_LOGI(TAG, "继电器初始化完成: relay1=GPIO%d relay2=GPIO%d active_high=%d", + relay1_gpio, + relay2_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) +{ + 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"); + return ESP_OK; +} 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/dependencies.lock b/dependencies.lock new file mode 100644 index 0000000..1fe2b9d --- /dev/null +++ b/dependencies.lock @@ -0,0 +1,73 @@ +dependencies: + esp-idf-lib/dht: + component_hash: a0a1b26519566809041bc8fccf754e3ee9b25f73b0decbdc51bfd16143dedd66 + dependencies: + - name: esp-idf-lib/esp_idf_lib_helpers + registry_url: https://components.espressif.com + require: private + version: '*' + source: + registry_url: https://components.espressif.com/ + type: service + targets: + - esp32 + - esp32c2 + - esp32c3 + - esp32c5 + - esp32c6 + - esp32c61 + - esp32h2 + - esp32p4 + - esp32s2 + - esp32s3 + version: 1.2.0 + esp-idf-lib/esp_idf_lib_helpers: + component_hash: 689853bb8993434f9556af0f2816e808bf77b5d22100144b21f3519993daf237 + dependencies: [] + source: + registry_url: https://components.espressif.com + type: service + targets: + - esp32 + - esp32c2 + - esp32c3 + - esp32c5 + - esp32c6 + - esp32c61 + - esp32h2 + - esp32p4 + - esp32s2 + - esp32s3 + version: 1.4.0 + espressif/cjson: + component_hash: e788323270d90738662d66fffa910bfe1fba019bba087f01557e70c40485b469 + dependencies: + - name: idf + require: private + version: '>=5.0' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.7.19~2 + espressif/mqtt: + component_hash: ffdad5659706b4dc14bc63f8eb73ef765efa015bf7e9adf71c813d52a2dc9342 + dependencies: + - name: idf + require: private + version: '>=5.3' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.0.0 + idf: + source: + type: idf + version: 5.5.2 +direct_dependencies: +- esp-idf-lib/dht +- espressif/cjson +- espressif/mqtt +- idf +manifest_hash: 5e6fbc0e2a31ade2b187deb633c2459efe2f2401bfa17c732a0aa99c7846a05e +target: esp32c3 +version: 2.0.0 diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt new file mode 100755 index 0000000..6790b71 --- /dev/null +++ b/main/CMakeLists.txt @@ -0,0 +1,3 @@ +idf_component_register(SRCS "main.c" + INCLUDE_DIRS "." + REQUIRES nvs_flash esp_wifi esp_event esp_system wifi-connect agri_env bh1750 dht relay_ctrl ) diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild new file mode 100644 index 0000000..ab82977 --- /dev/null +++ b/main/Kconfig.projbuild @@ -0,0 +1,31 @@ +menu "Example configuration" + choice EXAMPLE_CHIP_TYPE + prompt "Select chip type" + default EXAMPLE_TYPE_AM2301 + + config EXAMPLE_TYPE_DHT11 + bool "DHT11" + config EXAMPLE_TYPE_AM2301 + bool "DHT21/DHT22/AM2301/AM2302/AM2321" + config EXAMPLE_TYPE_SI7021 + bool "Itead Si7021" + endchoice + + config EXAMPLE_DATA_GPIO + int "Data GPIO number" + default 4 if IDF_TARGET_ESP8266 + default 4 if IDF_TARGET_ESP32C2 || IDF_TARGET_ESP32C3 || IDF_TARGET_ESP32C5 || IDF_TARGET_ESP32C6 || IDF_TARGET_ESP32C61 + default 4 if IDF_TARGET_ESP32H2 + default 4 if IDF_TARGET_ESP32P4 + default 17 if IDF_TARGET_ESP32 || IDF_TARGET_ESP32S2 || IDF_TARGET_ESP32S3 + help + GPIO number connected to DATA pin + + config EXAMPLE_INTERNAL_PULLUP + bool "Enable internal pull-up resistor" + default 0 + help + Check this option if you don't have external pull-up resistor on data GPIO. + DHT sensors that come mounted on a PCB generally have pull-up resistors on the data pin. + But for stable operation, it is recommended to provide an external pull-up resistor. +endmenu diff --git a/main/idf_component.yml b/main/idf_component.yml new file mode 100644 index 0000000..8844fec --- /dev/null +++ b/main/idf_component.yml @@ -0,0 +1,20 @@ +## IDF Component Manager Manifest File +dependencies: + ## Required IDF version + idf: + version: '>=4.1.0' + # # Put list of dependencies here + # # For components maintained by Espressif: + # component: "~1.0.0" + # # For 3rd party components: + # username/component: ">=1.0.0,<2.0.0" + # username2/component2: + # version: "~1.0.0" + # # For transient dependencies `public` flag can be set. + # # `public` flag doesn't have an effect dependencies of the `main` component. + # # All dependencies of `main` are public by default. + # public: true + esp-idf-lib/dht: ^1.2.0 + espressif/mqtt: ^1.0.0 + espressif/cjson: ^1.7.19~2 + diff --git a/main/main.c b/main/main.c new file mode 100755 index 0000000..b4b8ea1 --- /dev/null +++ b/main/main.c @@ -0,0 +1,146 @@ +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "esp_check.h" +#include "esp_log.h" +#include "wifi-connect.h" // 包含 Wi-Fi 配网模块头文件(提供 Wi-Fi 连接状态查询和初始化接口) +#include "agri_env.h" // 包含农业环境模块头文件(提供 MQTT 功能接口) +#include "bh1750_use.h" // 包含 BH1750 封装接口 +#include +#include "driver/gpio.h" +#include "relay_ctrl.h" // 包含继电器控制模块头文件(提供继电器控制接口) + +static const char *TAG = "main"; + +// 等待 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; +} + +// 根据 menuconfig 中的选择定义传感器类型常量 +#if defined(CONFIG_EXAMPLE_TYPE_DHT11) +#define SENSOR_TYPE DHT_TYPE_DHT11 +#elif defined(CONFIG_EXAMPLE_TYPE_AM2301) +#define SENSOR_TYPE DHT_TYPE_AM2301 +#elif defined(CONFIG_EXAMPLE_TYPE_SI7021) +#define SENSOR_TYPE DHT_TYPE_SI7021 +#else +#error "未在 menuconfig 中选择任何 DHT 传感器类型!" +#endif + +void dht_test(void *pvParameters) +{ + float temperature, humidity; + const gpio_num_t dht_gpio = (gpio_num_t)CONFIG_EXAMPLE_DATA_GPIO; + uint32_t timeout_count = 0; + + ESP_LOGI(TAG, "正在启动 DHT 测试任务,引脚: GPIO%d", dht_gpio); + ESP_LOGI(TAG, "使用的传感器类型: %d (0:DHT11, 1:AM2301, 2:SI7021)", SENSOR_TYPE); + + // 传感器上电后先等待稳定,避免首读超时 + vTaskDelay(pdMS_TO_TICKS(1500)); + +#ifdef CONFIG_EXAMPLE_INTERNAL_PULLUP + gpio_set_pull_mode(dht_gpio, GPIO_PULLUP_ONLY); +#endif + + while (1) + { + esp_err_t res = ESP_FAIL; + for (int attempt = 1; attempt <= 3; ++attempt) + { + res = dht_read_float_data(SENSOR_TYPE, dht_gpio, &humidity, &temperature); + if (res == ESP_OK) + { + break; + } + // 给总线一点恢复时间,再重试 + vTaskDelay(pdMS_TO_TICKS(200)); + } + + if (res == ESP_OK) + { + timeout_count = 0; + ESP_LOGI(TAG, "湿度: %.1f%% 温度: %.1f°C", humidity, temperature); + } + else + { + if (res == ESP_ERR_TIMEOUT) + { + timeout_count++; + } + ESP_LOGW(TAG, "读取失败: %s", esp_err_to_name(res)); + + if (timeout_count >= 3) + { + ESP_LOGW(TAG, "DHT 连续超时,建议检查: 1) DATA 线上拉电阻(4.7k~10k) 2) 传感器供电与地线 3) 是否更换为 GPIO2/3 等普通 IO"); + timeout_count = 0; + } + } + + vTaskDelay(pdMS_TO_TICKS(2000)); + } +} + +void app_main(void) +{ + ESP_ERROR_CHECK(wifi_connect_init()); // 初始化 Wi-Fi 配网模块 + ESP_ERROR_CHECK(bh1750_user_init()); // 初始化光照传感器 + + ESP_ERROR_CHECK(relay_ctrl_init(GPIO_NUM_6, GPIO_NUM_7, true)); // + + vTaskDelay(pdMS_TO_TICKS(2000)); // 等待系统稳定 + + + if (wait_for_wifi_connected(pdMS_TO_TICKS(60000))) + { + esp_err_t err = agri_env_mqtt_start(); + if (err != ESP_OK) + { + ESP_LOGW(TAG, "MQTT 启动失败: %s", esp_err_to_name(err)); + } + + // 测试读取光照数据 + float lux; + if (bh1750_user_read(&lux) == ESP_OK) + { + ESP_LOGI(TAG, "测试读取光照强度: %.2f Lux", lux); + } + } + else + { + ESP_LOGW(TAG, "Wi-Fi 连接超时,暂时跳过 MQTT 启动"); + } + + // 启动 DHT 传感器测试任务 + if (xTaskCreate(dht_test, "dht_test", 2048, NULL, 5, NULL) != pdPASS) + { + ESP_LOGE(TAG, "创建 DHT 任务失败"); + } + + // 启动一个心跳任务 + while (1) + { + ESP_LOGI(TAG, "系统心跳..."); + // 测试读取光照数据 + float lux; + if (bh1750_user_read(&lux) == ESP_OK) + { + ESP_LOGI(TAG, "测试读取光照强度: %.2f Lux", lux); + } + vTaskDelay(pdMS_TO_TICKS(10000)); + } +}