가끔 보자, 하늘.

ESP32에서 BLE 서버 구축하기: PlatformIO와 ESP32-PICO-DevKitM-2 활용 가이드 본문

개발 이야기/개발 및 서비스

ESP32에서 BLE 서버 구축하기: PlatformIO와 ESP32-PICO-DevKitM-2 활용 가이드

가온아 2025. 6. 3. 09:00

2025.05.19 - [개발 이야기/개발 및 서비스] - ESP32 IDF + VSCode 환경 구축

2025.05.31 - [개발 이야기/개발 및 서비스] - ESP32와 Flutter간 Bluetooth 통신

물론입니다! ESP32-PICO-DevKitM-2 보드와 ESP-Prog 디버거를 활용하여 PlatformIO에서 BLE(Bluetooth Low Energy) 서버를 구축하는 과정을 블로그 포스팅 형식으로 상세하게 정리해 드릴게요. 단순 연결을 넘어, 데이터 전송 및 응답의 기본 구조까지 다룹니다.


ESP32에서 BLE 서버 구축하기: PlatformIO와 ESP32-PICO-DevKitM-2 활용 가이드

안녕하세요! 오늘은 ESP32-PICO-DevKitM-2 보드를 사용하여 BLE(Bluetooth Low Energy) 서버를 구축하는 방법을 자세히 알아보겠습니다. 특히 PlatformIOVS Code Extension을 활용하여 프로젝트를 생성하고, 기본적인 BLE 연결 및 데이터 전송을 구현하는 과정을 단계별로 안내해 드릴게요. IoT 장치 개발이나 스마트 디바이스 연동을 꿈꾸시는 분들께 유용한 정보가 될 것입니다.


1. 프로젝트 초기 설정: PlatformIO로 시작하기

가장 먼저, PlatformIO를 사용하여 새로운 프로젝트를 생성해야 합니다. VS Code의 PlatformIO 확장 기능을 활용하면 명령줄에서 손쉽게 프로젝트를 초기화할 수 있습니다.

1.1. 프로젝트 폴더 생성

BLE 서버 프로젝트를 위한 새로운 디렉토리를 만들어 작업 공간을 정리합니다. VS Code 내장 터미널을 열고 다음 명령어를 입력하세요.

mkdir ble_sample
cd ble_sample

1.2. PlatformIO 프로젝트 초기화

이제 ble_sample 디렉토리 안에서 PlatformIO 프로젝트를 초기화합니다. 이때 사용할 보드(esp32-pico-devkitm-2)와 IDE(vscode)를 명시해줍니다.

pio project init --board esp32-pico-devkitm-2 --ide vscode

이 명령어를 실행하면 PlatformIO가 자동으로 필요한 파일과 폴더 구조를 생성해줍니다. 주요 생성 파일은 다음과 같습니다.

  • .pio/: PlatformIO 빌드 관련 파일 및 임시 파일
  • lib/: 라이브러리 추가 폴더
  • src/: 소스 코드 폴더 (여기에 main.cpp 파일을 생성할 것입니다)
  • platformio.ini: 프로젝트 설정 파일 (가장 중요!)
  • .gitignore: Git 버전 관리 시 무시할 파일 설정
  • test/: 테스트 코드 폴더

2. platformio.ini 파일 설정

생성된 platformio.ini 파일을 열어 프로젝트 환경을 정확하게 설정해야 합니다. 특히 ESP32-PICO-DevKitM-2 보드와 ESP-Prog 디버거를 사용하는 경우, 다음 설정을 반영해주세요.

[env:esp32-pico-devkitm-2]
platform = espressif32
board = esp32-pico-devkitm-2
framework = arduino              ; <-- 이 부분이 중요합니다! Arduino 프레임워크 사용
build_flags = -D CONFIG_IDF_TARGET_ESP32_PICO_DEVKITM_2
build_type = debug
upload_protocol = esp-prog       ; <-- ESP-Prog 디버거 사용 설정
debug_tool = esp-prog            ; <-- 디버깅 툴 설정
monitor_speed = 115200           ; 시리얼 모니터 속도

