# BLE Protocol Reference — VBT Device VesiScan Basic Android 앱이 VBT 디바이스(VBTFW0116+ 펌웨어)와 주고받는 BLE 명령/응답 전체 정리. 코드 위치는 모두 `app/src/main/java/com/example/medilightv2android/ble/` 기준. 작성일 / 검증 FW: 2026-06-05 / **VBTFW0116** (reb 신구조 적용) --- ## 1. 개요 — 통신 스택 ``` ┌─────────────────────────────────────────────────────────────┐ │ 앱 UI (ClinicalLive / PiezoMonitoring / Placement / Calib) │ └────────┬──────────────────────┬─────────────────────────────┘ │ │ │ TX (송신) │ RX (콜백) ▼ ▲ ┌─────────────────────────────────────────────────────────────┐ │ BleManager │ │ ├─ sendXxx() → CRC16.build → sendRaw → GATT write │ │ └─ processReceivedData() → prefix when 분기 │ │ ├─ "reb:" → piezoCollector.addPacket() │ │ ├─ "raa:" → piezoCollector.addPacket() │ │ ├─ "rim:" → imuCollector.parseRim() │ │ ├─ "rsn:" → batteryLevel 갱신 │ │ ├─ "rid:" → fw/hw/sn 파싱 │ │ └─ ... NIRS 분기 (rta/rsh/rqq/rag/rcj) │ └──────┬──────────────────────────────────────────────────────┘ │ │ CCCD notify ▼ ┌──────────────────────────────────────────────┐ │ Android BluetoothGatt │ │ Nordic UART Service │ │ ├─ TX char 6E400002-... (write w/o resp) │ │ └─ RX char 6E400003-... (notify) │ └──────────────────────────────────────────────┘ │ │ BLE 2.0+ Link Layer ▼ ┌──────────────────────────────────────────────┐ │ VBT Device (Nordic nRF52 + custom FW) │ └──────────────────────────────────────────────┘ ``` --- ## 2. BLE 연결 Lifecycle ### 2.1 UUID (Nordic UART Service) 파일: [`BleManager.kt:45-48`](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L45) | 역할 | UUID | |---|---| | Service | `6E400001-B5A3-F393-E0A9-E50E24DCCA9E` | | TX (앱→기기, write w/o response) | `6E400002-B5A3-F393-E0A9-E50E24DCCA9E` | | RX (기기→앱, notify) | `6E400003-B5A3-F393-E0A9-E50E24DCCA9E` | | CCCD (notify enable) | `00002902-0000-1000-8000-00805f9b34fb` | ### 2.2 연결 시퀀스 ``` 1. Scan → Advertising name 또는 service UUID 매칭 2. device.connectGatt(context, false, callback, TRANSPORT_LE) 3. onConnectionStateChange(STATE_CONNECTED) └─ requestMtu(247) 4. onMtuChanged(mtu=...) └─ gatt.discoverServices() 5. onServicesDiscovered └─ getService(SERVICE_UUID) ├─ txCharacteristic = getCharacteristic(TX_CHAR_UUID) ├─ rxCharacteristic = getCharacteristic(RX_CHAR_UUID) ├─ setCharacteristicNotification(rx, true) └─ rx.getDescriptor(CCCD).writeDescriptor(ENABLE_NOTIFICATION_VALUE) 6. onDescriptorWrite(status=SUCCESS, uuid=CCCD) ├─ requestConnectionPriority(CONNECTION_PRIORITY_HIGH) ← ★ throughput 핵심 ├─ isServiceReady.value = true ├─ AdcCsvLogger.newSession() ├─ BleForegroundService.start(context) ├─ startBatteryPolling() ← 즉시 msn? + 3s 재시도 + 30s 주기 └─ startWatchdog() ← 30s zombie 감지 7. mid? 1회 (FW/HW/SN 식별) — 사용자가 직접 호출 또는 화면 진입 시 ``` ### 2.3 MTU / Connection Priority - `requestMtu(247)` — Nordic 기기 권장 최대. 210B의 reb 한 패킷이 분할 없이 들어옴. - `CONNECTION_PRIORITY_HIGH` — peripheral 수락 시 connection interval ≈ 15ms. Central Link Layer ACK 속도 ↑ → FW SoftDevice TX queue 포화 빈도 ↓. - 두 협상 모두 디스커버리 + CCCD 활성화 이후에 진행. 너무 일찍 호출하면 reject. ### 2.4 자동 재연결 (silent) 파일: [`BleManager.kt:601` scheduleAutoReconnect](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L601) - 의도하지 않은 disconnect → `scheduleAutoReconnect()` 자동 호출 - 마지막 연결 디바이스 주소를 `SharedPreferences`에 저장 → scan + reconnect 시도 - UI banner는 표시하지 않음 (사내 피드백 반영) — 백그라운드만 동작 - 측정 중이었으면 측정 일시정지, 복구 시 사용자가 다시 시작 ### 2.5 Watchdog (좀비 감지) 파일: [`BleManager.kt` watchdogJob](../app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt) - 별도 스레드가 5초 tick 으로 RX 마지막 수신 시각 확인. - **2026-07-07**: watchdog timeout **15 → 25초** 연장. 실측 로그에서 재연결 후 첫 RX 가 14초 지연 후 도착하는 케이스 확인 — peripheral / OS BLE 스택의 좀비 회복 시간 허용. 15초로는 회복 직전에 forced reconnect 발동 → 무한 재연결 루프 문제. - Silence 10초~timeout 사이면 **heartbeat 발동**: 2026-07-08 부터 `msp` 대신 `sendImuFifoQuery()` (mim). `isMtbBusy` 시 skip. - 정리는 UI thread 로 위임 (`handler.post`) — watchdog thread 에서 직접 GATT close 하면 UI thread 의 sendRaw 와 race. ### 2.6 ⚠ 알려진 firmware freeze (VBTFW0121) — mtb queue overrun **증상**: `mtb` 응답 stream (reb×6 + raa + rim) 진행 중에 다른 명령 (`msn`, `mim`) 이 TX 로 끼어들면 firmware GATT queue 가 꼬여 이후 명령 응답이 실종. 특히 그 상태에서 `mls mode 0` 이 결정타가 되어 BLE advertising 까지 죽는 **완전 freeze** 실측 확인 (2026-07-07 11:26 세션 로그). **앱 측 3-layer 회피** (2026-07-08 커밋 9907931): **Layer 1 — BleManager gating (`isMtbBusy`)** ```kotlin val isMtbBusy: Boolean get() = piezoCollector.isMultiChannel && !piezoCollector.isComplete ``` - `batteryTimer` / `batteryRetryTimer`: `sendBatteryQuery` 앞에 skip - Watchdog silence heartbeat: `sendImuFifoQuery` 앞에 skip - 상위 (View 레이어) 도 `mim` 폴링 앞에서 `isMtbBusy` 체크 **Layer 2 — mtb 3초 timeout** - `sendMtb()` 후 3초 timeout runnable, `raa` 응답 오면 취소. - Timeout 시 `consecutiveMtbTimeouts` 증가 + `piezoCollector.reset()` / `imuCollector.reset()` 해제. **Layer 3 — UI 안내 + 자동 재연결** - `PlacementGuideView` 가 `consecutiveMtbTimeouts` 관찰. - 1~2회: `"기기 응답 지연"` 배너 - **3회 연속: `forceDisconnectAndReconnect()` 자동 호출** — 사용자가 앱을 방치해도 자동 복구. **Firmware 팀 리포트 대상**: VBTFW0121 의 `mls mode 0` handler 가 큐 스트레스 상태에서 취약. 앱 fix 로 회피는 되지만 근본은 firmware. --- ## 3. 명령 빌더 — CRC16-CCITT 파일: [`CRC16.kt`](../app/src/main/java/com/example/medilightv2android/ble/CRC16.kt) ``` Polynomial: 0x1021 Initial value: 0xFFFF CRC append: Little Endian 2 bytes (lo, hi) ``` ### 3.1 buildCommandASCII (가장 흔히 사용) ```kotlin fun buildCommandASCII(cmd: String, asciiParams: String = ""): ByteArray ``` - 형식: `"{cmd}?{ascii_params}"` + CRC (LE 2B) - 예: `buildCommandASCII("mtb", " ")` → `[0x6D, 0x74, 0x62, 0x3F, 0x20, CRC_LO, CRC_HI]` (7 bytes) - 파라미터 공백 1자는 펌웨어가 요구하는 separator (모든 ASCII 명령 공통) ### 3.2 buildCommandBE (Big Endian 정수 파라미터) ```kotlin fun buildCommandBE(cmd: String, params: IntArray): ByteArray ``` - 형식: `"{cmd}?"` + params × 2B (high, low) + CRC (LE 2B) - 예: `buildCommandBE("mta", intArrayOf(1))` → `mta?` + `[0x00, 0x01]` + CRC ### 3.3 buildCommand (Little Endian 정수 파라미터) ```kotlin fun buildCommand(cmd: String, params: IntArray): ByteArray ``` - 형식: `"{cmd}?"` + params × 2B (low, high) + CRC (LE 2B) - (현재 코드에서 직접 호출 위치 없음 — 호환성 유지) ### 3.4 verify ```kotlin fun verify(data: ByteArray): Boolean ``` - 응답의 마지막 2B를 CRC로 간주, 나머지로 다시 계산해 일치 여부 검증 - 현재 RX 파서는 **CRC 검증을 skip**하고 길이/prefix만 보고 처리 (성능 우선). 의심 케이스에선 디버그용으로 사용. --- ## 4. TX 명령 카탈로그 `sendRaw(...)` 는 write w/o response. txCharacteristic이 null이면 silently drop + log. | 명령 | 빌더 | Send 함수 | 응답 prefix | 용도 | |---|---|---|---|---| | `msr?` | ASCII " " | `disconnectAndUnbond()` 내부 ([L276](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L276)) | — | 본딩 wipe + reboot | | `mpa?` | BE [freq, cycles] | `sendPiezoPowerOn(freq=2, cycles=5)` ([L404](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L404)) | `rpa:` | Piezo power ON | | `mps?` | ASCII " " | `sendPiezoStop()` ([L408](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L408)) | — | Piezo stop | | `mec?` | BE [freq, delay, 140, cycles, 1, ch] | `sendBurst(...)` ([L412](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L412)) | `reb:` 단일 채널 | Single-channel burst | | `maa?` | ASCII " " | `sendAllChannels()` / `sendChannelsOnly()` ([L416, L513](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L416)) | reb×6 + raa | 6채널 sweep (도넛/placement) | | `mtb?` | ASCII " " | `sendMtb()` ([L430](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L430)) | reb×6 + raa + **rim** | 6채널 + IMU (★ Clinical) | | `mbb?` | ASCII " " | `sendFullMeasurement()` ([L480](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L480)) | rbb + reb×6 + raa | 측정 헤더(batt+temp+IMU) + 6채널 | | `mta?` | BE [0 or 1] | `sendNirsPowerOn()/Off()` ([L441/445](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L441)) | `rta:` | NIRS power | | `mqq?` | ASCII " " | `sendMqqQuery()` ([L449](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L449)) | `rqq:` | NIRS sensor activate | | `mag?` | ASCII " " | `sendMagQuery()` ([L453](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L453)) | `rag:` | NIRS gain calibration | | `mcj?` | ASCII " " | `sendMcjQuery()` ([L457](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L457)) | `rcj:` | NIRS MCJ raw | | `msn?` | BE [0] | `sendBatteryQuery()` ([L463](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L463)) | `rsn:` | 배터리 mV 조회 | | `mid?` | ASCII " " | `sendDeviceInfoQuery()` ([L467](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L467)) | `rid:` | FW/HW/Serial 조회 | | `mls?` | BE [state] | `sendLedMode(state)` ([L471](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L471)) | `rls:` | LED 모드 변경 | | ~~`msp?`~~ | ~~ASCII " "~~ | ~~`sendImuQuery()`~~ | `rsp:` | ⚠ **2026-07-08 완전 제거** — 신 firmware 는 `mim` 만 사용. `rsp:` 파서는 legacy 응답 대비 유지. | | `mim?` | ASCII " " | `sendImuFifoQuery()` | `rim:` (15 sample) | ★ IMU FIFO — piezo 무음, walking detector 시계열 확보용. `imuCollector.reset()` 을 먼저 호출하므로 mtb 진행 중 (isMtbBusy=true) 이면 caller 가 반드시 skip 해야 함 (mtb 의 rim 파괴 방지). | ### 4.1 maa / mtb / maa 송신 throttle (canSendMaa) 파일: [`BleManager.kt:494-509`](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L494) `maa?`, `mtb?`, `sendChannelsOnly()`는 한 채널 그룹 응답이 280~420ms 소요되므로 다음 가드를 통과해야만 송신: ``` 조건 동작 ───────────────────────────────────────────────── piezoCollector busy AND <3000ms 경과 → DROP (THROTTLED) piezoCollector busy AND ≥3000ms 경과 → FORCE (잔여 abandon) 정상 AND <600ms 경과 → DROP (THROTTLED) 정상 AND ≥600ms 경과 → ALLOW ``` → 600ms 미만 간격으로 호출하면 자동 무시. ClinicalLive의 600ms `delay` 와 정확히 일치. ### 4.2 자세한 송신 예 ```kotlin fun sendMtb(): Boolean { if (!canSendMaa("sendMtb")) return false // throttle lastMaaSentMs = System.currentTimeMillis() piezoCollector.startMultiChannel(6) // collector reset imuCollector.reset() sendRaw(CRC16.buildCommandASCII("mtb", " ")) // TX return true } ``` --- ## 5. RX 응답 카탈로그 `onCharacteristicChanged` → `processReceivedData(data)` → prefix 4글자 추출 → when 분기. 파일: [`BleManager.kt:986-1097`](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L986) | Prefix | 의미 | 패킷 길이 | 파싱 위치 | 콜백 | |---|---|---|---|---| | `rta:` `rta!` | NIRS power ON ack | — | [L991](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L991) | `onNirsPowerOnReceived` | | `rsh:` `rsh!` `rqq:` `rqq!` | Sensor activated | — | [L995](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L995) | `onNirsSensorActivated` | | `rag:` `rag!` | NIRS gain calibration | data.size - 6 = N×2 | [L999](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L999) | `onNirsMagReceived` | | `rcj:` `rcj!` | NIRS MCJ raw | — | [L1003](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1003) | `onNirsMcjReceived` | | `rpa:` | Piezo ack | — | [L1007](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1007) | `onPiezoDataReceived` | | `rer:` | Preliminary header | — | [L1011](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1011) | (로그만) | | `reb:` | Piezo ADC raw (1채널) | **210B** (신구조) | [L1014](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1014) | `piezoCollector.addPacket` | | `red:` | Piezo continuation | — | [L1022](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1022) | `piezoCollector.addPacket` | | `ree:` | Single-ch end | 8B | [L1026](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1026) | `piezoCollector.addPacket` | | `raa:` | **All-ch complete** marker | 8B | [L1026](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1026) | `piezoCollector.addPacket` → set finalize | | `rsn:` | Battery (mV) | ≥6B | [L1031](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1031) | `batteryLevel.value` 갱신 | | `rid:` | Device info (FW/HW/SN) | 가변 | [L1045](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1045) | `firmwareVersion/...value` 갱신 | | `rls:` | LED state | ≥5B | [L1067](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1067) | (로그만) | | `rxs:` | Cmd not supported (echo) | 가변 | [L1071](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1071) | (로그만) | | `rbb:` | Full measurement header | 22B | [L1075](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1075) | `onMbbHeaderReceived` + collector | | `rsp:` | IMU 단일 샘플 | ≥16B | [L1080](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1080) | `onImuReceived` | | `rim:` | **IMU 15 samples** | 188B | [L1086](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1086) | `imuCollector.parseRim` | | `???` | Unknown | — | [L1091](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1091) | hex 로그 + warn | ⚠️ prefix 매칭은 **`String(data, 0, 4, US_ASCII)`** 로 정확히 4글자 (`"reb:"`처럼 colon 포함). --- ## 6. Piezo 응답 상세 (reb / red / ree / raa) 파일: [`PiezoPacketCollector.kt`](../app/src/main/java/com/example/medilightv2android/ble/PiezoPacketCollector.kt) ### 6.1 reb 신구조 (210B, FW 2026-06-02+) ``` [tag 4B "reb:"][ch_session 1B][ch_num 1B][num_sample 2B][adc 200B][crc 2B] ↑0 ↑4 ↑5 ↑6 ↑8 ↑208 ``` - **ch_session** (byte 4): 0~255 순환. **동일 ch_session의 6개 reb = 한 measurement set** - **ch_num** (byte 5): 0~5. **채널 indexing은 도착 순서가 아닌 이 필드로** - **num_sample** (byte 6~7): repeat count (BE/LE 자동 감지) - **ADC data** (byte 8~207): 100 samples × 2B (BE/LE 자동 감지) - **CRC** (byte 208~209): LE ### 6.2 Endian 자동 감지 첫 채널(`ch_num == 0`)의 첫 ADC 샘플(byte 8~9)로 판별: ```kotlin val b8 = bytes[8] and 0xFF; val b9 = bytes[9] and 0xFF val firstSampleLE = b8 or (b9 shl 8) val firstSampleBE = (b8 shl 8) or b9 detectedBigEndian = when { firstSampleLE > 4095 && firstSampleBE <= 4095 -> true // 확실히 BE firstSampleBE > 4095 && firstSampleLE <= 4095 -> false // 확실히 LE else -> forceBigEndian // 둘 다 0..4095 → flag default (VBT=true) } ``` 12-bit ADC라 정상값은 0..4095. 한쪽이 이 범위를 벗어나면 그쪽 endian이 잘못된 해석. ### 6.3 측정 set 식별 + 무효 패킷 정책 | 상황 | 동작 | |---|---| | 새 ch_session 시작 (currentSetSession == null) | session 등록, channelResults reset | | ch_session 불일치 (set 진행 중 다른 session) | 이전 set 폐기, 새 session으로 시작 | | ch_num이 0..5 범위 밖 | `isSetDropped = true`, 그 reb 폐기 | | 같은 ch_num 중복 수신 | `isSetDropped = true`, 그 reb 폐기 | | raa: 도착 + `isSetDropped == true` | 콜백 미호출, 다음 set 대기 | | raa: 도착 + channelResults에 null 존재 | 콜백 미호출 (incomplete set) | | raa: 도착 + 6채널 모두 채워짐 | `onMultiChannelComplete([PiezoChannelData × 6])` 호출 | ### 6.4 raa 패킷 (set end marker) ``` [tag 4B "raa:"][state 2B][crc 2B] = 8B ``` state: 펌웨어 상태 코드. 현재 클라이언트는 사용 안 함 — 마커로만 처리. ### 6.5 red 패킷 (continuation) - 신구조에서는 210B reb 한 패킷에 다 들어와 사실상 미사용. - multi-channel 모드에서는 무시 (`onLog "red: ignored"`). - single-channel 모드(`mec?`)에서는 reb 데이터 분할 시 추가 데이터로 누적. ### 6.6 ree 패킷 (single-channel end) - multi-channel 모드에서는 무시. 신구조에서 채널 구분은 ch_num이 담당. - single-channel 모드에서는 `finishSingleChannel()` 호출 → `onComplete(PiezoEchoResult)`. --- ## 7. IMU 응답 상세 (rim) 파일: [`ImuPacketCollector.kt`](../app/src/main/java/com/example/medilightv2android/ble/ImuPacketCollector.kt) ### 7.1 패킷 구조 (188B) ``` [tag 4B "rim:"][num_sample 2B BE][imu_raw 180B][crc 2B] ↑0 ↑4 ↑6 ↑186 ``` `imu_raw` = N samples × 12B/sample, 모두 **BE int16**: ``` [Ax 2B][Ay 2B][Az 2B][Gx 2B][Gy 2B][Gz 2B] ``` 샘플 시간 순서: `samples[0]` = **oldest** (FIFO 순), `samples[N-1]` = **newest**. ⚠️ **labdb payload에서 `sensor.imu` 단일 필드는 마지막(newest) 샘플 사용** — 측정 순간 자세를 정확히 반영. (이전 버그: 첫 샘플 사용으로 약간 옛 자세 반영) ### 7.2 스케일링 ```kotlin private const val ACCEL_LSB_PER_G = 8192f // ±4g FSR private const val GYRO_LSB_PER_DPS = 65.536f // ±500 dps FSR ax = axRaw / ACCEL_LSB_PER_G // → g 단위 (1 = 중력 가속도) gx = gxRaw / GYRO_LSB_PER_DPS // → dps 단위 ``` ### 7.3 길이 검증 ```kotlin val numSamples = readBe16(data, 4) val payloadBytes = data.size - 6 - 2 // tag+num 빼고 CRC 빼고 val expectedBytes = numSamples * 12 if (payloadBytes < expectedBytes) { onLog("rim: payload short ...") return // 0 samples 처리 } ``` → 펌웨어가 num_sample=13으로 보고하면 13개만 파싱. 우리 파서는 잘림 없음. ### 7.4 알려진 quirk — 첫 사이클 IMU - 측정 시작 직후 첫 mtb 응답의 rim이 **13 samples**로 도착할 수 있음 (FW IMU FIFO가 아직 15개 미충전 상태) - 25 cycles 중 cycle 0만 13, 나머지는 15 - 분석 시 cycle 0 제외 또는 padding 처리. 측정 자체에는 영향 없음. --- ## 8. 디바이스 정보 (rid) 파일: [`BleManager.kt:1045-1066`](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1045) ### 8.1 패킷 구조 ``` [tag 4B "rid:"][ASCII payload 가변] ``` payload 예: `"VBTHW0100 VBT26040001 VBTFW0111"` (HW, Serial, FW 순서) ### 8.2 NULL byte sanitize (★ 중요) BLE 응답 끝에 0x00 padding이 붙는 경우가 있음. `String(US_ASCII).trim()`은 whitespace만 제거하고 NULL은 남김 → labdb upload 시 PostgreSQL JSONB가 0x00 거부 → HTTP 500. ```kotlin val info = String(data, 4, data.size - 4, US_ASCII) .replace(Regex("[\\x00-\\x1F\\x7F]"), " ") // C0 control + DEL → 공백 .trim() ``` ### 8.3 파싱 ```kotlin val parts = info.split(Regex("\\s+")).filter { it.isNotBlank() } val hw = parts.firstOrNull { it.startsWith("VBTHW") } ?: "" val fw = parts.firstOrNull { it.startsWith("VBTFW") } ?: "" val sn = parts.firstOrNull { it.startsWith("VBT") && !it.startsWith("VBTHW") && !it.startsWith("VBTFW") } ?: "" ``` `handler.post { ... }` 로 메인 스레드에서 `hardwareVersion / firmwareVersion / serialNumber` mutableState 갱신. --- ## 9. 배터리 (rsn) 파일: [`BleManager.kt:1031-1043`](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1031) ### 9.1 패킷 구조 ``` [tag 4B "rsn:"][millivolt 2B BE][...] ``` byte 4~5 = 배터리 전압 (mV) Big Endian. ### 9.2 mV → % 변환 (segmented curve) ```kotlin val millivolts = ((data[4] and 0xFF) shl 8) or (data[5] and 0xFF) val pct = when { millivolts <= 3500 -> 0 // 방전 millivolts <= 3700 -> ((millivolts - 3500) * 5 / 200) // 0~5% else -> 5 + (millivolts - 3700) * 95 / 400 // 5~100% }.coerceIn(0, 100) ``` ### 9.3 Hysteresis 가드 ```kotlin if (pct <= batteryLevel.value || batteryLevel.value == 0) { batteryLevel.value = pct } ``` - 새 값이 기존보다 크거나 같으면 무시 (노이즈 hysteresis) - 단 `batteryLevel.value == 0` 첫 응답은 항상 통과 - ⚠️ 충전 중 상승은 반영 안 됨 — 의도된 동작 ### 9.4 Polling 정책 (★ 2026-06-05 변경) 파일: [`BleManager.kt:517-565`](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L517) ``` 1. 연결 직후 즉시 1회 msn? 송신 2. 3초 후 batteryLevel.value == 0 이면 재송신 (max 2회 재시도) 3. 30초 주기 정상 polling ``` → 이전엔 "1회 송신만"이라 그 한 번이 큐 경합으로 손실되면 영원히 0%였음. --- ## 10. 측정 헤더 (rbb) 파일: [`BleManager.kt:1075-1078`](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1075) ``` [tag 4B "rbb:"][batt 2B][IMU 2B][temp 2B][crc 2B] = 22B ``` - `batt`: 배터리 mV (uint16 BE) - `IMU`: IMU 상태 (uint16 BE) — 의미는 펌웨어 정의 - `temp`: 온도 × 100 °C (uint16 BE). 변환: `temp / 100 = °C` - FW 내부 측정 방식 변경됨 (TMP235 ADC → IMU TEMP_DATA register, `T = 25 + raw/128`, 4× oversampling) — 클라이언트 포맷은 동일 `mbb?` 응답의 첫 패킷. 이후 reb×6 + raa 가 따라옴. 콜백: `onMbbHeaderReceived?.invoke(data)` + collector에 전달 (collector는 rbb를 skip 처리). --- ## 11. NIRS 응답 (rta / rsh / rqq / rag / rcj) | Prefix | 용도 | 콜백 | |---|---|---| | `rta:` `rta!` | NIRS power ON 완료 | `onNirsPowerOnReceived` | | `rsh:` `rsh!` | Sensor activated (calibration mode) | `onNirsSensorActivated` | | `rqq:` `rqq!` | Sensor activated (monitoring mode) | `onNirsSensorActivated` | | `rag:` `rag!` | Gain calibration 결과. `(data.size - 6) / 2`개 값 | `onNirsMagReceived(data)` | | `rcj:` `rcj!` | MCJ raw 패킷 — Vivamyo monitoring 데이터 | `onNirsMcjReceived(data)` | `!` 변형은 펌웨어 측 에러/특수 상태. 처리 자체는 `:` 와 동일하지만 디버그 로그에 prefix 그대로 기록. NIRS 자세한 파싱은 [`NirsManager`](../app/src/main/java/com/example/medilightv2android/managers/NirsManager.kt) 참고 (Vivamyo 화면 전용). --- ## 12. Pairing / Unpairing ### 12.1 Pairing 표준 GATT 연결만으로 끝. 별도 페어링 명령 없음. CCCD enable 완료 시 service ready. ### 12.2 Unpair (`disconnectAndUnbond()`) 파일: [`BleManager.kt:262-310`](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L262) ``` 1. isUserDisconnect = true ← auto-reconnect 차단 flag 2. cancelAutoReconnect() 3. stopWatchdog() 4. stopBatteryPolling() 5. BleForegroundService.stop(context) 6. connectionTimer cancel 7. sendRaw(buildCommandASCII("msr", " ")) ← 기기에 unbond + reboot 명령 8. (300ms 대기) ├─ gatt.disconnect() + gatt.close() ├─ characteristic/connectedDeviceName/batteryLevel 등 reset ├─ device.removeBond() (리플렉션) ← Android 본딩 정보 제거 └─ SharedPreferences "last_device_*" 제거 ``` → 기기와 앱 양측이 페어링/본딩 정보를 완전히 잃은 상태. 다음 사용 시 DeviceScan부터. --- ## 13. 알려진 quirk / Edge cases | 항목 | 원인 | 대응 | |---|---|---| | **첫 cycle IMU 13/15 samples** | FW FIFO 미충전 | 분석 시 cycle 0 제외 / payload는 그대로 저장 | | **rid: trailing NULL byte** | FW response padding | 클라이언트 [0x00-0x1F,0x7F] strip 필수 | | **연결 직후 배터리 0%** | msn? 1회 송신만 + 큐 경합으로 첫 응답 누락 | 즉시 1회 + 3s 재시도 + 30s polling으로 보강 | | **reb 신구조 미적용 펌웨어** | 구버전 단말 | 미지원. 현재는 신버전(210B) 전용 | | **maa/mtb 너무 빠른 연속 호출** | < 600ms 간격 | `canSendMaa()` 가드로 자동 drop | | **사용자가 모르게 측정 끊김** | 펌웨어 reboot / BLE 거리 이슈 | watchdog + scheduleAutoReconnect 백그라운드 처리 (UI banner는 표시 안 함) | | **Endian 잘못 감지** | 첫 ADC 샘플이 0..4095 양쪽 모두 가능한 값 | `forceBigEndian = true` (VBT 기본)로 fallback | | **rbb 22B 길이 검증** | 부분 수신 케이스 | length 가드 + onMbbHeaderReceived에서만 콜백 발사 | --- ## 14. 디버깅 가이드 ### 14.1 로그 태그 - `BleManager` — 연결/송신/큐 상태 - `LabdbClient` — REST 호출 payload + 응답 - `PiezoCollector` — 채널 set 진행 / drop 사유 - `ImuCollector` — rim sample count ### 14.2 ble.log (텍스트 디버그 로그) 파일: [`BleDebugLogger.kt`](../app/src/main/java/com/example/medilightv2android/services/BleDebugLogger.kt) 각 세션 폴더에 `ble.log` 자동 작성: ``` [12:34:56.123] RX reb (210B) session=2 ch=0 100samples [12:34:56.145] RX reb (210B) session=2 ch=1 100samples ... [12:34:56.310] RX raa (8B) all-ch complete [12:34:56.450] RX rim (188B) IMU 15 samples ``` set 무효 시 drop 사유까지 함께 기록되어 사후 분석 가능. ### 14.3 직접 점검 명령 - adb logcat 필터: `adb logcat -s BleManager PiezoCollector LabdbClient` - BLE 패킷 sniffing: nRF Connect 앱 또는 별도 sniffer --- ## 15. 빠른 참조 — 한 사이클 sequence (mtb) ``` T+0 App sendMtb() ├─ canSendMaa() 통과 확인 ├─ piezoCollector.startMultiChannel(6) ├─ imuCollector.reset() └─ TX: [m t b ? ] + CRC (7 bytes) T+~10 Device (mtb? 수신, 측정 시작) T+~80 Device reb (ch=0, session=N, 210B) ───────► piezoCollector.processRebPacket T+~110 Device reb (ch=1, session=N, 210B) ───────► (channelResults[1] = ...) T+~140 Device reb (ch=2, session=N, 210B) T+~170 Device reb (ch=3, session=N, 210B) T+~200 Device reb (ch=4, session=N, 210B) T+~230 Device reb (ch=5, session=N, 210B) T+~260 Device raa (8B) ──────────────────────────► piezoCollector.processEndPacket (6채널 다 채워짐 → 콜백) onMultiChannelComplete([Ch0..Ch5]) T+~290 Device rim (15 samples, 188B) ────────────► imuCollector.parseRim onComplete(samples) └─ pendingPiezo + imuSamples를 묶어 addCycle T+600 App delay(600 - 290 = 310ms) 끝 → 다시 sendMtb() (한 사이클 평균 280~420ms + 안전 마진 = 600ms 주기 안정) ``` 평균 한 사이클: 280~420ms. ClinicalLive 600ms 주기와 정확히 맞춤. --- ## 16. iOS 포팅 매핑 힌트 | Kotlin/Android | Swift/iOS | |---|---| | `BluetoothGatt` | `CBPeripheral` | | `BluetoothGattCharacteristic` | `CBCharacteristic` | | `gatt.connectGatt(...)` | `centralManager.connect(peripheral)` | | `gatt.discoverServices()` | `peripheral.discoverServices(nil)` | | `gatt.setCharacteristicNotification` + CCCD write | `peripheral.setNotifyValue(true, for:)` | | `gatt.requestMtu(247)` | iOS는 자동 협상 (최대 185 또는 ATT_MTU 결과 사용) | | `gatt.requestConnectionPriority(HIGH)` | iOS 직접 API 없음 — peripheral 측 connection interval 설정 의존 | | `txCharacteristic.value = data` + writeCharacteristic | `peripheral.writeValue(data, for:tx, type: .withoutResponse)` | | `onCharacteristicChanged(value)` | `peripheral(_:didUpdateValueFor:error:)` | | `setOnDescriptorWrite` | `peripheral(_:didUpdateNotificationStateFor:error:)` | | `device.removeBond` (리플렉션) | **없음** — 사용자에게 "Settings → Bluetooth → Forget Device" 안내 | | `mutableStateOf` | `@Published` / `@State` | --- **원본 소스**: `app/src/main/java/com/example/medilightv2android/ble/` - [`BleManager.kt`](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt) - [`CRC16.kt`](../app/src/main/java/com/example/medilightv2android/ble/CRC16.kt) - [`PiezoPacketCollector.kt`](../app/src/main/java/com/example/medilightv2android/ble/PiezoPacketCollector.kt) - [`ImuPacketCollector.kt`](../app/src/main/java/com/example/medilightv2android/ble/ImuPacketCollector.kt) **관련 문서**: [iOS Porting Clinical Measurement](iOS_PORTING_CLINICAL_MEASUREMENT.md), [labdb.md](../labdb.md)