ESP32 BLE UART:iPhone 和 Android 都能用的無線控制教學

用 ESP32 NimBLE-Arduino 函式庫建立 BLE UART Service,透過標準 UUID 讓 iPhone 與 Android 同時相容。手機傳送指令控制 LED,ESP32 即時回傳確認訊息,並可定時推送 DHT11 溫濕度,實現真正跨平台的藍牙無線通訊。

ESP32 BLE UART:iPhone 和 Android 都能用的無線控制教學

你有沒有想過,用手機直接控制 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 相容版本 ESP32開發板 WiFi 藍牙 可用Arduino IDE
全腳位引出,還保持迷你的身型,插上麵包後還能插杜邦線,真的是太棒了! 和NodeMCU V2幾乎一樣尺寸! 有5V供電輸出,非常方便! 有了ESP32開發板,真的可以忘記原來的那些Arduino板子了! 可以用Arduino IDE開發,但效能更強大,還內建WiFi 傑森實測記錄,大家可以到 F 粉 絲 團 B 看貼文哦! ESP32-D0WDQ6 內置兩個低功耗 Xtensa® 32-bit LX6 MCU。片上存儲包括: • 448 KB 的 ROM,用於程序啟動和內核功能
DHT11模組 DHT-11 溫濕度感測器
產品內容 一、尺寸:長30.5mmX寬12mmX高7.2mm 二、傳感器型號: DHT11溫濕度傳感器 三、工作電壓:直流5V 四、特點: 1、濕度測量範圍:20---90%RH 2、濕度測量精度:±5%RH 3、溫度測量範圍:0---50℃ 4、溫度測量精度:±2℃ 5、工作電壓:DC5V/3.3V 6、數字信號輸出 7、數據端口帶上拉電阻 8、帶3mm固定螺絲孔,方便安裝 五、接線方法: VCC → 3.3V/5V電源正極 GND →電源負極 DATA →單片機IO口
ESP32S擴展板 適用於Nodemcu-32s 38Pin全引出
※ 不含ESP32S開發板,需另購! ESP32S專用擴展板 適用於Nodemcu-32s,其它型號都不相容哦!請留意。 38Pin全引出,無敵方便!

本篇使用 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 更簡潔,斷線重連邏輯更可靠
  • 社群目前主流推薦

安裝步驟:

  1. Arduino IDE → 工具 → 管理函式庫
  2. 搜尋「NimBLE-Arduino」
  3. 安裝 NimBLE-Arduino by h2zero(選最新版本)
版本注意:本教學程式碼適用 NimBLE-Arduino 2.x(目前最新版)。若你安裝的是舊版 1.x,回呼函式的簽名不同,onConnectonDisconnectonWrite 的參數列會少一個 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完全相同:

  1. 開啟 App,點「Scan」搜尋裝置
  2. 找到「傑森創工ESP32-BLE」後點「Connect」
  3. 找到 UUID 開頭為 6E400001 的 Service
  4. TX Characteristic6E400003)→ 點「Listen for notifications」開啟通知
  5. RX Characteristic6E400002)→ 點「Write new value」
  6. 選擇「UTF-8 String」,輸入 1 → 點「Write」→ LED 亮
  7. 再次輸入 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完全相同:

  1. 開啟 App,點「Scan」搜尋裝置
  2. 找到「傑森創工ESP32-BLE」後點「Connect」
  3. 找到 UUID 開頭為 6E400001 的 Service
  4. TX Characteristic6E400003)→ 點「Subscribe」開啟通知
  5. 選擇「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的公告哦^^

傑森創工 - 網路商店 - Arduino、ESP32的專家,創客的好朋友
傑森創工 JMaker Workshop 專注於Arduino、樹莓派(Raspberry Pi)、物聯網、創客(Maker)相關商品的研究,專業銷售各種電子材料、開發板、Arduino套件、感測器模組,以及各類工具。更提供許多獨家的專題套件,供大學或高中職學生製作專題。為台中最專業的Arduino供應商。