핵심 설정 설명:

  • board = esp32-pico-devkitm-2: 사용하시는 보드 모델을 정확히 지정합니다.
  • framework = arduino: BLE 기능을 사용하려면 Arduino 프레임워크를 명시해야 합니다. ESP32의 BLE 라이브러리(BLEDevice.h 등)는 Arduino 코어에 포함되어 있습니다.
  • upload_protocol = esp-prog, debug_tool = esp-prog: ESP-Prog 보드를 통해 펌웨어를 업로드하고 디버깅하기 위한 설정입니다. 이 설정을 통해 USB-UART 변환기 없이 ESP-Prog의 JTAG/SWD 기능을 활용할 수 있습니다.
  • build_flags = -D CONFIG_IDF_TARGET_ESP32_PICO_DEVKITM_2: 특정 ESP-IDF 타겟을 명시하는 빌드 플래그입니다. Arduino 프레임워크를 사용하더라도, 하위 레벨에서 ESP-IDF 설정을 맞추는 데 도움이 될 수 있습니다.

3. src/main.cpp BLE 서버 코드 작성

이제 src 폴더 안에 main.cpp 파일을 생성하고 BLE 서버 코드를 작성합니다. 이 코드는 ESP32가 BLE 장치로 광고하고, 클라이언트의 연결을 받아들이며, 주기적으로 데이터를 전송하는 간단한 서버 역할을 합니다.

#include <Arduino.h>
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h> // BLE Descriptor for notifications/indications

// BLE Service and Characteristic UUIDs
// IMPORTANT: For real-world applications, generate unique UUIDs!
// Use a UUID generator like https://www.uuidgenerator.net/
#define SERVICE_UUID        "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8"

// Global BLE objects
BLEServer* pServer = NULL;
BLECharacteristic* pCharacteristic = NULL;

// Connection status flags
bool deviceConnected = false;
bool oldDeviceConnected = false;

// Data counter for notifications
uint32_t value = 0;

// --- BLE Server Callbacks ---
// This class handles connection and disconnection events
class MyServerCallbacks: public BLEServerCallbacks {
    void onConnect(BLEServer* pServer) {
      deviceConnected = true;
      Serial.println("*** CLIENT CONNECTED ***");
      // Stop advertising to prevent multiple connections if only one is desired
      // pServer->getAdvertising()->stop();
    };

    void onDisconnect(BLEServer* pServer) {
      deviceConnected = false;
      Serial.println("*** CLIENT DISCONNECTED ***");
      // Allow a small delay for BT stack to settle, then restart advertising
      delay(500); 
      pServer->startAdvertising();
      Serial.println("Restarted advertising after disconnect.");
    }
};

