ESP32 BLE UART:iPhone 和 Android 都能用的無線控制教學
用 ESP32 NimBLE-Arduino 函式庫建立 BLE UART Service,透過標準 UUID 讓 iPhone 與 Android 同時相容。手機傳送指令控制 LED,ESP32 即時回傳確認訊息,並可定時推送 DHT11 溫濕度,實現真正跨平台的藍牙無線通訊。
你有沒有想過,用手機直接控制 ESP32,完全不需要 WiFi、不需要伺服器?
BLE(Bluetooth Low Energy,藍牙低功耗) 就是解法。它是現代 IoT 裝置的主流無線技術,iPhone、Android 都原生支援,耗電量又遠低於傳統藍牙。本篇教學帶你從零開始,用 ESP32 建立一個 BLE UART 無線通訊,以「手機控制 LED 亮滅」作為實作目標,並延伸到雙向傳輸感測數據。
一、BLE UART 是什麼?
BLE 用「Service(服務)+ Characteristic(特徵值)」的架構傳遞資料,概念就像資料夾裡放文件:
- UART Service:一個業界通用的非官方標準服務,UUID 固定,讓各家 App 都能識別
- RX Characteristic:接收通道,手機 → ESP32
- TX Characteristic:發送通道,ESP32 → 手機(用 Notify 推送)
只要雙方用相同的 UUID,任何支援 BLE 的裝置都能互通,這就是為什麼 iPhone 也能用的原因。
BLE UART 和傳統藍牙模組(HC-05)的差別
| 比較項目 | HC-05 模組 | ESP32 BLE UART |
|---|---|---|
| iPhone 支援 | 不支援 | 支援 |
| Android 支援 | 支援 | 支援 |
| 需要外接模組 | 需要 | 不需要(ESP32 內建) |
| 耗電量 | 較高 | 較低 |
| 需要配對密碼 | 需要(預設 1234) | 不需要 |
二、硬體清單
| 項目 | 型號 / 說明 |
|---|---|
| 開發板 | NodeMCU-32S(ESP32) |
| 手機 | iPhone 或 Android 皆可 |



本篇使用 NodeMCU-32S 的內建 LED,不需要任何額外元件,上手更快。
手機 App 選擇
為了方便起見,iOS和Android兩個平台,我們都選同一款APP來測試。至於傑森開發的APP已接近完工,專門針對ESP32的開發,請大家期待!

| 平台 | 推薦 App | 備註 |
|---|---|---|
| iPhone | LightBlue(免費) | App Store 搜尋「LightBlue」 |
| Android | LightBlue(免費) | Google Play 搜尋「LightBlue」 |
三、內建 LED 說明
NodeMCU-32S 板子上有一顆藍色內建 LED,連接在 GPIO2,不需要任何接線。
不過有一點要特別注意:這顆 LED 是 Active Low(低電位觸發),邏輯和外接 LED 相反:
| 指令 | GPIO2 電位 | LED 狀態 |
|---|---|---|
digitalWrite(LED_PIN, LOW) |
低電位 | 亮 |
digitalWrite(LED_PIN, HIGH) |
高電位 | 滅 |
四、DHT11 接線(範例二才需要)
範例一只用內建 LED,不需要任何接線。若要進行範例二(加入溫濕度感測),才需要接 DHT11。
接線方式
DHT11 模組通常有三支腳位(模組版,已內建電阻):
DHT11 模組 NodeMCU-32S
VCC ────────── 3.3V
GND ────────── GND
DATA ────────── GPIO4
| DHT11 模組腳位 | 接到 NodeMCU-32S |
|---|---|
| VCC | 3.3V |
| GND | GND |
| DATA / S | GPIO4 |

