跪拜 Guibai
← Back to the summary

A $5 ESP32 Pager That Provisions Itself and Receives Cloud Messages

From phone-based Wi-Fi provisioning to cloud MQTT message delivery, an end-to-end IoT prototype covering embedded firmware, an Android App, a Cloudflare Worker, and EMQX Cloud.

GitHub source code repository

iot-provisioning-flow.png

This project solves two seemingly simple problems that actually span multiple systems:

  1. How can an ESP32-S3 that has just powered on and is not yet connected to the internet securely and recoverably obtain a home Wi-Fi configuration?
  2. After the device is online, how can a phone send messages to a specific device through the cloud without relying on the local network?

The project has currently run through the complete link: an Android phone connects to the ESP32's temporary hotspot and hands over the 2.4GHz Wi-Fi credentials; the device persists the configuration only after successfully verifying the network connection, then connects to EMQX via TLS. The phone can also call a Cloudflare Worker, which publishes display commands to MQTT, and the ESP32 feeds back the result via the serial port, an RGB indicator light, and an ACK topic.

The development board currently in use is the YD-ESP32-23 / ESP32-S3-N16R8. The OLED display and buzzer have not yet been connected, so message display is temporarily replaced by serial output and a blue light flash, but the cloud-to-device message link is fully closed-loop.

Project Capabilities

Project Cost

Hardware (total cost within 40 RMB)

Software (cost 0 RMB)

Repository Structure

.
├── android-app/                    # Kotlin + Jetpack Compose provisioning and messaging App
├── embedded/
│   ├── firmware/wifi_provisioning/ # ESP32-S3 Arduino firmware
│   ├── hardware_check/             # Arduino hardware check program
│   ├── circuitpython_check.py      # CircuitPython hardware check script
│   ├── img/                        # Development board photos
│   └── WIFI_PROVISIONING_PLAN.md   # Provisioning requirements and protocol design notes
├── mqtt/                           # MQTT certificate materials
├── serverless/                     # Cloudflare Worker message API
└── docs/images/                    # README illustrations

The four main boundaries each assume a responsibility:

Module Runtime Location Responsibility
android-app Android Phone Guide provisioning, call local device API, send cloud messages
embedded ESP32-S3 SoftAP, HTTP API, NVS, Wi-Fi state machine, MQTT client
serverless Cloudflare Workers Public API, authentication, parameter validation, call EMQX HTTP API
mqtt / EMQX MQTT Broker Route cloud commands to specified devices, carry ACKs and online status

Overall Architecture

Provisioning and message sending are two independent links. Provisioning occurs within the temporary local network formed by the phone and the ESP32; message sending occurs over the public internet, requiring the ESP32 to already be connected to the home Wi-Fi.

image.png

Phase 1: Getting Wi-Fi for an Offline Device

1. ESP32 Enters Provisioning Mode

IMG_20260811_100557.png

IMG_20260811_100631.png

At startup, the device first reads the saved SSID, password, and hasProvisionedBefore from NVS. If a valid configuration exists, the firmware attempts to connect directly; otherwise, it waits for the user to long-press BOOT.

After a 5-second long press, the device will:

  1. Generate a device ID based on the eFuse MAC.
  2. Take the last three characters of the device ID to generate a hotspot name, e.g., esp32-c9c.
  3. Generate a 16-byte random provisioning token.
  4. Start an open hotspot in WIFI_AP_STA mode.
  5. Fix the SoftAP to 192.168.4.1, start DNS and HTTP services.
  6. Use a blue breathing light to tell the user the device is waiting for provisioning.

The core logic in the firmware is as follows (excerpt):

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;
}

The ESP32-S3 has only one 2.4GHz radio. When the AP is turned on again while the device is already connected to a home Wi-Fi, the AP and STA need to share a channel, so the firmware prioritizes using the current STA channel to avoid low-level configuration failures when switching Wi-Fi.

2. Android Requests Connection to Device Hotspot

After Android 10, apps cannot silently switch Wi-Fi. The App uses WifiNetworkSpecifier to describe the target hotspot, and the system prompts the user to confirm via a dialog. This confirmation process cannot be bypassed and is part of the actual provisioning experience.

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)

The device hotspot has no internet, so Android may prompt "This network has no internet access," which is normal. After the App obtains the Network object returned by the system, it does not modify the global network but uses network.openConnection() to explicitly bind ESP32 API requests to the temporary hotspot. This way, even if the phone retains a cellular network, requests to 192.168.4.1 are not mistakenly sent to the public internet.

3. Establish a One-Time Provisioning Session

The App first anonymously reads device information:

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

The response includes the device ID, firmware version, historical provisioning status, and the current session token:

{
  "deviceModel": "ESP32-S3-N16R8",
  "deviceId": "7CE8B1B1FC9C",
  "firmwareVersion": "0.4.1",
  "provisioningState": "awaiting_config",
  "hasProvisionedBefore": true,
  "apSsid": "esp32-c9c",
  "provisioningToken": "Randomly generated token for this boot"
}

Except for device information, all other provisioning interfaces must carry the token:

