feat: 添加 console_user_cmds 组件,提供用户命令支持

This commit is contained in:
Wang Beihong
2026-03-06 13:21:11 +08:00
parent ae294c0b6a
commit 6782af2d4a
6 changed files with 531 additions and 169 deletions

View File

@@ -0,0 +1,3 @@
idf_component_register(SRCS "console_user_cmds.c"
INCLUDE_DIRS "include"
REQUIRES console_simple_init console i2c_master_messager io_device_control wifi-connect)

View File

@@ -0,0 +1,214 @@
#include <stdio.h>
#include <string.h>
#include "console_simple_init.h"
#include "console_user_cmds.h"
#include "i2c_master_messager.h"
#include "io_device_control.h"
#include "wifi-connect.h"
static const char *wifi_status_to_str(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";
}
}
// hello: 最小可用命令,用于验证 console 链路是否正常。
static int cmd_hello(int argc, char **argv)
{
(void)argc;
(void)argv;
printf("hello from BotanicalBuddy\n");
return 0;
}
// sensor: 读取一次传感器缓存数据并打印,便于快速排查现场状态。
static int cmd_sensor(int argc, char **argv)
{
(void)argc;
(void)argv;
i2c_master_messager_data_t data = {0};
esp_err_t ret = i2c_master_messager_get_data(&data);
if (ret != ESP_OK) {
printf("sensor read failed: %s\n", esp_err_to_name(ret));
return 1;
}
if (data.bh1750.valid) {
printf("BH1750: lux=%.1f, ts=%lld ms\n",
data.bh1750.lux,
(long long)data.bh1750.last_update_ms);
} else {
printf("BH1750: invalid, err=%s\n", esp_err_to_name(data.bh1750.last_error));
}
if (data.aht30.valid) {
printf("AHT30: temp=%.1f C, hum=%.1f %%, ts=%lld ms\n",
data.aht30.temperature_c,
data.aht30.humidity_rh,
(long long)data.aht30.last_update_ms);
} else {
printf("AHT30: invalid, err=%s\n", esp_err_to_name(data.aht30.last_error));
}
return 0;
}
static bool parse_on_off_arg(const char *arg, bool *on)
{
if (strcmp(arg, "on") == 0 || strcmp(arg, "1") == 0) {
*on = true;
return true;
}
if (strcmp(arg, "off") == 0 || strcmp(arg, "0") == 0) {
*on = false;
return true;
}
return false;
}
// pump: 控制水泵开关,参数支持 on/off 或 1/0。
static int cmd_pump(int argc, char **argv)
{
if (argc < 2) {
printf("usage: pump <on|off>\n");
return 1;
}
bool on = false;
if (!parse_on_off_arg(argv[1], &on)) {
printf("invalid arg: %s\n", argv[1]);
printf("usage: pump <on|off>\n");
return 1;
}
esp_err_t ret = io_device_control_set_pump(on);
if (ret != ESP_OK) {
printf("set pump failed: %s\n", esp_err_to_name(ret));
return 1;
}
printf("pump: %s\n", on ? "on" : "off");
return 0;
}
// light: 控制补光灯开关,参数支持 on/off 或 1/0。
static int cmd_light(int argc, char **argv)
{
if (argc < 2) {
printf("usage: light <on|off>\n");
return 1;
}
bool on = false;
if (!parse_on_off_arg(argv[1], &on)) {
printf("invalid arg: %s\n", argv[1]);
printf("usage: light <on|off>\n");
return 1;
}
esp_err_t ret = io_device_control_set_light(on);
if (ret != ESP_OK) {
printf("set light failed: %s\n", esp_err_to_name(ret));
return 1;
}
printf("light: %s\n", on ? "on" : "off");
return 0;
}
// wifi: 查询或控制配网状态,支持 status/start/stop/clear 子命令。
static int cmd_wifi(int argc, char **argv)
{
if (argc < 2 || strcmp(argv[1], "status") == 0) {
wifi_connect_config_t cfg = {0};
esp_err_t cfg_ret = wifi_connect_get_config(&cfg);
printf("wifi status: %s\n", wifi_status_to_str(wifi_connect_get_status()));
if (cfg_ret == ESP_OK && cfg.has_config) {
printf("saved ssid: %s\n", cfg.ssid);
} else {
printf("saved config: none\n");
}
return 0;
}
if (strcmp(argv[1], "start") == 0) {
esp_err_t ret = wifi_connect_start();
if (ret != ESP_OK) {
printf("wifi start failed: %s\n", esp_err_to_name(ret));
return 1;
}
printf("wifi start requested\n");
return 0;
}
if (strcmp(argv[1], "stop") == 0) {
esp_err_t ret = wifi_connect_stop();
if (ret != ESP_OK) {
printf("wifi stop failed: %s\n", esp_err_to_name(ret));
return 1;
}
printf("wifi stop requested\n");
return 0;
}
if (strcmp(argv[1], "clear") == 0) {
esp_err_t ret = wifi_connect_clear_config();
if (ret != ESP_OK) {
printf("wifi clear failed: %s\n", esp_err_to_name(ret));
return 1;
}
printf("wifi config cleared\n");
return 0;
}
printf("usage: wifi <status|start|stop|clear>\n");
return 1;
}
esp_err_t console_user_cmds_register(void)
{
esp_err_t ret = ESP_OK;
ret = console_cmd_user_register("hello", cmd_hello);
if (ret != ESP_OK) {
return ret;
}
ret = console_cmd_user_register("sensor", cmd_sensor);
if (ret != ESP_OK) {
return ret;
}
ret = console_cmd_user_register("pump", cmd_pump);
if (ret != ESP_OK) {
return ret;
}
ret = console_cmd_user_register("light", cmd_light);
if (ret != ESP_OK) {
return ret;
}
ret = console_cmd_user_register("wifi", cmd_wifi);
if (ret != ESP_OK) {
return ret;
}
return ESP_OK;
}