五、安裝函式庫
本教學使用 NimBLE-Arduino 函式庫,而不是 ESP32 Arduino 核心內建的 BLEDevice。
為什麼選 NimBLE?
- 記憶體佔用比官方函式庫少約 50%
- API 更簡潔,斷線重連邏輯更可靠
- 社群目前主流推薦
安裝步驟:
- Arduino IDE → 工具 → 管理函式庫
- 搜尋「NimBLE-Arduino」
- 安裝 NimBLE-Arduino by h2zero(選最新版本)

版本注意:本教學程式碼適用 NimBLE-Arduino 2.x(目前最新版)。若你安裝的是舊版 1.x,回呼函式的簽名不同,onConnect、onDisconnect、onWrite的參數列會少一個NimBLEConnInfo&,編譯時會出現marked 'override', but does not override錯誤。建議直接升級到最新版本。
六、程式碼
範例一:基礎版 — 接收指令控制 LED
#include <NimBLEDevice.h> // NimBLE 函式庫,需先在函式庫管理員安裝
// ===== BLE UART 標準 UUID =====
// 這三組 UUID 是業界通用的 UART Service 識別碼
// 只要使用這組固定 UUID,LightBlue 等 App 都能自動識別
#define SERVICE_UUID "6E400001-B5A3-F393-E0A9-E50E24DCCA9E" // 整個 UART Service 的 UUID
#define CHAR_UUID_RX "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" // RX:手機傳資料給 ESP32
#define CHAR_UUID_TX "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" // TX:ESP32 傳資料給手機
#define LED_PIN 2 // NodeMCU-32S 內建 LED 接在 GPIO2
// 注意:內建 LED 是 Active Low(低電位亮、高電位滅)
// pTxChar 是全域變數,讓 RxCallbacks 也能使用它來回傳訊息給手機
NimBLECharacteristic* pTxChar = nullptr;
// 記錄目前是否有手機連線,避免在無人連線時傳送資料造成錯誤
bool deviceConnected = false;
// ===== 連線 / 斷線 事件處理 =====
// 當手機連線或斷線時,BLE 系統會自動呼叫這裡的函式
class ServerCallbacks : public NimBLEServerCallbacks {
// 手機連線成功時自動執行
// NimBLE 2.x:簽名多了 NimBLEConnInfo& connInfo 參數
void onConnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo) override {
deviceConnected = true;
Serial.println("手機已連線");
}
// 手機斷線時自動執行
// reason 是斷線原因代碼,正常斷線為 0
void onDisconnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo, int reason) override {
deviceConnected = false;
Serial.println("手機已斷線,重新廣播中...");
// 斷線後必須重新呼叫 startAdvertising(),否則其他手機無法再搜尋到裝置
NimBLEDevice::startAdvertising();
}
};
// ===== 收到手機指令時的處理 =====
// 每當手機對 RX Characteristic 寫入資料,就會自動呼叫 onWrite()
// NimBLE 2.x:onWrite 也多了 NimBLEConnInfo& connInfo 參數
class RxCallbacks : public NimBLECharacteristicCallbacks {
void onWrite(NimBLECharacteristic* pChar, NimBLEConnInfo& connInfo) override {
// getValue() 取得手機傳來的字串內容
std::string value = pChar->getValue();
// 如果收到空字串就直接忽略,不做任何動作
if (value.empty()) return;
// 只取第一個字元作為指令('1' 或 '0')
char cmd = value[0];
Serial.print("收到指令:");
Serial.println(cmd);
if (cmd == '1') {
digitalWrite(LED_PIN, LOW); // 內建 LED Active Low:LOW = 亮
if (deviceConnected) {
// setValue() 設定要回傳的內容,notify() 把內容推送給手機
pTxChar->setValue("LED 已開啟\n");
pTxChar->notify();
}
} else if (cmd == '0') {
digitalWrite(LED_PIN, HIGH); // 內建 LED Active Low:HIGH = 滅
if (deviceConnected) {
pTxChar->setValue("LED 已關閉\n");
pTxChar->notify();
}
} else {
// 收到非預期的指令,回傳提示訊息
if (deviceConnected) {
pTxChar->setValue("未知指令,請傳送 1 或 0\n");
pTxChar->notify();
}
}
}
};
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, HIGH); // 初始化:HIGH = 滅(避免開機時 LED 預設亮著)
// 初始化 BLE,括號內是裝置名稱,手機掃描時會看到這個名稱
NimBLEDevice::init("傑森創工ESP32-BLE");
// 設定 MTU 為 512 bytes(預設只有 23 bytes,實際可用僅 20 bytes)
// 協商後最多可傳約 509 bytes,足以傳送較長的中文字串或 JSON 資料
// 注意:需要 App 端也同步呼叫 requestMtu(512) 才能生效
NimBLEDevice::setMTU(512);
// 建立 BLE Server(ESP32 扮演伺服器角色,等待手機連線)
NimBLEServer* pServer = NimBLEDevice::createServer();
// 註冊連線/斷線事件處理
pServer->setCallbacks(new ServerCallbacks());
// 建立 UART Service,並綁定上方定義的 UUID
NimBLEService* pService = pServer->createService(SERVICE_UUID);
// 建立 TX Characteristic(ESP32 → 手機)
// NOTIFY 屬性:ESP32 主動推送資料給手機,手機不需要輪詢
pTxChar = pService->createCharacteristic(
CHAR_UUID_TX,
NIMBLE_PROPERTY::NOTIFY
);
// 建立 RX Characteristic(手機 → ESP32)
// WRITE:手機可寫入資料;WRITE_NR(No Response):不等待回應,減少延遲
NimBLECharacteristic* pRxChar = pService->createCharacteristic(
CHAR_UUID_RX,
NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR
);
// 把收到資料時的處理邏輯綁定到 RX Characteristic
pRxChar->setCallbacks(new RxCallbacks());
// 啟動 Service
pService->start();
// 設定廣播內容(NimBLE 2.x 需要手動指定,否則掃描時顯示 unnamed)
NimBLEAdvertising* pAdvertising = NimBLEDevice::getAdvertising();
pAdvertising->setName("傑森創工ESP32-BLE"); // 手機掃描時看到的裝置名稱
pAdvertising->addServiceUUID(SERVICE_UUID); // 讓 App 自動識別為 UART 裝置
// 開始廣播,讓附近的手機能搜尋到這台裝置
NimBLEDevice::startAdvertising();
Serial.println("BLE 廣播中,等待手機連線...");
Serial.println("裝置名稱:傑森創工ESP32-BLE");
// 印出本機 MAC 位址,方便在 App 掃描清單中確認是哪台裝置
Serial.print("MAC 位址:");
Serial.println(NimBLEDevice::getAddress().toString().c_str());
}
void loop() {
// 本範例所有邏輯都在回呼函式中處理
// loop() 只需要 delay 避免 CPU 空轉
delay(10);
}
程式重點說明:
NimBLEDevice::setMTU(512)— 協商傳輸封包大小至 512 bytes,預設只有 20 bytes 可用,傳中文或 JSON 容易截斷,務必設定。App 端也需要同步呼叫requestMtu(512)NimBLEDevice::init("傑森創工ESP32-BLE")— 設定藍牙裝置名稱,手機掃描時會看到這個名稱NIMBLE_PROPERTY::NOTIFY— TX 用 Notify,ESP32 主動推送,手機不需要輪詢NIMBLE_PROPERTY::WRITE_NR— Write Without Response,減少延遲onDisconnect內的startAdvertising()— 斷線後自動重啟廣播,不需要按 Reset
程式執行後,就可以在監控窗看到BLE在進行廣播囉!

