用ESP32-S3开发板开发一个接收消息的BB机

27 阅读15分钟

从手机配网到云端 MQTT 消息下发,一个覆盖嵌入式固件、Android App、Cloudflare Worker 和 EMQX Cloud 的端到端 IoT 原型。

github源代码仓库地址

iot-provisioning-flow.png

这个项目解决两个看似简单、实际横跨多个系统的问题:

  1. 一块刚上电、尚未连接互联网的 ESP32-S3,如何安全、可恢复地拿到家庭 Wi-Fi 配置?
  2. 设备联网后,手机如何不再依赖局域网,经过云端向指定设备发送消息?

项目目前已经跑通完整链路:Android 手机连接 ESP32 临时热点,将 2.4GHz Wi-Fi 凭据交给设备;设备验证联网成功后才持久化配置,随后通过 TLS 连接 EMQX。手机还可以 调用 Cloudflare Worker,由 Worker 将显示命令发布到 MQTT,ESP32 收到后通过串口、RGB 指示灯和 ACK 主题反馈结果。

当前使用的开发板是 YD-ESP32-23 / ESP32-S3-N16R8。OLED显示屏和蜂鸣器尚未接入, 因此消息展示暂时由串口输出和蓝灯闪烁代替,但云端到设备的消息链路已经完整闭环。

项目能力

  • 长按 BOOT 5 秒进入 SoftAP 配网,短按可退出配网模式。
  • Android 10 及以上通过系统 Wi-Fi 弹窗连接 esp32-xxx 临时热点。
  • ESP32 提供本地 HTTP JSON API,负责设备信息、Wi-Fi 扫描、配置和状态查询。
  • 每次启动配网模式都会生成随机 X-Provisioning-Token,保护修改类接口。
  • 新 Wi-Fi 凭据只有在设备成功联网并取得 IP 后才会写入 NVS。
  • 已保存凭据不会因一次切换失败或退出配网而被覆盖。
  • ESP32 使用 TLS 8883 端口连接 EMQX,并支持自动重连和网络诊断。
  • Android 通过 Cloudflare Worker 发送消息,无需再次连接 ESP32 热点。
  • Worker 校验 Bearer Token、设备 ID、消息长度和时长,再以 QoS 1 发布 MQTT 命令。
  • 固件订阅设备专属主题,校验命令后发布 ACK 和在线状态。
  • App 在本机保存最近 10 条成功发送的消息,但不保存家庭 Wi-Fi 密码。

项目成本

硬件部分(全部费用在40元之内)

  • ESP32-S3-N16R8开发板(非官方价格在25元左右)
  • 0.96寸1306驱动4针OLED显示屏(可不购买)
  • 3针高电平触发有源蜂鸣器(可不购买)
  • 10-20cm母对母杜邦线7根(可不购买)

软件部分(费用0元)

仓库结构

.
├── android-app/                    # Kotlin + Jetpack Compose 配网与消息 App
├── embedded/
│   ├── firmware/wifi_provisioning/ # ESP32-S3 Arduino 固件
│   ├── hardware_check/             # Arduino 硬件检测程序
│   ├── circuitpython_check.py      # CircuitPython 硬件检查脚本
│   ├── img/                        # 开发板实拍资料
│   └── WIFI_PROVISIONING_PLAN.md   # 配网需求与协议设计记录
├── mqtt/                           # MQTT 证书资料
├── serverless/                     # Cloudflare Worker 消息 API
└── docs/images/                    # README 插画

四个主要边界各自承担一种职责:

模块运行位置职责
android-appAndroid 手机引导配网、调用本地设备 API、发送云端消息
embeddedESP32-S3SoftAP、HTTP API、NVS、Wi-Fi 状态机、MQTT 客户端
serverlessCloudflare Workers公网 API、鉴权、参数校验、调用 EMQX HTTP API
mqtt / EMQXMQTT Broker将云端命令路由到指定设备,并承载 ACK 与在线状态