View File

@@ -0,0 +1,5 @@
#pragma once
#include "esp_err.h"
esp_err_t console_user_cmds_register(void);

View File

@@ -1,60 +1,69 @@
# 从 0 到 1我在 ESP32-C3 上做了一个可落地的 Wi-Fi 配网组件(含中文日志与一键清除配置)
# ESP32 配网组件设计实践:聚焦功能与实现,而不是项目绑定
> 项目BotanicalBuddy
> 平台ESP-IDF v5.5.2ESP32-C3
很多 ESP32 设备在开发阶段都把“配网”当成一个小功能,但真正落地后会发现:
- 用户第一次接入要顺畅
- 失败后要能恢复
- 日志要便于现场排障
这篇文章只讲配网组件本身,聚焦能力设计和实现思路,不依赖具体业务项目。
---
## 一、为什么要自己做一个配网组件?
## 一、目标能力定义
很多物联网项目都会遇到同一个问题
一个可落地的配网组件,建议至少包含以下能力
- 设备第一次上电,怎么让用户把它连进家里 Wi-Fi
- 配网失败时,怎么给用户清晰反馈?
- 现场调试时,日志如何快速看懂?
- 按键触发配网
- 常驻配网模式(可选)
- SoftAP + Web Portal 配网
- DNS 劫持与常见 Captive Portal 兼容
- 凭据持久化NVS与重启自动重连
- 清除历史配置API + Web
- 状态机与可读日志
我这次的目标很明确:
这几项组合起来,才能覆盖“首次成功 + 失败恢复 + 现场维护”三个关键场景。
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 事件] --> [更新状态机 + 打印中文日志 + 保存凭据]
```mermaid
flowchart TD
A[按键任务/业务触发] --> B[进入 provisioning]
B --> C[启动 SoftAP]
B --> D[启动 HTTP Server]
B --> E[启动 DNS Hijack]
D --> F[/api/scan]
D --> G[/api/connect]
D --> H[/api/status]
D --> I[/api/clear]
G --> J[设置 STA 参数]
J --> K[发起 esp_wifi_connect]
K --> L{Wi-Fi/IP 事件}
L -->|成功| M[状态=connected]
L -->|失败| N[状态=failed]
I --> O[清除 NVS 凭据]
O --> P[清空运行态缓存]
P --> B
```
核心状态枚举:
---
## 二、组件架构(通用)
```text
[按键任务] --> [进入配网]
|
+--> SoftAP + HTTP Server + DNS Hijack
[Web] -- /api/scan --> Wi-Fi 扫描
[Web] -- /api/connect --> 设置 STA 并发起连接
[Web] -- /api/status --> 轮询状态
[Web] -- /api/clear --> 清除已保存配置
[Wi-Fi/IP 事件] --> 更新状态机 + 打印日志 + 保存凭据
```
推荐状态机:
- `idle`
- `provisioning`
@@ -63,96 +72,29 @@
- `failed`
- `timeout`
---
图示(状态机):
## 四、最关键的实现点
```mermaid
stateDiagram-v2
[*] --> idle
idle --> provisioning: wifi_connect_start()
provisioning --> connecting: POST /api/connect
connecting --> connected: GOT_IP
connecting --> failed: AUTH_FAIL / NO_AP / ...
connecting --> timeout: connect timeout
failed --> provisioning: POST /api/clear
timeout --> provisioning: POST /api/clear
connected --> provisioning: 常驻配网继续开放入口(可选)
connected --> idle: stop provisioning(按策略)
```
### 1配网页面的“够用即好”设计
我没有引入前端框架,而是把 HTML/JS 直接内嵌在 C 字符串里,避免增加构建复杂度。页面只保留 3 个动作:
- 扫描网络
- 提交连接
- 清除已保存配置
这种做法的好处是:**部署轻、调试快、资源占用低**。
上图展示了组件的主要数据流与恢复路径。
---
### 2手机“连上热点但不弹页面”的处理
## 三、对外 API 设计建议
这是配网常见痛点。为了提高兼容性,我做了两件事
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);
@@ -163,58 +105,261 @@ 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()`
- `init` 只做初始化和基础恢复
- `start/stop` 控制配网生命周期
- `get_status` 作为 UI/接口层统一读取入口
- `clear_config` 提供失败恢复通道
可以展示一段“典型调用顺序”代码:
```c
ESP_ERROR_CHECK(wifi_connect_init());
// 按键触发或业务触发时
ESP_ERROR_CHECK(wifi_connect_start());
// UI 侧轮询状态
wifi_connect_status_t st = wifi_connect_get_status();
// 需要恢复出厂配网时
ESP_ERROR_CHECK(wifi_connect_clear_config());
```
图示API 生命周期时序):
```mermaid
sequenceDiagram
participant App as 应用层
participant WC as wifi-connect
participant WiFi as esp_wifi
participant Web as 配网页面
App->>WC: wifi_connect_init()
WC-->>App: ESP_OK
App->>WC: wifi_connect_start()
WC->>WiFi: 开启 APSTA / 事件注册
WC-->>App: ESP_OK
Web->>WC: POST /api/connect(ssid,pass)
WC->>WiFi: esp_wifi_disconnect()
WC->>WiFi: esp_wifi_set_config()
WC->>WiFi: esp_wifi_connect()
WiFi-->>WC: WIFI_EVENT / IP_EVENT
alt 获取到 IP
WC-->>Web: status=connected
else 连接失败或超时
WC-->>Web: status=failed|timeout
end
Web->>WC: POST /api/clear
WC-->>Web: status=provisioning
```
上图对应完整 API 生命周期init -> start -> connecting -> connected/failed -> clear
---
## 八、测试与验证建议
## 四、关键实现点
建议至少覆盖以下场景:
### 1) Web 配网页面保持轻量
1. 首次配网成功;
2. 密码错误后重试成功;
3. 已连接旧网时切换到新网;
4. 清除配置后重新配网;
5. 空闲超时自动退出;
6. 断电重启后自动重连。
不一定要引入前端框架。对于资源受限设备,内嵌简洁 HTML/JS 往往更稳定。
如果这 6 条都稳定通过,组件可用性通常已经比较高。
建议页面只保留核心动作:
- 扫描网络
- 提交连接
- 查看状态
- 清除配置
### 2) Captive Portal 兼容是体验关键
仅提供首页 URL 通常不够。建议额外处理:
- 常见探测路径(如 `generate_204` 等)
- 未知路径统一 302 到配网页
这样手机系统弹门户页面成功率会明显提高。
示例代码(伪代码):
```c
static esp_err_t captive_redirect_handler(httpd_req_t *req)
{
httpd_resp_set_status(req, "302 Found");
httpd_resp_set_hdr(req, "Location", "http://192.168.4.1/");
return httpd_resp_send(req, NULL, 0);
}
// 注册常见探测路径
httpd_register_uri_handler(server, &uri_generate_204);
httpd_register_uri_handler(server, &uri_hotspot_detect);
httpd_register_uri_handler(server, &uri_ncsi);
```
可直接使用以下流程图(对应 Captive Portal 重定向路径):
```mermaid
flowchart LR
A[手机连接设备 AP] --> B[访问任意域名]
B --> C[DNS Hijack 返回 192.168.4.1]
C --> D[HTTP 探测路径请求<br/>/generate_204 等]
D --> E[302 Location: http://192.168.4.1/]
E --> F[打开配网页面]
F --> G[扫描 / 连接 / 清除配置]
```
### 3) 连接前主动断开旧连接
这是一个高频坑:设备已有 STA 连接时,直接连接新 AP 可能导致超时或异常状态。
建议在 `esp_wifi_set_config + esp_wifi_connect` 前先执行 `esp_wifi_disconnect()`,并对异常返回做日志记录。
示例代码:
```c
esp_err_t err = esp_wifi_disconnect();
if (err != ESP_OK && err != ESP_ERR_WIFI_NOT_CONNECT) {
ESP_LOGW(TAG, "disconnect before reconnect failed: %s", esp_err_to_name(err));
}
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &cfg));
ESP_ERROR_CHECK(esp_wifi_connect());
```
### 4) “清除配置”必须打通全链路
建议同时提供:
- SDK API`wifi_connect_clear_config()`
- HTTP API`POST /api/clear`
- 页面按钮:`清除已保存`
这样现场人员无需改固件即可恢复设备。
建议把清除动作写成“存储层 + 运行态”两段:
```c
esp_err_t wifi_connect_clear_config(void)
{
// 1) 清 NVS 凭据
ESP_RETURN_ON_ERROR(nvs_erase_key(nvs, "ssid"), TAG, "erase ssid failed");
ESP_RETURN_ON_ERROR(nvs_erase_key(nvs, "pass"), TAG, "erase pass failed");
ESP_RETURN_ON_ERROR(nvs_commit(nvs), TAG, "nvs commit failed");
// 2) 清内存态并回到 provisioning
memset(&s_ctx.pending_cfg, 0, sizeof(s_ctx.pending_cfg));
s_ctx.status = WIFI_CONNECT_STATUS_PROVISIONING;
return ESP_OK;
}
```
可直接使用以下流程图(对应 clear 后状态回到 provisioning
```mermaid
flowchart TD
A[开始清除配置] --> B[擦除 NVS:ssid]
B --> C[擦除 NVS:pass]
C --> D[nvs_commit]
D --> E[清空 pending 配置缓存]
E --> F[清空错误原因/中间状态]
F --> G[状态切回 provisioning]
G --> H[前端轮询显示 可重新配网]
```
---
## 九、我这次的经验总结
## 五、日志策略(非常重要)
如果你也在做 ESP32 配网,我建议优先做好三件事
建议日志遵循“状态 + 原因”格式,例如
1. **状态机清晰**:每个阶段可见、可回退;
2. **日志可读**:现场的人不一定是固件开发;
3. **失败可恢复**:必须有“清除历史配置”的入口。
- `【状态】配网已启动热点已开启SSID=...`
- `【状态】开始连接路由器:目标网络=...`
- `【状态】联网成功:获取 IP=...`
- `【状态】连接失败:原因=...`
很多时候,不是“功能没做出来”,而是“异常路径没兜住”。把恢复路径做顺,产品体验会提升一大截。
这样做的收益是:
- 开发调试快
- 测试可直接定位阶段
- 现场人员无需先理解底层驱动日志
可展示一个统一日志函数风格:
```c
static void log_state_i(const char *title, const char *detail)
{
ESP_LOGI(TAG, "【状态】%s%s", title, detail ? detail : "-");
}
```
如需补充非 Mermaid 图,建议仅放一张关键串口日志截图(启动配网、连接中、成功/失败重试)。
---
## 十、后续可继续优化的方向
## 六、常见问题与排障思路
- 增加多语言页面(中/英切换);
- 增加 AP 密码与会话保护;
- 支持 BLE 辅助配网;
- 接入云端激活与设备绑定流程;
- 做更细粒度的连接错误码映射(前端可读提示)。
### 问题 1手机连上 AP 但页面不弹
排查:
- 手动访问 `http://192.168.4.1`
- 检查 DNS 劫持和门户探测路径是否启用
- 检查 HTTP 服务是否启动成功
### 问题 2提交密码后长时间连接失败
排查:
- 是否先断开旧 STA
- 是否正确处理了连接超时和重试
- 失败原因是否上报到状态机和前端
### 问题 3配网失败后无法恢复
排查:
- NVS 清除逻辑是否真正执行
- 内存态缓存是否同时清空
- 配网状态是否回到 `provisioning`
---
## 参考(项目内文档
## 七、测试清单(可复用
- `components/wifi-connect/README.md`
- `components/wifi-connect/USER_GUIDE.md`
- `components/wifi-connect/QUICK_POSTER.md`
建议每次迭代最少覆盖:
1. 首次配网成功
2. 密码错误后重试成功
3. 连接旧网状态下切换新网成功
4. 清除配置后重新配网成功
5. 重启后自动重连
6. 空闲超时与手动停止路径可用
这 6 条通过后,组件稳定性通常会显著提升。
---
如果你正在做类似项目,希望这篇实践记录能帮你少踩一些坑。
## 八、可继续增强的方向
- 配网页面安全增强(鉴权/会话)
- 多语言提示
- 更细粒度错误码映射
- BLE 辅助配网
- 命令行/远程维护接口联动
---
## 结语
配网组件的核心价值,不是“让设备连上一次网”,而是:
- 功能完整
- 异常可恢复
- 排障可落地
当这三件事做好后,它才是一个能复用、能维护、能上线的基础能力组件。