From 7261022611f83f6e948e900b5303c86a9ed537cb Mon Sep 17 00:00:00 2001 From: Wang Beihong Date: Thu, 5 Mar 2026 12:41:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9D=E5=A7=8B=E5=8C=96=20Botanical?= =?UTF-8?q?Buddy=20=E9=A1=B9=E7=9B=AE=E5=B9=B6=E9=9B=86=E6=88=90=20Wi-Fi?= =?UTF-8?q?=20=E9=85=8D=E7=BD=91=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 wifi-connect 组件(AP 配网、DNS 劫持、Captive Portal) - 支持长按按键进入配网,网页配置路由器 SSID/密码 - 增加清除已保存 Wi-Fi 参数能力(API + 页面按钮) - 增加中文状态日志与中文注释,优化配网交互 - 补充组件文档:README、USER_GUIDE、QUICK_POSTER、BLOG --- .clangd | 2 + .devcontainer/Dockerfile | 13 + .devcontainer/devcontainer.json | 19 + .gitignore | 78 ++ .vscode/launch.json | 10 + .vscode/settings.json | 21 + CMakeLists.txt | 6 + components/wifi-connect/BLOG.md | 220 +++ components/wifi-connect/CMakeLists.txt | 5 + components/wifi-connect/Kconfig.projbuild | 52 + components/wifi-connect/QUICK_POSTER.md | 28 + components/wifi-connect/README.md | 148 +++ components/wifi-connect/USER_GUIDE.md | 77 ++ .../wifi-connect/include/wifi-connect.h | 34 + components/wifi-connect/wifi-connect.c | 1179 +++++++++++++++++ main/CMakeLists.txt | 3 + main/main.c | 14 + 17 files changed, 1909 insertions(+) create mode 100644 .clangd create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100755 CMakeLists.txt create mode 100644 components/wifi-connect/BLOG.md create mode 100644 components/wifi-connect/CMakeLists.txt create mode 100644 components/wifi-connect/Kconfig.projbuild create mode 100644 components/wifi-connect/QUICK_POSTER.md create mode 100644 components/wifi-connect/README.md create mode 100644 components/wifi-connect/USER_GUIDE.md create mode 100644 components/wifi-connect/include/wifi-connect.h create mode 100644 components/wifi-connect/wifi-connect.c create mode 100755 main/CMakeLists.txt 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/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..4028089 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,21 @@ +{ + "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" + }, + "idf.buildPath": "/home/beihong/esp_projects/BotanicalBuddy/build", + "C_Cpp.default.compileCommands": "/home/beihong/esp_projects/BotanicalBuddy/build/compile_commands.json", + "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/BotanicalBuddy/build" + ], + "idf.flashType": "UART", + "C_Cpp.default.compilerPath": "/home/beihong/.espressif/tools/riscv32-esp-elf/esp-14.2.0_20251107/riscv32-esp-elf/bin/riscv32-esp-elf-g++" +} diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100755 index 0000000..36a2aa9 --- /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(BotanicalBuddy) diff --git a/components/wifi-connect/BLOG.md b/components/wifi-connect/BLOG.md new file mode 100644 index 0000000..76b479e --- /dev/null +++ b/components/wifi-connect/BLOG.md @@ -0,0 +1,220 @@ +# 从 0 到 1:我在 ESP32-C3 上做了一个可落地的 Wi-Fi 配网组件(含中文日志与一键清除配置) + +> 项目:BotanicalBuddy +> 平台:ESP-IDF v5.5.2(ESP32-C3) + +--- + +## 一、为什么要自己做一个配网组件? + +很多物联网项目都会遇到同一个问题: + +- 设备第一次上电,怎么让用户把它连进家里 Wi-Fi? +- 配网失败时,怎么给用户清晰反馈? +- 现场调试时,日志如何快速看懂? + +我这次的目标很明确: + +1. **用户侧简单**:长按按键 → 连接热点 → 打开网页 → 输入密码; +2. **开发侧可维护**:接口清晰、状态可追踪、日志可读; +3. **现场可排障**:失败能看到原因,支持“一键清除历史配置”。 + +最终我把这些能力收敛成一个组件:`wifi-connect`。 + +--- + +## 二、组件能力概览 + +`wifi-connect` 目前实现了这些核心能力: + +- 长按按键进入配网模式; +- 设备开启 SoftAP(`ESP32-xxxxxx`); +- 内置 HTTP 配网页面(扫描、提交、状态轮询); +- DNS 劫持 + Captive Portal 路径兼容(提升手机弹窗成功率); +- 配网成功后保存凭据到 NVS; +- 上电自动重连已保存网络; +- 中文状态日志(便于现场阅读); +- **清除已保存配置**(网页按钮 + API + SDK 接口)。 + +--- + +## 三、整体架构(简化) + +```text +[按键任务] --长按--> [进入配网] + | + +--> APSTA 模式 + +--> HTTP Server(网页) + +--> DNS 劫持服务 + +[网页] --POST /api/connect--> [设置 STA 参数并连接] +[网页] --GET /api/status --> [轮询状态] +[网页] --POST /api/clear --> [清除 NVS 凭据] + +[Wi-Fi/IP 事件] --> [更新状态机 + 打印中文日志 + 保存凭据] +``` + +核心状态枚举: + +- `idle` +- `provisioning` +- `connecting` +- `connected` +- `failed` +- `timeout` + +--- + +## 四、最关键的实现点 + +### 1)配网页面的“够用即好”设计 + +我没有引入前端框架,而是把 HTML/JS 直接内嵌在 C 字符串里,避免增加构建复杂度。页面只保留 3 个动作: + +- 扫描网络 +- 提交连接 +- 清除已保存配置 + +这种做法的好处是:**部署轻、调试快、资源占用低**。 + +--- + +### 2)手机“连上热点但不弹页面”的处理 + +这是配网常见痛点。为了提高兼容性,我做了两件事: + +1. 注册常见探测路径(如 `/generate_204`、`/hotspot-detect.html` 等); +2. 对探测/未知 GET 请求统一返回 `302` 到 `http://192.168.4.1/`。 + +这样很多手机系统会更容易触发门户页面。 + +--- + +### 3)连接超时问题的根因与修复 + +我遇到过这样一条典型日志: + +- `sta is connected, disconnect before connecting to new ap` + +说明设备当时还连着旧网络,却直接尝试切到新网络,最终走到连接超时。修复方案很直接: + +- 在 `esp_wifi_set_config + esp_wifi_connect` 前,先 `esp_wifi_disconnect()`; +- 若断开失败(非 `NOT_CONNECT`),记录告警日志。 + +这一步对稳定性提升很明显。 + +--- + +### 4)新增“清除已保存配置”能力 + +为了提升可恢复性,我新增了完整链路: + +- SDK API:`wifi_connect_clear_config()` +- HTTP API:`POST /api/clear` +- 页面按钮:“清除已保存” + +执行逻辑: + +1. 清除 NVS 的 `ssid`/`pass`; +2. 清空运行时 pending 参数; +3. 若正在连接中,取消当前连接流程; +4. 在配网模式下把状态恢复为 `provisioning`,并清空旧错误文案。 + +这让“失败后重试”路径变得非常顺畅。 + +--- + +## 五、中文日志:现场效率提升非常大 + +为了让非固件同学也能看懂串口,我统一了状态日志风格: + +- `【状态】配网已启动:配网热点已开启,SSID=...` +- `【状态】开始连接路由器:收到配网请求,目标网络:...` +- `【状态】联网成功:已连接 ...,获取 IP=...` +- `【状态】连接路由器超时:请确认密码和路由器信号` + +这类日志在现场排障时比纯英文驱动日志直观很多。 + +> 注:`wifi:`、`esp_netif_lwip:` 前缀日志依然是 ESP-IDF 框架默认输出。 + +--- + +## 六、前端交互做了哪些“小而有效”的优化? + +在配网页面里,我加了几项很实用的小优化: + +- 连接/清除时禁用按钮,防止连点并发请求; +- 清除成功后自动清空密码框; +- 清除后自动刷新状态和扫描结果; +- 状态枚举映射成中文显示(`connecting -> 连接中`)。 + +这些改动代码不多,但用户体验差异非常明显。 + +--- + +## 七、对外 API(当前版本) + +```c +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); +``` + +建议调用顺序: + +1. 启动时调用 `wifi_connect_init()`; +2. 用户长按或业务触发时调用 `wifi_connect_start()`; +3. 成功后由组件自动收口,必要时可手动 `wifi_connect_stop()`; +4. 需要重置时调用 `wifi_connect_clear_config()`。 + +--- + +## 八、测试与验证建议 + +建议至少覆盖以下场景: + +1. 首次配网成功; +2. 密码错误后重试成功; +3. 已连接旧网时切换到新网; +4. 清除配置后重新配网; +5. 空闲超时自动退出; +6. 断电重启后自动重连。 + +如果这 6 条都稳定通过,组件可用性通常已经比较高。 + +--- + +## 九、我这次的经验总结 + +如果你也在做 ESP32 配网,我建议优先做好三件事: + +1. **状态机清晰**:每个阶段可见、可回退; +2. **日志可读**:现场的人不一定是固件开发; +3. **失败可恢复**:必须有“清除历史配置”的入口。 + +很多时候,不是“功能没做出来”,而是“异常路径没兜住”。把恢复路径做顺,产品体验会提升一大截。 + +--- + +## 十、后续可继续优化的方向 + +- 增加多语言页面(中/英切换); +- 增加 AP 密码与会话保护; +- 支持 BLE 辅助配网; +- 接入云端激活与设备绑定流程; +- 做更细粒度的连接错误码映射(前端可读提示)。 + +--- + +## 参考(项目内文档) + +- `components/wifi-connect/README.md` +- `components/wifi-connect/USER_GUIDE.md` +- `components/wifi-connect/QUICK_POSTER.md` + +--- + +如果你正在做类似项目,希望这篇实践记录能帮你少踩一些坑。 \ No newline at end of file diff --git a/components/wifi-connect/CMakeLists.txt b/components/wifi-connect/CMakeLists.txt new file mode 100644 index 0000000..5d624af --- /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..48475a7 --- /dev/null +++ b/components/wifi-connect/Kconfig.projbuild @@ -0,0 +1,52 @@ +menu "WiFi Connect" + +config WIFI_CONNECT_BUTTON_GPIO + int "Provision button GPIO" + range 0 21 + default 9 + help + GPIO used for entering provisioning mode. + +config WIFI_CONNECT_BUTTON_ACTIVE_LEVEL + int "Provision button active level" + range 0 1 + default 0 + help + Active level of provisioning button. 0 means active-low. + +config WIFI_CONNECT_DEBOUNCE_MS + int "Button debounce time (ms)" + range 10 200 + default 40 + +config WIFI_CONNECT_LONG_PRESS_MS + int "Button long press trigger time (ms)" + range 500 10000 + default 2000 + +config WIFI_CONNECT_CONNECT_TIMEOUT_SEC + int "Wi-Fi connect timeout (sec)" + range 5 180 + default 30 + +config WIFI_CONNECT_IDLE_TIMEOUT_SEC + int "Provisioning idle timeout (sec)" + range 30 1800 + default 300 + +config WIFI_CONNECT_MAX_SCAN_RESULTS + int "Maximum scan results" + range 5 50 + default 20 + +config WIFI_CONNECT_AP_MAX_CONNECTIONS + int "SoftAP max connections" + range 1 10 + default 4 + +config WIFI_CONNECT_AP_GRACEFUL_STOP_SEC + int "AP keep-alive after STA connected (sec)" + range 0 120 + default 15 + +endmenu diff --git a/components/wifi-connect/QUICK_POSTER.md b/components/wifi-connect/QUICK_POSTER.md new file mode 100644 index 0000000..1a45948 --- /dev/null +++ b/components/wifi-connect/QUICK_POSTER.md @@ -0,0 +1,28 @@ +# BotanicalBuddy 配网四步卡(张贴版) + +## 第 1 步:长按按键 +长按设备配网键约 2 秒,进入配网模式。 + +## 第 2 步:连接热点 +手机连接 Wi-Fi:ESP32-xxxxxx + +## 第 3 步:打开页面 +浏览器访问:http://192.168.4.1 + +## 第 4 步:提交路由器信息 +选择家里 Wi-Fi,输入密码,点击“连接”。 + +--- + +## 失败时先做这三件事 +1. 确认访问的是 http://192.168.4.1(不是 https) +2. 确认输入的 Wi-Fi 密码正确 +3. 路由器使用 2.4G,设备离路由器更近一点再试 + +--- + +## 二维码占位(贴纸用) +建议制作一个二维码,内容指向: +http://192.168.4.1 + +打印时把二维码贴在本段下方空白区域即可。 diff --git a/components/wifi-connect/README.md b/components/wifi-connect/README.md new file mode 100644 index 0000000..e58d9ad --- /dev/null +++ b/components/wifi-connect/README.md @@ -0,0 +1,148 @@ +# 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` 菜单 + +- `WIFI_CONNECT_BUTTON_GPIO`:进入配网的按键 GPIO +- `WIFI_CONNECT_BUTTON_ACTIVE_LEVEL`:按键有效电平 +- `WIFI_CONNECT_DEBOUNCE_MS`:按键去抖时间 +- `WIFI_CONNECT_LONG_PRESS_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) 提交后连接失败 + +- 检查密码是否正确 +- 查看日志中的失败原因码(`连接失败,原因=...`) +- 检查路由器是否禁用了新设备接入 +- 若曾保存过旧配置,可先在页面点击“清除已保存”后再重试 + +### 3) 成功后热点消失是否正常 + +- 正常。组件设计为连接成功后自动关闭配网热点 +- 可通过 `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/USER_GUIDE.md b/components/wifi-connect/USER_GUIDE.md new file mode 100644 index 0000000..ae5f61a --- /dev/null +++ b/components/wifi-connect/USER_GUIDE.md @@ -0,0 +1,77 @@ +# BotanicalBuddy 配网操作手册(用户版) + +> 适用对象:现场测试、家人用户、非开发人员 +> 目标:让设备快速连上家里 Wi-Fi + +--- + +## 1. 开始前准备 + +- 手机已打开 Wi-Fi +- 记住家里 Wi-Fi 名称和密码 +- 设备已上电 + +--- + +## 2. 进入配网模式 + +1. 长按设备上的配网按键(约 2 秒) +2. 看到串口日志或提示为“配网已启动” +3. 手机 Wi-Fi 列表会出现:`ESP32-xxxxxx` + +--- + +## 3. 手机连接设备热点 + +1. 在手机 Wi-Fi 中连接 `ESP32-xxxxxx` +2. 若系统自动弹出页面,直接进入下一步 +3. 若没有自动弹出,手动打开浏览器输入: + +`http://192.168.4.1` + +> 注意:必须是 `http`,不要用 `https` + +--- + +## 4. 选择路由器并提交 + +1. 点击“扫描网络” +2. 选择你家的 Wi-Fi +3. 输入 Wi-Fi 密码 +4. 点击“连接” + +成功后页面会提示连接成功,设备热点会在几秒后自动关闭(正常现象)。 + +--- + +## 5. 成功后的现象 + +- 设备不再广播 `ESP32-xxxxxx` +- 串口会显示“联网成功,获取 IP=... ” +- 设备进入正常工作状态 + +--- + +## 6. 常见问题 + +### Q1:手机连上热点但打不开页面 + +- 手动访问:`http://192.168.4.1` +- 关闭手机“私人 DNS / 智能网络切换 / VPN”后重试 +- 确认没有强制跳 HTTPS + +### Q2:提示连接失败 + +- 检查 Wi-Fi 密码是否正确 +- 确认路由器 2.4G 可用(ESP32-C3 使用 2.4G) +- 路由器信号太弱时,靠近路由器后重试 + +### Q3:配网成功后热点消失了 + +- 这是正常设计:设备连上路由器后会自动关闭配网热点 + +--- + +## 7. 一句话速记 + +长按按键 → 连 `ESP32-xxxxxx` → 打开 `http://192.168.4.1` → 选 Wi-Fi + 输密码 → 等待成功提示。 diff --git a/components/wifi-connect/include/wifi-connect.h b/components/wifi-connect/include/wifi-connect.h new file mode 100644 index 0000000..ff9addf --- /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..7858a9a --- /dev/null +++ b/components/wifi-connect/wifi-connect.c @@ -0,0 +1,1179 @@ +#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_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 button_task; + TaskHandle_t dns_task; + + SemaphoreHandle_t lock; + + esp_timer_handle_t connect_timer; + esp_timer_handle_t idle_timer; + esp_timer_handle_t ap_stop_timer; + + int dns_sock; + bool dns_running; + + char ap_ssid[32]; + char pending_ssid[33]; + char pending_password[65]; + char last_error[96]; +} wifi_connect_ctx_t; + +static wifi_connect_ctx_t s_ctx = { + .status = WIFI_CONNECT_STATUS_IDLE, + .dns_sock = -1, +}; + +// 配网页面(内嵌 HTML + JS) +static const char *s_html_page = + "" + "" + "ESP32 Wi-Fi Setup" + "
" + "