整体架构

配网和消息发送是两条独立链路。配网发生在手机与 ESP32 组成的临时局域网内;消息发送 发生在公网,要求 ESP32 已经连上家庭 Wi-Fi。

image.png

第一阶段:让未联网设备拿到 Wi-Fi

1. ESP32 进入配网模式

  • ESP32-S3-N16R8开发板正面

IMG_20260811_100557.png

  • ESP32-S3-N16R8开发板背面

IMG_20260811_100631.png

设备启动时先从 NVS 读取已保存的 SSID、密码和 hasProvisionedBefore。如果存在有效 配置,固件会直接尝试连接;否则等待用户长按 BOOT。

长按 5 秒后,设备会:

  1. 根据 eFuse MAC 生成设备 ID。
  2. 取设备 ID 最后三位生成热点名,例如 esp32-c9c
  3. 生成 16 字节随机配网令牌。
  4. WIFI_AP_STA 模式启动开放热点。
  5. 将 SoftAP 固定为 192.168.4.1,启动 DNS 和 HTTP 服务。
  6. 用蓝色呼吸灯告诉用户设备正在等待配网。

固件中的核心逻辑如下(节选):

void startProvisioningMode() {
  String ssidSuffix = deviceId.substring(deviceId.length() - 3);
  ssidSuffix.toLowerCase();
  apSsid = "esp32-" + ssidSuffix;
  provisioningToken = makeProvisioningToken();

  WiFi.mode(WIFI_AP_STA);
  const IPAddress apIp(192, 168, 4, 1);
  WiFi.softAP(apSsid.c_str(), nullptr, apChannel, false, 4);
  WiFi.softAPConfig(apIp, apIp, IPAddress(255, 255, 255, 0));

  dnsServer.start(53, "*", apIp);
  server.begin();
  provisioningState = ProvisioningState::kAwaitingConfig;
}

ESP32-S3 只有一套 2.4GHz 无线电。设备已经连接家庭 Wi-Fi 时再次打开 AP,AP 与 STA 需要共享信道,因此固件优先沿用当前 STA 信道,避免切换 Wi-Fi 时出现底层配置失败。

2. Android 请求连接设备热点

Android 10 之后,应用不能静默切换 Wi-Fi。App 使用 WifiNetworkSpecifier 描述目标 热点,再由系统弹窗让用户确认。这个确认过程不能绕过,也是实际配网体验的一部分。

val specifier = WifiNetworkSpecifier.Builder()
    .setSsid(ssid)
    .build()

val request = NetworkRequest.Builder()
    .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
    .removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
    .setNetworkSpecifier(specifier)
    .build()

connectivityManager.requestNetwork(request, callback, 30_000)

设备热点没有互联网,Android 可能提示“此网络无法访问互联网”,这是正常现象。App 获得系统返回的 Network 对象后,不会修改全局网络,而是用 network.openConnection() 将 ESP32 API 请求明确绑定到临时热点。这样即使手机同时保留 蜂窝网络,也不会把 192.168.4.1 的请求错误地发到公网。

3. 建立一次性配网会话

App 首先匿名读取设备信息:

GET http://192.168.4.1/api/v1/device

响应包含设备 ID、固件版本、历史配网状态和本次会话令牌:

{
  "deviceModel": "ESP32-S3-N16R8",
  "deviceId": "7CE8B1B1FC9C",
  "firmwareVersion": "0.4.1",
  "provisioningState": "awaiting_config",
  "hasProvisionedBefore": true,
  "apSsid": "esp32-c9c",
  "provisioningToken": "本次启动随机生成的令牌"
}

除设备信息外,其余配网接口都必须带上令牌:

X-Provisioning-Token: <token>

固件统一校验该请求头,令牌错误会返回 401 INVALID_PROVISIONING_TOKEN。令牌只存在于 本次配网进程中,重启设备或重新进入配网模式后会发生变化。