// --- Setup Function ---
// Initializes the ESP32 and sets up the BLE server
void setup() {
  Serial.begin(115200);
  delay(1000); // Give time for Serial to initialize

  Serial.println("=== ESP32 BLE Server Starting ===");
  Serial.print("Free heap: ");
  Serial.println(ESP.getFreeHeap());

  // 1. Create the BLE Device
  Serial.println("1. Initializing BLE Device...");
  BLEDevice::init("ESP32 BLE Test"); // Set the local device name
  Serial.println("   BLE Device initialized successfully");

  // 2. Create the BLE Server
  Serial.println("2. Creating BLE Server...");
  pServer = BLEDevice::createServer();
  if (pServer == NULL) {
    Serial.println("   ERROR: Failed to create BLE Server!");
    return; // Halt if server creation fails
  }
  pServer->setCallbacks(new MyServerCallbacks()); // Register connection callbacks
  Serial.println("   BLE Server created successfully");

  // 3. Create the BLE Service
  Serial.println("3. Creating BLE Service...");
  BLEService *pService = pServer->createService(SERVICE_UUID);
  if (pService == NULL) {
    Serial.println("   ERROR: Failed to create BLE Service!");
    return; // Halt if service creation fails
  }
  Serial.print("   Service UUID: ");
  Serial.println(SERVICE_UUID);

  // 4. Create a BLE Characteristic within the service
  Serial.println("4. Creating BLE Characteristic...");
  pCharacteristic = pService->createCharacteristic(
                      CHARACTERISTIC_UUID,
                      BLECharacteristic::PROPERTY_READ   |  // Client can read its value
                      BLECharacteristic::PROPERTY_WRITE  |  // Client can write to it
                      BLECharacteristic::PROPERTY_NOTIFY |  // Server can notify clients of value changes
                      BLECharacteristic::PROPERTY_INDICATE    // Server can indicate value changes (more reliable than notify)
                    );
  if (pCharacteristic == NULL) {
    Serial.println("   ERROR: Failed to create BLE Characteristic!");
    return; // Halt if characteristic creation fails
  }
  Serial.print("   Characteristic UUID: ");
  Serial.println(CHARACTERISTIC_UUID);

  // Add a 2902 descriptor to enable notifications/indications
  // This is crucial for clients to subscribe to updates.
  pCharacteristic->addDescriptor(new BLE2902());
  Serial.println("   BLE2902 Descriptor added successfully (for Notify/Indicate)");

  // 5. Start the BLE Service
  Serial.println("5. Starting BLE Service...");
  pService->start();
  Serial.println("   BLE Service started successfully");

  // 6. Start BLE Advertising
  Serial.println("6. Starting BLE Advertising...");
  BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
  pAdvertising->addServiceUUID(SERVICE_UUID); // Advertise the service UUID
  pAdvertising->setScanResponse(false);       // Simple advertisement, no scan response for now
  pAdvertising->setMinPreferred(0x0);         // Set preferred connection interval (optional, 0x0 for default)

  try {
    BLEDevice::startAdvertising(); // Begin broadcasting advertisement packets
    Serial.println("   BLE Advertising started successfully");
    Serial.println("=== SETUP COMPLETE ===");
    Serial.println("Device name: ESP32 BLE Test");
    Serial.println("Status: Waiting for client connection...");
    Serial.print("MAC Address: ");
    Serial.println(BLEDevice::getAddress().toString().c_str());
  } catch (const std::exception& e) {
    Serial.print("   ERROR starting advertising: ");
    Serial.println(e.what()); // Print any exceptions during advertising start
  }
}

// --- Loop Function ---
// Main loop for continuous operation and data transmission
void loop() {
    static unsigned long lastHeartbeat = 0;
    static int loopCount = 0;

    // Print status every 5 seconds for debugging
    if (millis() - lastHeartbeat > 5000) {
      Serial.print("[");
      Serial.print(millis()/1000);
      Serial.print("s] Status: ");
      Serial.print(deviceConnected ? "CONNECTED" : "DISCONNECTED");
      Serial.print(" | Loop count: ");
      Serial.print(loopCount);
      Serial.print(" | Free heap: ");
      Serial.println(ESP.getFreeHeap());
      lastHeartbeat = millis();
    }

    // If a client is connected, send data via notification
    if (deviceConnected) {
        // Send a 4-byte integer value
        pCharacteristic->setValue((uint8_t*)&value, 4); 
        pCharacteristic->notify(); // Notify the connected client(s)

        if (value % 100 == 0) { // Print every 100 values to avoid spamming serial
          Serial.print("Notified client with value: ");
          Serial.println(value);
        }
        value++; // Increment value for next notification
        delay(3); // Small delay to prevent overwhelming the BLE stack
    }

    // If device was connected but now disconnected, restart advertising
    if (!deviceConnected && oldDeviceConnected) {
        delay(500); // Give some time for the stack to clean up
        Serial.println("Client disconnected. Restarting advertising...");
        pServer->startAdvertising(); // Re-enable advertising for new connections
        Serial.println("Advertising restarted.");
        oldDeviceConnected = deviceConnected; // Update old state
    }

    // If device just connected, log it
    if (deviceConnected && !oldDeviceConnected) {
        Serial.println("New client connected. Starting continuous notifications...");
        oldDeviceConnected = deviceConnected; // Update old state
    }

    loopCount++; // Increment loop counter
    delay(10); // General delay to prevent busy-looping
}

