BLE 상태 전송을 Provider 패턴으로 구성하기

BLEIoTEmbeddedFreeRTOS

IoT 디바이스의 모터, 센서, 다리 등 여러 서브시스템의 상태를 모바일 앱으로 전송해야 한다. 서브시스템마다 BLE characteristic을 두면 subscribe 관리가 복잡해진다. 하나의 characteristic에 모든 상태를 모아 10Hz로 전송하되, 각 서브시스템은 자기 영역만 채우는 Provider 패턴을 사용했다.

구조

┌─────────────────────────────────────────────────┐
│ 공유 버퍼 (최대 128바이트)                         │
│ [Common 17B][Sensors 7B][Motor 13B][Legs 6B]    │
│  offset 0    offset 17   offset 24   offset 37  │
└─────────────────────────────────────────────────┘
       ↑            ↑           ↑           ↑
   put_common   put_sensors  put_motor   put_legs

각 provider는 등록 시 offset과 length를 지정한다. 겹침이 발생하면 등록이 거부된다.

typedef struct {
  size_t offset;
  size_t length;
  device_status_provider_t fn;
} provider_slot_t;

esp_err_t device_status_register_provider(size_t offset, size_t length,
                                          device_status_provider_t provider) {
  // 겹침 검사
  for (size_t i = 0; i < s_provider_count; i++) {
    size_t existing_end = s_providers[i].offset + s_providers[i].length;
    if (!(offset + length <= s_providers[i].offset || offset >= existing_end)) {
      return ESP_ERR_INVALID_ARG;
    }
  }
  s_providers[s_provider_count++] = (provider_slot_t){offset, length, provider};
  return ESP_OK;
}

실제 Provider 등록

Ceily 디바이스의 경우:

esp_err_t ceily_device_status_init(void) {
  device_status_register_provider(0,  17, put_common_status);   // 공통
  device_status_register_provider(17,  7, put_sensors_status);  // ToF 센서
  device_status_register_provider(24, 13, put_motor_status);    // 서보 모터
  device_status_register_provider(37,  6, put_legs_status);     // 다리 관절
  return ESP_OK;
}

Common provider가 채우는 17바이트의 레이아웃:

offset크기내용
01Bmotion_state
11BMCU 온도 (int8, 섭씨)
21Bspeed_control_state
31Bposition_percentage
4-52Bposition_mm (uint16)
6-72Bremain_distance (uint16)
8-92Btorque (int16, mNm)
10-112Bcurrent_velocity
12-132Bcommand_velocity
141Bis_position_verified
151Bis_current_model_learning
161Bmotion_phase

다른 디바이스(Wally)는 동일한 Common 영역을 사용하되, 센서/모터 영역은 자체 provider로 등록한다. 바이트 레이아웃이 디바이스마다 다를 수 있으므로 모바일 앱은 디바이스 모델에 따라 파싱 로직을 분기한다.

전송

notification 태스크가 100ms마다 모든 provider를 호출해 버퍼를 채우고, 한 번에 전송한다.

static void notify_task(void* arg) {
  while (1) {
    if (s_subscribed && conn_handle != BLE_HS_CONN_HANDLE_NONE) {
      // 버퍼 초기화 후 모든 provider 호출
      memset(s_buffer, 0, s_total_length);
      for (size_t i = 0; i < s_provider_count; i++) {
        s_providers[i].fn(s_buffer, s_providers[i].offset, s_providers[i].length);
      }

      struct os_mbuf* om = ble_hs_mbuf_from_flat(s_buffer, s_total_length);
      if (om) ble_gattc_notify_custom(conn_handle, chr_handle, om);
    }
    vTaskDelay(pdMS_TO_TICKS(100));  // 10Hz
  }
}

collect_status()는 notify_task에서만 호출되므로 lock이 필요 없다. 단, 각 provider 함수가 읽는 데이터(모터 상태 등)는 다른 태스크에서 갱신될 수 있으므로, provider 함수 자체가 atomic read를 보장해야 한다.

장단점

장점:

  • 서브시스템 간 의존성 없음. 모터 모듈은 센서 모듈의 존재를 모른다
  • 모바일 앱은 하나의 characteristic만 subscribe하면 된다
  • 겹침 검사로 버퍼 레이아웃 오류를 등록 시점에 잡는다
  • provider 추가/제거가 다른 provider에 영향을 주지 않는다

단점:

  • 바이트 레이아웃을 펌웨어와 모바일 앱이 공유해야 한다. 레이아웃 변경 시 양쪽을 동시에 업데이트해야 한다
  • 특정 서브시스템의 상태만 필요해도 전체 버퍼가 전송된다
  • notification은 ACK가 없으므로 유실될 수 있다. 10Hz 스트림이라 다음 프레임에서 복구되지만, 정확한 전달이 필요한 데이터에는 부적합하다

대안 검토

서비스별 characteristic 분리:

  • 모터 characteristic, 센서 characteristic 등을 각각 만들고 개별 subscribe
  • 장점: 필요한 데이터만 수신 가능. 레이아웃 변경이 독립적
  • 단점: subscribe 수가 늘어나면 connection interval 내에 모든 notification을 보내지 못할 수 있다. iOS는 동시 notification 수에 제한이 있다
  • 이 프로젝트에서는 모바일 앱이 항상 전체 상태를 필요로 하므로 단일 characteristic이 적합했다

Protocol Buffers / CBOR:

  • 구조화된 직렬화 포맷으로 바이트 레이아웃을 자동 관리
  • 장점: 필드 추가/삭제가 하위 호환. 스키마 공유가 명확
  • 단점: 임베디드 환경에서 protobuf 라이브러리는 코드 크기와 메모리를 추가로 소모한다. 43바이트 고정 구조에 직렬화 오버헤드를 추가하는 것은 과도하다

Indication (ACK 있는 전송):

  • notification 대신 indication을 사용하면 전달 보장이 가능하다
  • 단점: indication은 ACK를 기다리므로 10Hz 전송이 불가능하다. connection interval이 30~50ms일 때, ACK 대기까지 포함하면 초당 10회 전송을 유지할 수 없다