4. 扫描并提交家庭 Wi-Fi

App 调用 /wifi/scan,ESP32 扫描附近网络并返回 SSID、RSSI、信道和加密类型。App 去重后按信号强度排序,只让用户选择实际可见的网络。ESP32-S3 仅支持 2.4GHz,因此 家庭路由器必须开启 2.4GHz 频段。

用户输入密码后,App 提交:

POST /api/v1/wifi/config
Content-Type: application/json
X-Provisioning-Token: <token>

{
  "ssid": "HomeWiFi",
  "password": "example-password"
}

固件限制 SSID 为 1–32 字节,密码为空或 8–63 字节,并限制请求体大小。请求通过后返回 202 Accepted,随后以 AP + STA 共存模式尝试连接家庭路由器。

5. 轮询状态,成功后才保存

App 每秒轮询一次 /wifi/status。状态机包括:

unprovisioned → awaiting_config → connecting → connected
                                      └──────→ failed

这里最重要的设计是“先验证,后覆盖”:用户提交的新 SSID 和密码先放在内存中;只有 ESP32 已连接路由器并取得非零 IP,固件才把新凭据写入 NVS。如果连接失败,旧配置仍然 保留,避免一次输错密码就让原本可用的设备彻底离线。

void processConnectionState() {
  if (provisioningState != ProvisioningState::kConnecting) return;

  if (WiFi.status() == WL_CONNECTED &&
      WiFi.localIP() != IPAddress(0, 0, 0, 0)) {
    finishSuccessfulConnection(); // 在这里保存 NVS
    return;
  }

  if (millis() - connectionStartedAt >= Config::kConnectionTimeoutMs) {
    finishFailedConnection();
  }
}

成功后 App 释放设备网络,Android 自动恢复原网络。ESP32 会暂时保留热点供 App 读到 最终状态,然后关闭 SoftAP,RGB 指示灯切换为低亮度绿色常亮。

完整时序如下:

sequenceDiagram
    actor User as 用户
    participant App as Android App
    participant OS as Android 系统
    participant ESP as ESP32-S3
    participant Router as 2.4GHz 路由器

    User->>ESP: 长按 BOOT 5 秒
    ESP->>ESP: 启动 esp32-xxx 与蓝色呼吸灯
    App->>OS: 请求连接指定 SoftAP
    OS->>User: 显示系统确认弹窗
    User-->>OS: 允许连接
    OS-->>App: 返回绑定热点的 Network
    App->>ESP: GET /api/v1/device
    ESP-->>App: 设备信息 + 临时 Token
    App->>ESP: GET /api/v1/wifi/scan
    ESP-->>App: 2.4GHz 网络列表
    User->>App: 选择 SSID 并输入密码
    App->>ESP: POST /api/v1/wifi/config
    ESP->>Router: 尝试连接
    loop 每秒查询,最多约 22 秒
        App->>ESP: GET /api/v1/wifi/status
        ESP-->>App: connecting / connected / failed
    end
    Router-->>ESP: DHCP 地址
    ESP->>ESP: 成功后写入 NVS
    ESP-->>App: connected
    App->>OS: 释放设备网络
    ESP->>ESP: 延时关闭 SoftAP,绿灯常亮

第二阶段:从 App 向设备发送消息

完成配网后,手机和 ESP32 不需要在同一个局域网。App 通过 HTTPS 调用 Worker,Worker 再通过 EMQX 的 HTTP API 发布 MQTT 命令。

1. Android 调用 Worker

App 将 Worker 地址、目标设备 ID 和本机构建时读取的 Token 放入 BuildConfig。请求格式:

POST /api/v1/devices/7CE8B1B1FC9C/messages
Authorization: Bearer <APP_API_TOKEN>
Content-Type: application/json

{
  "text": "Hello ESP32",
  "displayDurationMs": 10000,
  "buzzerDurationMs": 3000
}