七、手機 App 操作說明
LightBlue
操作步驟iOS和Android完全相同:
- 開啟 App,點「Scan」搜尋裝置
- 找到「傑森創工ESP32-BLE」後點「Connect」
- 找到 UUID 開頭為
6E400001的 Service - 點 TX Characteristic(
6E400003)→ 點「Listen for notifications」開啟通知 - 點 RX Characteristic(
6E400002)→ 點「Write new value」 - 選擇「UTF-8 String」,輸入
1→ 點「Write」→ LED 亮 - 再次輸入
0→ 點「Write」→ LED 滅




範例二:進階版 — 雙向傳輸 + 定時推送溫濕度
加入 DHT11,每 5 秒自動推送溫濕度到手機,同時保留指令控制 LED 功能。
需要額外安裝的函式庫:
- DHT sensor library by Adafruit(函式庫管理員搜尋安裝)
#include <NimBLEDevice.h>
#include <DHT.h> // 需安裝「DHT sensor library by Adafruit」
#define SERVICE_UUID "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"
#define CHAR_UUID_RX "6E400002-B5A3-F393-E0A9-E50E24DCCA9E"
#define CHAR_UUID_TX "6E400003-B5A3-F393-E0A9-E50E24DCCA9E"
#define LED_PIN 2 // NodeMCU-32S 內建 LED,Active Low(LOW 亮、HIGH 滅)
#define DHT_PIN 4 // DHT11 資料腳接 GPIO4
#define DHT_TYPE DHT11
DHT dht(DHT_PIN, DHT_TYPE); // 建立 DHT11 物件
NimBLECharacteristic* pTxChar = nullptr;
bool deviceConnected = false;
unsigned long lastSend = 0;
const unsigned long SEND_INTERVAL = 5000; // 自動推送間隔:5000 毫秒 = 5 秒
// 前向宣告:讓 RxCallbacks 類別內部能呼叫這個函式
// 因為函式本體定義在類別後面,不宣告的話編譯器會報錯
void sendSensorData();
class ServerCallbacks : public NimBLEServerCallbacks {
void onConnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo) override {
deviceConnected = true;
Serial.println("手機已連線");
}
void onDisconnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo, int reason) override {
deviceConnected = false;
Serial.println("手機已斷線,重新廣播中...");
NimBLEDevice::startAdvertising();
}
};
class RxCallbacks : public NimBLECharacteristicCallbacks {
void onWrite(NimBLECharacteristic* pChar, NimBLEConnInfo& connInfo) override {
std::string value = pChar->getValue();
if (value.empty()) return;
char cmd = value[0];
if (cmd == '1') {
digitalWrite(LED_PIN, LOW); // Active Low:LOW = 亮
if (deviceConnected) { pTxChar->setValue("LED 已開啟\n"); pTxChar->notify(); }
} else if (cmd == '0') {
digitalWrite(LED_PIN, HIGH); // Active Low:HIGH = 滅
if (deviceConnected) { pTxChar->setValue("LED 已關閉\n"); pTxChar->notify(); }
} else if (cmd == 't') {
// 收到 't' 指令:立即讀取並推送一次溫濕度
sendSensorData();
}
}
};
// 讀取 DHT11 並透過 BLE 推送給手機
void sendSensorData() {
if (!deviceConnected) return; // 沒有手機連線就不做任何事
float temp = dht.readTemperature(); // 讀取攝氏溫度
float humi = dht.readHumidity(); // 讀取濕度(%)
// isnan() 判斷讀取是否失敗(DHT11 讀取失敗時會回傳 NaN)
if (isnan(temp) || isnan(humi)) {
pTxChar->setValue("感測器讀取失敗\n");
} else {
// snprintf 把浮點數格式化成字串,%.1f 代表保留一位小數
char buf[40];
snprintf(buf, sizeof(buf), "溫度:%.1f°C 濕度:%.1f%%\n", temp, humi);
pTxChar->setValue(buf);
}
// notify() 把資料主動推送給手機(手機不需要主動查詢)
pTxChar->notify();
}
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, HIGH); // 初始化:HIGH = 滅
dht.begin(); // 啟動 DHT11 感測器
NimBLEDevice::init("傑森創工ESP32-BLE");
NimBLEDevice::setMTU(512); // 協商 MTU 至 512 bytes,避免長字串傳輸截斷
NimBLEServer* pServer = NimBLEDevice::createServer();
pServer->setCallbacks(new ServerCallbacks());
NimBLEService* pService = pServer->createService(SERVICE_UUID);
// TX Characteristic:ESP32 主動推送資料給手機
pTxChar = pService->createCharacteristic(CHAR_UUID_TX, NIMBLE_PROPERTY::NOTIFY);
// RX Characteristic:接收手機傳來的指令
NimBLECharacteristic* pRxChar = pService->createCharacteristic(
CHAR_UUID_RX,
NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR
);
pRxChar->setCallbacks(new RxCallbacks());
pService->start();
// 設定廣播內容
NimBLEAdvertising* pAdvertising = NimBLEDevice::getAdvertising();
pAdvertising->setName("傑森創工ESP32-BLE");
pAdvertising->addServiceUUID(SERVICE_UUID);
NimBLEDevice::startAdvertising();
Serial.println("BLE 廣播中...");
Serial.print("MAC 位址:");
Serial.println(NimBLEDevice::getAddress().toString().c_str());
}
void loop() {
// millis() 回傳開機後經過的毫秒數
// 用「目前時間 - 上次傳送時間 >= 間隔」來計時,比 delay() 更好
// 因為 delay() 會讓整個程式暫停,millis() 計時法則不影響其他功能
if (deviceConnected && millis() - lastSend >= SEND_INTERVAL) {
lastSend = millis(); // 更新上次傳送的時間點
sendSensorData(); // 推送溫濕度
}
delay(10);
}
指令對照表:
| 傳送指令 | 動作 |
|---|---|
1 |
LED 亮,回傳「LED 已開啟」 |
0 |
LED 滅,回傳「LED 已關閉」 |
t |
立即回傳目前溫濕度 |
| (無需指令) | 每 5 秒自動推送溫濕度 |
LightBlue
操作步驟iOS和Android完全相同:
- 開啟 App,點「Scan」搜尋裝置
- 找到「傑森創工ESP32-BLE」後點「Connect」
- 找到 UUID 開頭為
6E400001的 Service - 點 TX Characteristic(
6E400003)→ 點「Subscribe」開啟通知 - 選擇「UTF-8 String」,之後就可以每隔5秒看到一次溫濕度囉!