连接 Wi-Fi

请选择网络并输入密码。

" + "
" + "" + "" + "
" + "
" + "
"; + +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 void wifi_connect_refresh_idle_timeout(void) +{ + if (s_ctx.idle_timer == NULL) { + return; + } + esp_timer_stop(s_ctx.idle_timer); + esp_timer_start_once(s_ctx.idle_timer, (uint64_t)CONFIG_WIFI_CONNECT_IDLE_TIMEOUT_SEC * 1000000ULL); +} + +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_connect_refresh_idle_timeout(); + + 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) { + char dis_msg[96] = {0}; + snprintf(dis_msg, sizeof(dis_msg), "切换网络前断开 STA 失败,错误=%s", esp_err_to_name(dis_err)); + wifi_connect_log_state_w("预断开当前连接失败", dis_msg); + } + + 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_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); + char msg[96] = {0}; + snprintf(msg, sizeof(msg), "自动重连启动失败,错误=%s", esp_err_to_name(err)); + wifi_connect_log_state_w("自动重连失败", msg); + 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; +} + +static esp_err_t wifi_connect_http_connect_handler(httpd_req_t *req) +{ + wifi_connect_refresh_idle_timeout(); + + 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); + char err_msg[96] = {0}; + snprintf(err_msg, sizeof(err_msg), "提交连接失败,错误=%s", esp_err_to_name(err)); + wifi_connect_log_state_w("连接启动失败", err_msg); + 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_refresh_idle_timeout(); + + 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) +{ + wifi_connect_refresh_idle_timeout(); + 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_index_handler(httpd_req_t *req) +{ + wifi_connect_refresh_idle_timeout(); + 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) +{ + wifi_connect_refresh_idle_timeout(); + 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) +{ + esp_err_t ret = ESP_OK; + + 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; + ESP_RETURN_ON_ERROR(httpd_start(&s_ctx.http_server, &config), TAG, "start http server failed"); + + const httpd_uri_t index_uri = { + .uri = "/", + .method = HTTP_GET, + .handler = wifi_connect_http_index_handler, + }; + const httpd_uri_t scan_uri = { + .uri = "/api/scan", + .method = HTTP_GET, + .handler = wifi_connect_http_scan_handler, + }; + const httpd_uri_t connect_uri = { + .uri = "/api/connect", + .method = HTTP_POST, + .handler = wifi_connect_http_connect_handler, + }; + const httpd_uri_t status_uri = { + .uri = "/api/status", + .method = HTTP_GET, + .handler = wifi_connect_http_status_handler, + }; + const httpd_uri_t clear_uri = { + .uri = "/api/clear", + .method = HTTP_POST, + .handler = wifi_connect_http_clear_handler, + }; + const httpd_uri_t probe_1 = { + .uri = "/generate_204", + .method = HTTP_GET, + .handler = wifi_connect_http_probe_handler, + }; + const httpd_uri_t probe_2 = { + .uri = "/hotspot-detect.html", + .method = HTTP_GET, + .handler = wifi_connect_http_probe_handler, + }; + const httpd_uri_t probe_3 = { + .uri = "/ncsi.txt", + .method = HTTP_GET, + .handler = wifi_connect_http_probe_handler, + }; + const httpd_uri_t probe_4 = { + .uri = "/connecttest.txt", + .method = HTTP_GET, + .handler = wifi_connect_http_probe_handler, + }; + const httpd_uri_t probe_5 = { + .uri = "/redirect", + .method = HTTP_GET, + .handler = wifi_connect_http_probe_handler, + }; + const httpd_uri_t probe_6 = { + .uri = "/canonical.html", + .method = HTTP_GET, + .handler = wifi_connect_http_probe_handler, + }; + const httpd_uri_t probe_7 = { + .uri = "/mobile/status.php", + .method = HTTP_GET, + .handler = wifi_connect_http_probe_handler, + }; + const httpd_uri_t probe_8 = { + .uri = "/success.txt", + .method = HTTP_GET, + .handler = wifi_connect_http_probe_handler, + }; + const httpd_uri_t probe_9 = { + .uri = "/library/test/success.html", + .method = HTTP_GET, + .handler = wifi_connect_http_probe_handler, + }; + const httpd_uri_t wildcard = { + .uri = "/*", + .method = HTTP_GET, + .handler = wifi_connect_http_probe_handler, + }; + + ESP_GOTO_ON_ERROR(httpd_register_uri_handler(s_ctx.http_server, &index_uri), fail, TAG, "register / failed"); + ESP_GOTO_ON_ERROR(httpd_register_uri_handler(s_ctx.http_server, &scan_uri), fail, TAG, "register /api/scan failed"); + ESP_GOTO_ON_ERROR(httpd_register_uri_handler(s_ctx.http_server, &connect_uri), fail, TAG, "register /api/connect failed"); + ESP_GOTO_ON_ERROR(httpd_register_uri_handler(s_ctx.http_server, &status_uri), fail, TAG, "register /api/status failed"); + ESP_GOTO_ON_ERROR(httpd_register_uri_handler(s_ctx.http_server, &clear_uri), fail, TAG, "register /api/clear failed"); + ESP_GOTO_ON_ERROR(httpd_register_uri_handler(s_ctx.http_server, &probe_1), fail, TAG, "register /generate_204 failed"); + ESP_GOTO_ON_ERROR(httpd_register_uri_handler(s_ctx.http_server, &probe_2), fail, TAG, "register /hotspot-detect.html failed"); + ESP_GOTO_ON_ERROR(httpd_register_uri_handler(s_ctx.http_server, &probe_3), fail, TAG, "register /ncsi.txt failed"); + ESP_GOTO_ON_ERROR(httpd_register_uri_handler(s_ctx.http_server, &probe_4), fail, TAG, "register /connecttest.txt failed"); + ESP_GOTO_ON_ERROR(httpd_register_uri_handler(s_ctx.http_server, &probe_5), fail, TAG, "register /redirect failed"); + ESP_GOTO_ON_ERROR(httpd_register_uri_handler(s_ctx.http_server, &probe_6), fail, TAG, "register /canonical.html failed"); + ESP_GOTO_ON_ERROR(httpd_register_uri_handler(s_ctx.http_server, &probe_7), fail, TAG, "register /mobile/status.php failed"); + ESP_GOTO_ON_ERROR(httpd_register_uri_handler(s_ctx.http_server, &probe_8), fail, TAG, "register /success.txt failed"); + ESP_GOTO_ON_ERROR(httpd_register_uri_handler(s_ctx.http_server, &probe_9), fail, TAG, "register /library/test/success.html failed"); + ESP_GOTO_ON_ERROR(httpd_register_uri_handler(s_ctx.http_server, &wildcard), fail, TAG, "register wildcard failed"); + + return ESP_OK; + +fail: + httpd_stop(s_ctx.http_server); + s_ctx.http_server = NULL; + return ret; +} + +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) +{ + (void)arg; + uint8_t rx_buf[256]; + uint8_t 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) { + wifi_connect_log_state_e("DNS 服务启动失败", "创建 socket 失败"); + s_ctx.dns_running = false; + vTaskDelete(NULL); + return; + } + + if (bind(s_ctx.dns_sock, (struct sockaddr *)&addr, sizeof(addr)) != 0) { + char err_msg[96] = {0}; + snprintf(err_msg, sizeof(err_msg), "绑定 53 端口失败,errno=%d", errno); + wifi_connect_log_state_e("DNS 服务启动失败", err_msg); + close(s_ctx.dns_sock); + s_ctx.dns_sock = -1; + s_ctx.dns_running = false; + vTaskDelete(NULL); + return; + } + wifi_connect_log_state_i("DNS 劫持服务已启动", "手机访问任意域名将跳转配网页面"); + + 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) { + continue; + } + + 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; + BaseType_t ok = xTaskCreate(wifi_connect_dns_task, "wifi_dns", 4096, NULL, 4, &s_ctx.dns_task); + if (ok != 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) +{ + (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) +{ + (void)arg; + wifi_connect_stop(); +} + +static void wifi_connect_idle_timeout_cb(void *arg) +{ + (void)arg; + xSemaphoreTake(s_ctx.lock, portMAX_DELAY); + bool should_stop = s_ctx.provisioning_active; + if (should_stop) { + wifi_connect_set_status_locked(WIFI_CONNECT_STATUS_TIMEOUT); + wifi_connect_set_error_locked("配网空闲超时"); + wifi_connect_log_state_w("配网超时", "长时间无操作,正在关闭配网热点"); + } + xSemaphoreGive(s_ctx.lock); + + if (should_stop) { + wifi_connect_stop(); + } +} + +static void wifi_connect_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data) +{ + (void)arg; + // STA 获取到 IP:判定联网成功,并根据配置决定是否关闭配网热点 + + 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); + bool provisioning_active = s_ctx.provisioning_active; + 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]; + char password[65]; + 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=" IPSTR, ssid, IP2STR(&got_ip->ip_info.ip)); + wifi_connect_log_state_i("联网成功", success_msg); + + esp_timer_stop(s_ctx.connect_timer); + if (should_save) { + esp_err_t err = wifi_connect_save_credentials(ssid, password); + if (err != ESP_OK) { + char save_msg[96] = {0}; + snprintf(save_msg, sizeof(save_msg), "保存凭据失败,错误=%s", esp_err_to_name(err)); + wifi_connect_log_state_w("保存 Wi-Fi 信息失败", save_msg); + } + } + + if (provisioning_active) { + if (CONFIG_WIFI_CONNECT_AP_GRACEFUL_STOP_SEC == 0) { + wifi_connect_stop(); + } else { + esp_timer_stop(s_ctx.ap_stop_timer); + esp_timer_start_once(s_ctx.ap_stop_timer, (uint64_t)CONFIG_WIFI_CONNECT_AP_GRACEFUL_STOP_SEC * 1000000ULL); + } + } + 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; + char dis_msg[96] = {0}; + snprintf(dis_msg, sizeof(dis_msg), "自动重连断开,原因=%d", dis->reason); + wifi_connect_log_state_w("自动重连中断", dis_msg); + } else { + wifi_connect_set_status_locked(WIFI_CONNECT_STATUS_FAILED); + snprintf(s_ctx.last_error, sizeof(s_ctx.last_error), "连接失败,原因=%d", dis->reason); + char dis_msg[96] = {0}; + snprintf(dis_msg, sizeof(dis_msg), "连接失败,原因=%d", dis->reason); + wifi_connect_log_state_w("连接路由器失败", dis_msg); + } + 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", 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; +} + +static void wifi_connect_button_task(void *arg) +{ + (void)arg; + + const TickType_t interval = pdMS_TO_TICKS(CONFIG_WIFI_CONNECT_DEBOUNCE_MS); + int stable_level = gpio_get_level(CONFIG_WIFI_CONNECT_BUTTON_GPIO); + int last_level = stable_level; + TickType_t changed_at = xTaskGetTickCount(); + TickType_t low_since = 0; + bool triggered = false; + + while (true) { + vTaskDelay(interval); + int level = gpio_get_level(CONFIG_WIFI_CONNECT_BUTTON_GPIO); + TickType_t now = xTaskGetTickCount(); + + if (level != last_level) { + last_level = level; + changed_at = now; + } + + if ((now - changed_at) >= interval && stable_level != level) { + stable_level = level; + if (stable_level == CONFIG_WIFI_CONNECT_BUTTON_ACTIVE_LEVEL) { + low_since = now; + triggered = false; + } else { + low_since = 0; + triggered = false; + } + } + + if (stable_level == CONFIG_WIFI_CONNECT_BUTTON_ACTIVE_LEVEL && low_since != 0 && !triggered) { + TickType_t held = now - low_since; + if (held >= pdMS_TO_TICKS(CONFIG_WIFI_CONNECT_LONG_PRESS_MS)) { + triggered = true; + wifi_connect_log_state_i("检测到按键长按", "开始进入配网模式"); + wifi_connect_start(); + } + } + } +} + +esp_err_t wifi_connect_start(void) +{ + ESP_RETURN_ON_FALSE(s_ctx.initialized, ESP_ERR_INVALID_STATE, TAG, "not initialized"); + // 启动 AP+STA、HTTP 配网页面和 DNS 劫持,进入可配网状态 + + xSemaphoreTake(s_ctx.lock, portMAX_DELAY); + if (s_ctx.provisioning_active) { + xSemaphoreGive(s_ctx.lock); + return ESP_OK; + } + esp_err_t err = wifi_connect_start_apsta_locked(); + if (err != ESP_OK) { + xSemaphoreGive(s_ctx.lock); + return err; + } + + err = wifi_connect_http_start(); + if (err != ESP_OK) { + xSemaphoreGive(s_ctx.lock); + return err; + } + + err = wifi_connect_dns_start(); + if (err != ESP_OK) { + wifi_connect_http_stop(); + xSemaphoreGive(s_ctx.lock); + return err; + } + + 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); + + wifi_connect_refresh_idle_timeout(); + 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; + } + // 停止配网相关服务,若已联网则回到 STA 模式 + + 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); + esp_timer_stop(s_ctx.idle_timer); + esp_timer_stop(s_ctx.ap_stop_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; +} + +esp_err_t wifi_connect_init(void) +{ + if (s_ctx.initialized) { + return ESP_OK; + } + // 一次性初始化 NVS/Wi-Fi/事件/按键任务,并尝试自动连接已保存网络 + + 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; + + gpio_config_t io = { + .pin_bit_mask = (1ULL << CONFIG_WIFI_CONNECT_BUTTON_GPIO), + .mode = GPIO_MODE_INPUT, + .pull_up_en = GPIO_PULLUP_ENABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + ESP_RETURN_ON_ERROR(gpio_config(&io), TAG, "button gpio config failed"); + + 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_timer_create_args_t idle_timer_args = { + .callback = wifi_connect_idle_timeout_cb, + .name = "wifi_idle_to", + }; + 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(&connect_timer_args, &s_ctx.connect_timer), TAG, "connect timer create failed"); + ESP_RETURN_ON_ERROR(esp_timer_create(&idle_timer_args, &s_ctx.idle_timer), TAG, "idle timer create failed"); + ESP_RETURN_ON_ERROR(esp_timer_create(&ap_stop_timer_args, &s_ctx.ap_stop_timer), TAG, "ap stop timer create failed"); + + BaseType_t ok = xTaskCreate(wifi_connect_button_task, "wifi_btn", 3072, NULL, 4, &s_ctx.button_task); + ESP_RETURN_ON_FALSE(ok == pdPASS, ESP_ERR_NO_MEM, TAG, "button task create failed"); + + s_ctx.initialized = true; + + err = wifi_connect_try_auto_connect(); + if (err != ESP_OK) { + char skip_msg[96] = {0}; + snprintf(skip_msg, sizeof(skip_msg), "自动重连已跳过,错误=%s", esp_err_to_name(err)); + wifi_connect_log_state_w("初始化后自动重连未执行", skip_msg); + } + + wifi_connect_log_state_i("wifi-connect 初始化完成", "长按按键可进入配网"); + return ESP_OK; +} diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt new file mode 100755 index 0000000..fbe1e2b --- /dev/null +++ b/main/CMakeLists.txt @@ -0,0 +1,3 @@ +idf_component_register(SRCS "main.c" + INCLUDE_DIRS "." + REQUIRES wifi-connect) diff --git a/main/main.c b/main/main.c new file mode 100755 index 0000000..6568223 --- /dev/null +++ b/main/main.c @@ -0,0 +1,14 @@ +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_check.h" + +#include "wifi-connect.h" + + +void app_main(void) +{ + // 初始化 Wi-Fi 配网组件,支持长按按键进入配网 + ESP_ERROR_CHECK(wifi_connect_init()); + printf("设备启动完成:长按按键进入配网模式,手机连接 ESP32-* 后访问 http://192.168.4.1\n"); +}