文字限制为 1–120 个 Unicode 字符。发送成功后,App 保存 messageId、文字和本地发送 时间到 SharedPreferences,仅保留最近 10 条记录。

2. Worker 校验并发布 MQTT

Worker 不只是转发器,它承担公网入口的边界检查:

  • 使用近似常量时间比较校验 Bearer Token。
  • 只允许配置中的单个设备 ID。
  • 限制请求体不超过 4096 字节。
  • 校验文字、显示时长和蜂鸣时长。
  • 为每条消息生成 UUID。
  • 以 QoS 1、非 retained 消息发布到设备专属主题。

发布给 EMQX 的关键代码如下:

await fetch(`${env.EMQX_API_URL}/publish`, {
  method: "POST",
  headers: {
    authorization: basicAuthorization(env.EMQX_APP_ID, env.EMQX_APP_SECRET),
    "content-type": "application/json"
  },
  body: JSON.stringify({
    topic: env.DEVICE_COMMAND_TOPIC,
    qos: 1,
    retain: false,
    payload_encoding: "plain",
    payload: JSON.stringify(message)
  })
});

这里选择 retain: false,是为了避免设备离线后重新连接时把一条旧的临时显示命令当成 新消息再次执行。QoS 1 则让 Broker 至少确认一次消息投递。

3. ESP32 通过 TLS 接收命令

ESP32 在获取可信系统时间后,使用 DigiCert Global Root G2 CA 校验 EMQX 证书,并通过 8883 端口建立 TLS 连接。客户端 ID 和用户名均由设备 ID 派生:

客户端:esp32-7CE8B1B1FC9C
订阅:devices/7CE8B1B1FC9C/commands/display
发布:devices/7CE8B1B1FC9C/ack
发布:devices/7CE8B1B1FC9C/state

收到消息后,固件会解析 JSON,并再次检查 messageId、目标 deviceId、命令类型和 文字内容,不能只依赖 Broker 的主题隔离。

if (messageId[0] == '\0' ||
    strcmp(targetDeviceId, deviceId.c_str()) != 0 ||
    strcmp(type, "display") != 0 || text[0] == '\0') {
  publishCommandAck(messageId, "rejected", "INVALID_COMMAND");
  return;
}

Serial.printf("[mqtt] Text: %s\n", text);
rgbLedWrite(RGB_BUILTIN, 0, 0, Config::kButtonHoldLedBrightness);
publishCommandAck(messageId, "received");

在 OLED 和蜂鸣器接入之前,串口打印和 250ms 蓝灯闪烁就是端到端确认信号。之后只需在 同一处理函数中替换为真正的显示与蜂鸣驱动,云端协议不需要变化。

sequenceDiagram
    actor User as 用户
    participant App as Android App
    participant Worker as Cloudflare Worker
    participant EMQX as EMQX Cloud
    participant ESP as ESP32-S3

    User->>App: 输入文字并发送
    App->>Worker: HTTPS POST + Bearer Token
    Worker->>Worker: 鉴权、校验、生成 messageId
    Worker->>EMQX: HTTP API 发布 QoS 1 消息
    EMQX-->>Worker: 接受发布
    Worker-->>App: 202 Accepted + messageId
    EMQX->>ESP: MQTTS display command
    ESP->>ESP: 校验、串口输出、蓝灯闪烁
    ESP->>EMQX: ACK: received

接口与主题速查

ESP32 本地 API

基础地址:http://192.168.4.1/api/v1

方法路径Token作用
GET/device获取设备信息和本次配网 Token
GET/wifi/scan扫描附近 Wi-Fi
POST/wifi/config提交候选 SSID 和密码
GET/wifi/status查询连接状态和错误原因
POST/wifi/reset清空 NVS 并重启
POST/device/reboot重启设备

Worker 公网 API

方法路径作用
GET/health检查服务配置和可用性,不返回秘密
POST/api/v1/devices/:deviceId/messages鉴权后发布设备显示命令

