60b630d3de
이번 세션 대량 변경 사항을 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>
620 lines
31 KiB
Markdown
620 lines
31 KiB
Markdown
# 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)
|