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 | 크기 | 내용 |
|---|---|---|
| 0 | 1B | motion_state |
| 1 | 1B | MCU 온도 (int8, 섭씨) |
| 2 | 1B | speed_control_state |
| 3 | 1B | position_percentage |
| 4-5 | 2B | position_mm (uint16) |
| 6-7 | 2B | remain_distance (uint16) |
| 8-9 | 2B | torque (int16, mNm) |
| 10-11 | 2B | current_velocity |
| 12-13 | 2B | command_velocity |
| 14 | 1B | is_position_verified |
| 15 | 1B | is_current_model_learning |
| 16 | 1B | motion_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회 전송을 유지할 수 없다