MQTT 主题

方向主题retained说明
云端 → 设备devices/:deviceId/commands/display临时显示命令
设备 → 云端devices/:deviceId/ack接收或拒绝结果
设备 → 云端devices/:deviceId/state在线状态、固件版本和 IP

从零运行项目

1. 烧录 ESP32 固件

使用 Arduino IDE 打开:

embedded/firmware/wifi_provisioning/wifi_provisioning.ino

开发板配置:

Board: ESP32S3 Dev Module
Flash Size: 16MB (128Mb)
Flash Mode: QIO 80MHz
PSRAM: OPI PSRAM
Partition Scheme: 16M Flash (3MB APP/9.9MB FATFS)
CPU Frequency: 240MHz (WiFi)
Upload Speed: 460800
Upload Mode: UART0 / Hardware CDC
USB CDC On Boot: Disabled

安装 Arduino 库:

ArduinoJson
PubSubClient

复制 MQTT 密钥示例,并填写设备在 EMQX 中的认证密码:

cd embedded/firmware/wifi_provisioning
cp mqtt_secrets.h.example mqtt_secrets.h

mqtt_secrets.h 已被 Git 忽略。烧录后打开 115200 波特率串口监视器,可以看到设备 ID、 联网状态、MQTT 连接过程和收到的消息。

2. 配置并部署 Worker

进入 Worker 工程并安装依赖:

cd serverless
npm install

Cloudflare 环境需要配置以下变量或 Secret:

名称类型用途
EMQX_API_URLTextEMQX v5 HTTP API 地址
DEVICE_IDText当前允许访问的设备 ID
DEVICE_COMMAND_TOPICText设备命令主题
EMQX_APP_IDSecretEMQX API Key
EMQX_APP_SECRETSecretEMQX API Secret
APP_API_TOKENSecretAndroid 调用 Worker 的 Bearer Token

本地开发可将 .dev.vars.example 复制为 .dev.vars 并填入测试值;真实值不能提交。

npm test
npm run dev
npm run deploy

wrangler.jsonc 开启了 keep_vars,部署代码时不会删除 Cloudflare 控制台中已有的变量和 Secret。

3. 构建 Android App

在 Android Studio 中打开 android-app/,安装 Android SDK 36,使用 JDK 17。让 Android Studio 生成 android-app/local.properties 后,在其中追加:

APP_API_TOKEN=与Cloudflare中一致的Token

不要覆盖 Android Studio 写入的 sdk.dirlocal.properties 已被 Git 忽略,仓库只保留 占位示例。Token 修改后需要重新构建 APK,因为它会被编译进 BuildConfig

命令行构建与测试:

cd android-app
./gradlew test
./gradlew assembleDebug

Wi-Fi 配网必须使用真机验证:模拟器无法可靠测试附近 Wi-Fi、系统连接确认弹窗和无互联网 热点的路由行为。Android 13 及以上需要“附近的 Wi-Fi 设备”权限;Android 10–12 使用 相关 Wi-Fi API 时仍需要位置权限。

安全设计与当前边界

这个项目已经把“本地配网凭据”和“公网控制凭据”分开处理,但当前仍是单设备原型,不能 直接等同于量产安全方案。

风险点当前措施量产建议
开放 SoftAP 被旁观者访问修改接口要求每次启动随机 Token使用二维码携带设备秘密,增加应用层加密和配网超时
家庭 Wi-Fi 密码泄露只在本地热点传输;不打印、不在 App 持久化启用加密配网协议与 NVS Encryption
错误配置覆盖可用凭据联网并取得 IP 后才写 NVS增加双分区配置、回滚计数和恢复策略
Worker 被未授权调用Bearer Token + 设备 ID + 输入校验用户登录、短期令牌、设备归属和限流
Token 被逆向 APK 获取Token 仅存本机配置但会编译进 APK不在客户端保存长期共享秘密
MQTT 被窃听或冒充TLS CA 校验、设备独立账号和主题每设备证书、最小 ACL、密钥轮换
固件被篡改当前尚未启用硬件安全能力Secure Boot、Flash Encryption、签名 OTA