코드 분석: BLE 서버의 핵심 동작

위 코드는 ESP32가 BLE 서버로 작동하기 위한 필수 구성 요소를 포함하고 있습니다.

  1. 헤더 파일 포함:
    • Arduino.h: ESP32의 기본 Arduino 기능
    • BLEDevice.h, BLEServer.h, BLEUtils.h, BLE2902.h: flutter_blue 패키지와 마찬가지로, ESP32 Arduino 환경에서 BLE 기능을 제공하는 핵심 라이브러리입니다. 특히 BLE2902.h는 클라이언트가 서버의 특성(Characteristic) 변경을 알림(Notify) 받거나 확인(Indicate) 받을 수 있도록 해주는 Descriptor를 추가하는 데 사용됩니다.
  2. UUID 정의:
    • SERVICE_UUIDCHARACTERISTIC_UUID: BLE 서비스와 특성의 고유 식별자입니다. 이 UUID들을 통해 클라이언트 장치는 ESP32 BLE 서버를 찾아 통신할 수 있습니다. 반드시 https://www.uuidgenerator.net/ 같은 도구를 사용하여 고유한 UUID를 생성하여 사용하세요. 예제에서는 임시 UUID를 사용했습니다.
  3. MyServerCallbacks 클래스:
    • onConnect(): 클라이언트가 ESP32 BLE 서버에 성공적으로 연결될 때 호출됩니다. deviceConnected 플래그를 true로 설정하여 연결 상태를 관리합니다.
    • onDisconnect(): 클라이언트가 서버에서 연결을 해제할 때 호출됩니다. deviceConnected 플래그를 false로 설정하고, 다시 광고를 시작하여 다른 클라이언트가 연결할 수 있도록 합니다.
  4. setup() 함수:
    • BLE 장치 초기화: BLEDevice::init("ESP32 BLE Test")를 통해 ESP32의 BLE 기능을 활성화하고, 다른 장치에서 검색될 때 표시될 이름을 "ESP32 BLE Test"로 설정합니다.
    • BLE 서버 생성: BLEDevice::createServer()를 호출하여 BLE 서버 객체를 생성하고, 위에서 정의한 MyServerCallbacks를 연결 콜백으로 등록합니다.
    • BLE 서비스 생성: pServer->createService(SERVICE_UUID)를 통해 특정 SERVICE_UUID를 가진 서비스를 생성합니다. BLE 통신의 기본 단위입니다.
    • BLE 특성 생성: pService->createCharacteristic(CHARACTERISTIC_UUID, ...)를 통해 서비스 내부에 특성을 생성합니다.
      • PROPERTY_READ: 클라이언트가 이 특성 값을 읽을 수 있게 합니다.
      • PROPERTY_WRITE: 클라이언트가 이 특성 값을 변경할 수 있게 합니다.
      • PROPERTY_NOTIFY: 서버(ESP32)가 특성 값 변경 시 클라이언트에게 알림을 보낼 수 있게 합니다.
      • PROPERTY_INDICATE: NOTIFY와 유사하지만, 클라이언트로부터 수신 확인 응답을 받습니다. NOTIFY보다 더 신뢰성 있는 전송 방식입니다.
    • Descriptor 추가: pCharacteristic->addDescriptor(new BLE2902());는 클라이언트가 알림/지시를 구독할 수 있도록 하는 표준 BLE Descriptor입니다. NOTIFYINDICATE 속성을 사용할 때 필수적입니다.
    • 서비스 및 광고 시작: pService->start()로 서비스를 활성화하고, BLEDevice::startAdvertising()으로 ESP32가 주변에 자신을 알리기 시작합니다. addServiceUUID()를 통해 광고 데이터에 서비스 UUID를 포함시켜 클라이언트가 특정 서비스를 쉽게 찾을 수 있도록 합니다.
  5. loop() 함수:
    • 상태 모니터링: 5초마다 현재 연결 상태, 루프 카운트, 사용 가능한 힙 메모리 정보를 시리얼 모니터로 출력하여 디버깅을 돕습니다.
    • 데이터 전송 (Notify): deviceConnectedtrue일 때, pCharacteristic->setValue()pCharacteristic->notify()를 사용하여 value 변수의 값을 주기적으로 클라이언트에게 알립니다. 클라이언트는 이 알림을 구독하여 실시간으로 데이터를 받을 수 있습니다.
    • 연결 해제 처리: !deviceConnected && oldDeviceConnected 조건을 통해 클라이언트가 연결 해제되었음을 감지하면, 다시 광고를 시작하여 새로운 연결을 기다립니다.
    • 새로운 연결 처리: deviceConnected && !oldDeviceConnected 조건을 통해 새로운 클라이언트가 연결되었음을 감지하고 메시지를 출력합니다.