X-Provisioning-Token: <token>

The firmware uniformly validates this request header; an incorrect token returns 401 INVALID_PROVISIONING_TOKEN. The token exists only within the current provisioning process and changes after a device restart or re-entering provisioning mode.

4. Scan and Submit Home Wi-Fi

The App calls /wifi/scan, and the ESP32 scans nearby networks and returns SSID, RSSI, channel, and encryption type. The App deduplicates and sorts by signal strength, only allowing the user to select actually visible networks. The ESP32-S3 only supports 2.4GHz, so the home router must have the 2.4GHz band enabled.

After the user enters the password, the App submits:

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

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

The firmware restricts the SSID to 1–32 bytes, the password to empty or 8–63 bytes, and limits the request body size. After the request passes, it returns 202 Accepted, then attempts to connect to the home router in AP + STA coexistence mode.

5. Poll Status, Save Only After Success

The App polls /wifi/status once per second. The state machine includes:

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

The most important design here is "verify first, then overwrite": the new SSID and password submitted by the user are first held in memory; only when the ESP32 has connected to the router and obtained a non-zero IP does the firmware write the new credentials to NVS. If the connection fails, the old configuration is retained, preventing a single password typo from completely taking an otherwise usable device offline.

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

  if (WiFi.status() == WL_CONNECTED &&
      WiFi.localIP() != IPAddress(0, 0, 0, 0)) {
    finishSuccessfulConnection(); // Save NVS here
    return;
  }

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

After success, the App releases the device network, and Android automatically restores the original network. The ESP32 temporarily retains the hotspot for the App to read the final status, then turns off the SoftAP, and the RGB indicator switches to a low-brightness solid green.

The complete sequence is as follows:

sequenceDiagram
    actor User as User
    participant App as Android App
    participant OS as Android System
    participant ESP as ESP32-S3
    participant Router as 2.4GHz Router

    User->>ESP: Long press BOOT 5s
    ESP->>ESP: Start esp32-xxx and blue breathing light
    App->>OS: Request connection to specified SoftAP
    OS->>User: Show system confirmation dialog
    User-->>OS: Allow connection
    OS-->>App: Return Network bound to hotspot
    App->>ESP: GET /api/v1/device
    ESP-->>App: Device info + temporary Token
    App->>ESP: GET /api/v1/wifi/scan
    ESP-->>App: 2.4GHz network list
    User->>App: Select SSID and enter password
    App->>ESP: POST /api/v1/wifi/config
    ESP->>Router: Attempt connection
    loop Poll every second, up to ~22s
        App->>ESP: GET /api/v1/wifi/status
        ESP-->>App: connecting / connected / failed
    end
    Router-->>ESP: DHCP address
    ESP->>ESP: Write to NVS after success
    ESP-->>App: connected
    App->>OS: Release device network
    ESP->>ESP: Delay close SoftAP, solid green light

Phase 2: Sending Messages from App to Device

After provisioning is complete, the phone and ESP32 do not need to be on the same local network. The App calls the Worker via HTTPS, and the Worker publishes MQTT commands through EMQX's HTTP API.

1. Android Calls Worker

The App places the Worker address, target device ID, and the Token read at build time into BuildConfig. The request format:

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

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

Text is limited to 1–120 Unicode characters. After sending successfully, the App saves the messageId, text, and local send time to SharedPreferences, retaining only the last 10 records.

2. Worker Validates and Publishes MQTT

The Worker is not just a forwarder; it performs boundary checks as the public internet entry point:

The key code for publishing to EMQX is as follows:

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)
  })
});

Choosing retain: false here is to prevent the device from re-executing an old temporary display command as a new message when it reconnects after being offline. QoS 1 ensures the Broker confirms message delivery at least once.

3. ESP32 Receives Commands via TLS

After obtaining a trusted system time, the ESP32 uses the DigiCert Global Root G2 CA to verify the EMQX certificate and establishes a TLS connection via port 8883. The client ID and username are both derived from the device ID:

Client: esp32-7CE8B1B1FC9C
Subscribe: devices/7CE8B1B1FC9C/commands/display
Publish: devices/7CE8B1B1FC9C/ack
Publish: devices/7CE8B1B1FC9C/state

Upon receiving a message, the firmware parses the JSON and re-checks the messageId, target deviceId, command type, and text content; it cannot rely solely on the Broker's topic isolation.

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");

Before the OLED and buzzer are connected, the serial print and 250ms blue light flash serve as the end-to-end confirmation signal. Later, simply replace them with the actual display and buzzer drivers in the same handler function; the cloud protocol does not need to change.

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

    User->>App: Enter text and send
    App->>Worker: HTTPS POST + Bearer Token
    Worker->>Worker: Auth, validate, generate messageId
    Worker->>EMQX: HTTP API publish QoS 1 message
    EMQX-->>Worker: Accept publish
    Worker-->>App: 202 Accepted + messageId
    EMQX->>ESP: MQTTS display command
    ESP->>ESP: Validate, serial output, blue light flash
    ESP->>EMQX: ACK: received

