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.
This project solves two seemingly simple problems that actually span multiple systems:
- 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?
- 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
- Long-press BOOT for 5 seconds to enter SoftAP provisioning mode; short press to exit provisioning mode.
- Android 10 and above connect to the
esp32-xxxtemporary hotspot via the system Wi-Fi dialog. - The ESP32 provides a local HTTP JSON API, responsible for device information, Wi-Fi scanning, configuration, and status queries.
- A random
X-Provisioning-Tokenis generated each time provisioning mode starts, protecting modification interfaces. - New Wi-Fi credentials are written to NVS only after the device successfully connects to the network and obtains an IP.
- Saved credentials are not overwritten due to a single switch failure or exiting provisioning mode.
- The ESP32 connects to EMQX using TLS port 8883 and supports automatic reconnection and network diagnostics.
- Android sends messages via a Cloudflare Worker without needing to reconnect to the ESP32 hotspot.
- The Worker validates the Bearer Token, device ID, message length, and duration before publishing the MQTT command with QoS 1.
- The firmware subscribes to a device-specific topic, validates the command, and publishes an ACK and online status.
- The App saves the last 10 successfully sent messages locally but does not save the home Wi-Fi password.
Project Cost
Hardware (total cost within 40 RMB)
- ESP32-S3-N16R8 development board (unofficial price around 25 RMB)
- 0.96-inch 1306-driven 4-pin OLED display (optional)
- 3-pin high-level trigger active buzzer (optional)
- 7 x 10-20cm female-to-female Dupont wires (optional)
Software (cost 0 RMB)
- EMQX MQTT Platform, use the free tier after registration
- Serverless Platform - Cloudflare Workers, free deployment of Workers and Pages
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.
Phase 1: Getting Wi-Fi for an Offline Device
1. ESP32 Enters Provisioning Mode
- ESP32-S3-N16R8 development board front
- ESP32-S3-N16R8 development board back
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:
- Generate a device ID based on the eFuse MAC.
- Take the last three characters of the device ID to generate a hotspot name, e.g.,
esp32-c9c. - Generate a 16-byte random provisioning token.
- Start an open hotspot in
WIFI_AP_STAmode. - Fix the SoftAP to
192.168.4.1, start DNS and HTTP services. - 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:
- Validates the Bearer Token using near-constant time comparison.
- Only allows the single device ID configured.
- Limits the request body to no more than 4096 bytes.
- Validates text, display duration, and buzzer duration.
- Generates a UUID for each message.
- Publishes to the device-specific topic with QoS 1, non-retained message.
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
- Confirm that BOOT is held continuously for a full 5 seconds, not just a short press.
- Observe if the onboard RGB is breathing blue.
- Open the 115200 serial port and check if
Provisioning mode startedis output. - If the device is already online, the AP will share a channel with the home Wi-Fi when provisioning again.
Android connected to hotspot, but cannot read device
- Accept the system's "Connect to device" dialog.
- Do not disconnect the hotspot because of the "No internet" prompt.
- Confirm that requests are executed through the returned
Network, not a regularURL.openConnection(). - Check if the address is still
http://192.168.4.1.
ESP32 cannot connect to home Wi-Fi
- Confirm using 2.4GHz, not an SSID that only has 5GHz enabled.
- Check if the password length and characters are correct.
- View the
WIFI_NOT_FOUND,WIFI_AUTH_OR_CONNECTION_FAILED, orWIFI_CONNECTION_TIMEOUTreturned by/wifi/status. - After failure, old credentials are still retained; you can exit this provisioning session and retry.
Worker returns 401 or 502
401 UNAUTHORIZED: Check if theAPP_API_TOKENin the App and Cloudflare are consistent, and rebuild the App.404 DEVICE_NOT_FOUND: Check the device ID used by the App, Worker, and ESP32.502 MQTT_PUBLISH_FAILED: Check the EMQX API address, App ID, Secret, and ACL.
ESP32 is online but cannot receive MQTT
- Confirm that the device password has been configured in
mqtt_secrets.h. - Wait for NTP synchronization to complete; the firmware will not skip TLS certificate verification if the system time is untrusted.
- Check port 8883, DNS, and Broker CA.
- If the firmware fails continuously, it will output DNS, TCP, and TLS diagnostics and refresh the Wi-Fi connection after reaching a threshold.
- Confirm that the Worker's publish topic exactly matches the device's subscription topic.
Testing
The Worker uses Node.js's built-in test runner to cover the following scenarios:
- Health check does not leak Secrets.
- Invalid Token is rejected.
- Text and duration parameter validation.
- MQTT messages use QoS 1 and are not retained.
- EMQX errors are mapped to
502 MQTT_PUBLISH_FAILED.
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
- Connect the OLED, mapping
textanddisplayDurationMsto real display tasks. - Connect the buzzer and set a hardware-level upper limit for
buzzerDurationMs. - Have the Worker consume ACKs to show the App two different statuses: "Broker accepted" and "Device executed."
- Upgrade from single-device fixed configuration to user and device binding and dynamic topic ACLs.
- Use QR codes to carry the device ID and provisioning secret, reducing manual input.
- Add message idempotency handling to prevent duplicate execution from QoS 1 redelivery.
- Add signed OTA, NVS Encryption, Flash Encryption, and Secure Boot.
- Establish end-to-end automated testing and multi-device concurrency testing.
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.