4. 코드 빌드 및 업로드

PlatformIO를 통해 코드를 빌드하고 ESP32-PICO-DevKitM-2 보드에 업로드합니다.

  1. PlatformIO 빌드: VS Code 하단의 PlatformIO 툴바에서 'Build' (체크 표시 아이콘) 버튼을 클릭합니다.
  2. PlatformIO 업로드: 빌드가 성공하면 'Upload' (오른쪽 화살표 아이콘) 버튼을 클릭하여 ESP-Prog 보드를 통해 ESP32에 펌웨어를 업로드합니다.
  3. 시리얼 모니터 확인: 업로드 후 'Serial Monitor' (플러그 아이콘)를 열어 ESP32의 시리얼 출력을 확인합니다. BLE 서버의 초기화 과정과 현재 상태를 확인할 수 있습니다.

5. 결과 확인 및 테스트

ESP32에 펌웨어 업로드가 완료되고 실행되면, 이제 스마트폰의 BLE 스캐닝 앱(예: nRF Connect for Mobile (Android/iOS) 또는 LightBlue (iOS))을 사용하여 테스트할 수 있습니다.

  1. 장치 검색: 앱을 실행하고 주변 BLE 장치를 스캔합니다. "ESP32 BLE Test"라는 이름의 장치를 찾을 수 있을 것입니다.
  2. 연결: 해당 장치를 탭하여 연결합니다. ESP32의 시리얼 모니터에 "* CLIENT CONNECTED *" 메시지가 출력되는 것을 확인할 수 있습니다.
  3. 서비스 및 특성 확인: 연결되면 ESP32가 제공하는 서비스와 특성 목록을 볼 수 있습니다. 정의한 서비스(SERVICE_UUID)와 특성(CHARACTERISTIC_UUID)을 찾을 수 있습니다.
  4. 데이터 수신 (Notify 확인): CHARACTERISTIC_UUID 특성을 선택하고 "Enable notifications" 또는 "Subscribe" 옵션을 활성화합니다. 그러면 ESP32가 주기적으로 보내는 value (증가하는 숫자)가 앱에 표시되는 것을 실시간으로 확인할 수 있습니다.
  5. 데이터 전송 (Write 테스트 - 선택 사항): 이 예제 코드에는 onWrite 콜백이 명시적으로 구현되어 있지 않지만, PROPERTY_WRITE 속성을 주었으므로 클라이언트 앱에서 이 특성에 값을 쓸 수 있습니다. 만약 값을 쓴다면, ESP32 코드에 BLECharacteristicCallbacks를 추가하여 이벤트를 처리할 수 있습니다.

(* github link : https://github.com/blackwitch/pio_ble_test)


다음 단계: 데이터 송수신 처리 심화

현재 예제는 BLE 서버가 클라이언트에게 주기적으로 데이터를 전송(Notify)하는 기능에 초점을 맞추고 있습니다. 다음 포스팅에서는 클라이언트가 서버로 데이터를 전송(Write)하고, 서버가 이 데이터를 받아 특정 동작을 수행하는 양방향 통신 처리 과정을 더 자세히 다뤄보겠습니다.

이 가이드가 ESP32와 PlatformIO를 사용하여 BLE 서버 개발을 시작하는 데 도움이 되었기를 바랍니다. 궁금한 점이 있으시면 언제든지 질문해주세요!

반응형