Files
VesiscanClinicalAndroid/docs/BLE_PROTOCOL_REFERENCE.md
T
dw.jang 60b630d3de docs: v7 (2026-07-10) 반영 — msp 제거, 6-stage alignment, mtb queue fix, labdb 자동 재시도
이번 세션 대량 변경 사항을 5개 문서에 반영. 각 문서마다 stale 이던
섹션을 갱신하거나 신규 섹션 추가.

USER_GUIDE.md
  - Sensor Alignment 를 V1 (3-stage) / V2 (6-stage) 로 재구성
  - V2 6-stage 표 + relaxed mode / soft hint 설명
  - GREEN 진입 5초 hold + 10-strike 리셋 완화 명시
  - "최적의 위치입니다!" 문구 반영

docs/BLE_PROTOCOL_REFERENCE.md
  - msp 명령 취소선 처리 + mim 신규 명령 문서화
  - Watchdog timeout 25초 연장 명시 (2.5)
  - §2.6 신규: firmware VBTFW0121 mls mode 0 freeze 취약점 +
    앱 측 3-layer 회피 (isMtbBusy / mtb 3초 timeout / 자동 재연결)

VesiScan_Android_Pipeline_Summary.md
  - v7 (2026-07-10) 섹션 신규 추가 — BLE / Alignment / UI / labdb /
    tools 5개 카테고리로 변경 사항 정리
  - 권장 펌웨어 표기 VBTFW0116 → VBTFW0120+ 로 갱신

labdb.md
  - §12b 신규: 앱 측 Auto Retry Policy — endMeasurement 자동 업로드
    조건 완화, LabdbAutoRetry object, UI 배너, 재시도 안전성

tools/README.md
  - labdb_upload.py 섹션 신규 — 사용법 / 폴더 구조 / 재실행 안전성 /
    buildPayload 로직 / 활용 예 정리

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-10 11:25:26 +09:00

620 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 ? <SP>] + 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)