九、常見問題
| 問題 | 可能原因 | 解法 |
|---|---|---|
| App 掃描不到裝置 | 程式未上傳 / 廣播未啟動 | 確認 Serial Monitor 顯示「BLE 廣播中」,按 RST 重啟 |
| iPhone 連線後沒有收到回傳 | TX Notify 未開啟 | LightBlue 點 TX Characteristic → 點「Subscribe」 |
| 傳送指令沒有反應 | App 傳的是 HEX 格式 | LightBlue 寫入時選「UTF-8 String」,不要選「Hex」 |
| 斷線後無法重新連線 | 廣播未重啟 | 確認 onDisconnect 有呼叫 NimBLEDevice::startAdvertising() |
| DHT11 顯示「感測器讀取失敗」 | 接線錯誤 / 讀取太快 | 確認 GPIO4 接線,DHT11 至少需要 2 秒才能再次讀取 |
| 編譯錯誤:找不到 NimBLEDevice | 函式庫未安裝 | 函式庫管理員搜尋「NimBLE-Arduino」安裝 |
傑森創工獨家ESP32藍牙APP快上架了!
雖然大家已經可以讓BLE和手機溝通了,但用這類APP也太工程FU了啦!不過要大家自行開發APP也太為難了。所以傑森決定自行開發,做一個方便用又美觀的APP,而且不綁專屬規格,大家一用就上手!
快好了!請大家留意FB的公告哦^^