特别需要注意:SoftAP 虽然有临时 Token,但目前仍是开放热点,而且本地 HTTP 是明文。 这足以支持受控环境中的原型验证,不适合在不可信公共环境直接量产部署。

故障排查

手机找不到 esp32-xxx

  • 确认持续按住 BOOT 满 5 秒,而不是短按。
  • 观察板载 RGB 是否呈蓝色呼吸。
  • 打开 115200 串口,检查是否输出 Provisioning mode started
  • 如果设备已联网,再次配网时 AP 会与家庭 Wi-Fi 共用信道。

Android 已连接热点,但读不到设备

  • 接受系统的“连接到设备”弹窗。
  • 不要因为“无互联网”提示而切断热点。
  • 确认请求通过返回的 Network 执行,而不是普通 URL.openConnection()
  • 检查地址是否仍为 http://192.168.4.1

ESP32 连不上家庭 Wi-Fi

  • 确认使用 2.4GHz,而不是仅开启 5GHz 的 SSID。
  • 检查密码长度和字符是否正确。
  • 查看 /wifi/status 返回的 WIFI_NOT_FOUNDWIFI_AUTH_OR_CONNECTION_FAILEDWIFI_CONNECTION_TIMEOUT
  • 失败后旧凭据仍保留,可以退出本次配网再重试。

Worker 返回 401 或 502

  • 401 UNAUTHORIZED:检查 App 与 Cloudflare 中的 APP_API_TOKEN 是否一致,并重新构建 App。
  • 404 DEVICE_NOT_FOUND:检查 App、Worker 和 ESP32 使用的设备 ID。
  • 502 MQTT_PUBLISH_FAILED:检查 EMQX API 地址、App ID、Secret 和 ACL。

ESP32 已联网但收不到 MQTT

  • 确认 mqtt_secrets.h 中已经配置设备密码。
  • 等待 NTP 同步完成;系统时间不可信时固件不会跳过 TLS 证书校验。
  • 检查 8883 端口、DNS 和 Broker CA。
  • 固件连续失败会输出 DNS、TCP、TLS 诊断,并在达到阈值后刷新 Wi-Fi 连接。
  • 确认 Worker 发布主题与设备订阅主题完全一致。

测试

Worker 使用 Node.js 内置测试运行器覆盖以下场景:

  • 健康检查不泄露 Secret。
  • 无效 Token 被拒绝。
  • 文字与时长参数校验。
  • MQTT 消息使用 QoS 1 且不 retained。
  • EMQX 错误被映射为 502 MQTT_PUBLISH_FAILED
cd serverless && npm test
cd android-app && ./gradlew test

硬件链路仍需真机验证,包括 BOOT 按键、RGB 状态、SoftAP、路由器连接、TLS 握手和 MQTT 收发。embedded/hardware_check/embedded/circuitpython_check.py 可用于开发板基础检查。

后期完善

  • 接入 OLED,将 textdisplayDurationMs 映射为真实显示任务。
  • 接入蜂鸣器,并对 buzzerDurationMs 设置硬件级上限。
  • 让 Worker 消费 ACK,向 App 展示“Broker 已接受”和“设备已执行”两个不同状态。
  • 从单设备固定配置升级为用户、设备绑定和动态主题 ACL。
  • 使用二维码承载设备 ID 与配网秘密,减少手工输入。
  • 增加消息幂等处理,避免 QoS 1 重投导致重复执行。
  • 增加签名 OTA、NVS Encryption、Flash Encryption 和 Secure Boot。
  • 建立端到端自动化测试与多设备并发测试。

写在最后

如果文章对您有帮助,可以关注我的个人公众号半个柠檬2020,偶尔也会在公众号上面更新一些自己的学习笔记。