Interface and Topic Quick Reference

ESP32 Local API

Base URL: http://192.168.4.1/api/v1

Method Path Token Purpose
GET /device No Get device info and current provisioning Token
GET /wifi/scan Yes Scan nearby Wi-Fi
POST /wifi/config Yes Submit candidate SSID and password
GET /wifi/status Yes Query connection status and error reason
POST /wifi/reset Yes Clear NVS and restart
POST /device/reboot Yes Restart device

Worker Public API

Method Path Purpose
GET /health Check service config and availability, no secrets returned
POST /api/v1/devices/:deviceId/messages Publish device display command after authentication

MQTT Topics

Direction Topic retained Description
Cloud → Device devices/:deviceId/commands/display No Temporary display command
Device → Cloud devices/:deviceId/ack No Receive or reject result
Device → Cloud devices/:deviceId/state Yes Online status, firmware version, and IP

Running the Project from Scratch

1. Flash ESP32 Firmware

Open in Arduino IDE:

embedded/firmware/wifi_provisioning/wifi_provisioning.ino

Board configuration:

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

Install Arduino libraries:

ArduinoJson
PubSubClient

Copy the MQTT key example and fill in the device's authentication password in EMQX:

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

mqtt_secrets.h is Git-ignored. After flashing, open the serial monitor at 115200 baud to see the device ID, network status, MQTT connection process, and received messages.

2. Configure and Deploy Worker

Enter the Worker project and install dependencies:

cd serverless
npm install

The Cloudflare environment needs the following variables or Secrets configured:

Name Type Purpose
EMQX_API_URL Text EMQX v5 HTTP API address
DEVICE_ID Text Currently allowed device ID
DEVICE_COMMAND_TOPIC Text Device command topic
EMQX_APP_ID Secret EMQX API Key
EMQX_APP_SECRET Secret EMQX API Secret
APP_API_TOKEN Secret Bearer Token for Android to call the Worker

For local development, copy .dev.vars.example to .dev.vars and fill in test values; real values must not be committed.

npm test
npm run dev
npm run deploy

wrangler.jsonc has keep_vars enabled, so deploying code will not delete existing variables and Secrets in the Cloudflare dashboard.

3. Build Android App

Open android-app/ in Android Studio, install Android SDK 36, use JDK 17. After letting Android Studio generate android-app/local.properties, append the following to it:

APP_API_TOKEN=Token matching the one in Cloudflare

Do not overwrite the sdk.dir written by Android Studio. local.properties is Git-ignored; the repository only keeps a placeholder example. After changing the Token, the APK needs to be rebuilt because it is compiled into BuildConfig.

Command-line build and test:

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

Wi-Fi provisioning must be verified on a real device: the emulator cannot reliably test nearby Wi-Fi, the system connection confirmation dialog, and the routing behavior of a hotspot without internet. Android 13 and above require the "Nearby Wi-Fi devices" permission; Android 10–12 still require location permission when using related Wi-Fi APIs.

Security Design and Current Boundaries

This project has already separated "local provisioning credentials" from "public internet control credentials," but it is currently a single-device prototype and cannot be directly equated with a production-ready security scheme.

Risk Point Current Measure Production Suggestion
Open SoftAP accessed by bystanders Modification interfaces require a random Token per boot Use QR code to carry device secret, add application-layer encryption and provisioning timeout
Home Wi-Fi password leakage Only transmitted over local hotspot; not printed, not persisted in App Enable encrypted provisioning protocol and NVS Encryption
Misconfiguration overwrites usable credentials Write to NVS only after connecting and obtaining IP Add dual-slot configuration, rollback counter, and recovery strategy
Worker called without authorization Bearer Token + Device ID + Input validation User login, short-lived tokens, device ownership, and rate limiting
Token obtained by reverse-engineering APK Token only stored in local config but compiled into APK Do not store long-term shared secrets on the client
MQTT eavesdropped or impersonated TLS CA verification, device-specific accounts and topics Per-device certificates, minimal ACL, key rotation
Firmware tampered with Hardware security capabilities not yet enabled Secure Boot, Flash Encryption, signed OTA

It is particularly important to note: although the SoftAP has a temporary Token, it is currently an open hotspot, and the local HTTP is in plaintext. This is sufficient to support prototype verification in a controlled environment but is not suitable for direct mass production deployment in an untrusted public environment.

Troubleshooting

Phone cannot find esp32-xxx

Android connected to hotspot, but cannot read device

ESP32 cannot connect to home Wi-Fi

Worker returns 401 or 502

ESP32 is online but cannot receive MQTT

Testing

The Worker uses Node.js's built-in test runner to cover the following scenarios:

cd serverless && npm test
cd android-app && ./gradlew test

The hardware link still requires verification on a real device, including the BOOT button, RGB status, SoftAP, router connection, TLS handshake, and MQTT send/receive. embedded/hardware_check/ and embedded/circuitpython_check.py can be used for basic development board checks.

Future Improvements

Afterword

If this article was helpful to you, you can follow my personal public account 半个柠檬2020. I occasionally update some of my own study notes there.