Files
VesiscanClinicalAndroid/docs/BLE_PROTOCOL_REFERENCE.md
T
dw.jang 1cf45df80c docs: 5개 md 일괄 sync (버전/날짜/package path/V2 6-stage/CH3 flicker)
- USER_GUIDE.md
  * versionCode 24 → 26, App 1.0.0-design → 1.2.0-demo
  * Last Updated 2026-05-26 → 2026-07-13
  * Recommended FW VBTFW0116 → VBTFW0120+ (mim FIFO 필수)
  * PinView.kt / AppState.kt link path com.example → com.medithings.vesiscan
- docs/BLE_PROTOCOL_REFERENCE.md
  * 작성일 2026-06-05 → 2026-07-13, 검증 FW VBTFW0116 → VBTFW0120+
  * package path 52건 com.example → com.medithings.vesiscan (모든 코드 링크 복구)
- docs/ALGORITHM_COMPARISON.md
  * 3-Stage 를 "V1 (일반 진입)" 로 명시
  * "V2 6-Stage Guide (AlignGuide4Stage)" 신규 섹션: phase 표 + CH3 flicker
    3-Layer (majority / relaxed / soft-hint) + Python replay 검증 요약
  * 헤더에 2026-07-13 update 표시
- docs/FLAVOR_DEMO_STABLE.md
  * example source path 표기 com/example/... → com/medithings/vesiscan/...
- VesiScan_Android_Pipeline_Summary.md
  * 남아있던 com/example/ 경로 1건 정리

기능 변화 없음. 문서-코드 일관성 확보 (기존 링크가 리네임 이후 broken 상태였음).
2026-07-13 15:28:43 +09:00

30 KiB
Raw Blame History

BLE Protocol Reference — VBT Device

VesiScan Basic Android 앱이 VBT 디바이스(VBTFW0120+ 펌웨어)와 주고받는 BLE 명령/응답 전체 정리. 코드 위치는 모두 app/src/main/java/com/medithings/vesiscan/ble/ 기준.

작성일 / 검증 FW: 2026-07-13 / VBTFW0120+ (mim FIFO + mtb queue overrun 3-layer guard 반영)


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

역할 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

  • 의도하지 않은 disconnect → scheduleAutoReconnect() 자동 호출
  • 마지막 연결 디바이스 주소를 SharedPreferences에 저장 → scan + reconnect 시도
  • UI banner는 표시하지 않음 (사내 피드백 반영) — 백그라운드만 동작
  • 측정 중이었으면 측정 일시정지, 복구 시 사용자가 다시 시작

2.5 Watchdog (좀비 감지)

파일: BleManager.kt watchdogJob

  • 별도 스레드가 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)

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

Polynomial:    0x1021
Initial value: 0xFFFF
CRC append:    Little Endian 2 bytes (lo, hi)

3.1 buildCommandASCII (가장 흔히 사용)

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 정수 파라미터)

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 정수 파라미터)

fun buildCommand(cmd: String, params: IntArray): ByteArray
  • 형식: "{cmd}?" + params × 2B (low, high) + CRC (LE 2B)
  • (현재 코드에서 직접 호출 위치 없음 — 호환성 유지)

3.4 verify

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) — 본딩 wipe + reboot
mpa? BE [freq, cycles] sendPiezoPowerOn(freq=2, cycles=5) (L404) rpa: Piezo power ON
mps? ASCII " " sendPiezoStop() (L408) — Piezo stop
mec? BE [freq, delay, 140, cycles, 1, ch] sendBurst(...) (L412) reb: 단일 채널 Single-channel burst
maa? ASCII " " sendAllChannels() / sendChannelsOnly() (L416, L513) reb×6 + raa 6채널 sweep (도넛/placement)
mtb? ASCII " " sendMtb() (L430) reb×6 + raa + rim 6채널 + IMU (★ Clinical)
mbb? ASCII " " sendFullMeasurement() (L480) rbb + reb×6 + raa 측정 헤더(batt+temp+IMU) + 6채널
mta? BE [0 or 1] sendNirsPowerOn()/Off() (L441/445) rta: NIRS power
mqq? ASCII " " sendMqqQuery() (L449) rqq: NIRS sensor activate
mag? ASCII " " sendMagQuery() (L453) rag: NIRS gain calibration
mcj? ASCII " " sendMcjQuery() (L457) rcj: NIRS MCJ raw
msn? BE [0] sendBatteryQuery() (L463) rsn: 배터리 mV 조회
mid? ASCII " " sendDeviceInfoQuery() (L467) rid: FW/HW/Serial 조회
mls? BE [state] sendLedMode(state) (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

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 자세한 송신 예

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

Prefix 의미 패킷 길이 파싱 위치 콜백
rta: rta! NIRS power ON ack — L991 onNirsPowerOnReceived
rsh: rsh! rqq: rqq! Sensor activated — L995 onNirsSensorActivated
rag: rag! NIRS gain calibration data.size - 6 = N×2 L999 onNirsMagReceived
rcj: rcj! NIRS MCJ raw — L1003 onNirsMcjReceived
rpa: Piezo ack — L1007 onPiezoDataReceived
rer: Preliminary header — L1011 (로그만)
reb: Piezo ADC raw (1채널) 210B (신구조) L1014 piezoCollector.addPacket
red: Piezo continuation — L1022 piezoCollector.addPacket
ree: Single-ch end 8B L1026 piezoCollector.addPacket
raa: All-ch complete marker 8B L1026 piezoCollector.addPacket → set finalize
rsn: Battery (mV) ≥6B L1031 batteryLevel.value 갱신
rid: Device info (FW/HW/SN) 가변 L1045 firmwareVersion/...value 갱신
rls: LED state ≥5B L1067 (로그만)
rxs: Cmd not supported (echo) 가변 L1071 (로그만)
rbb: Full measurement header 22B L1075 onMbbHeaderReceived + collector
rsp: IMU 단일 샘플 ≥16B L1080 onImuReceived
rim: IMU 15 samples 188B L1086 imuCollector.parseRim
??? Unknown — L1091 hex 로그 + warn

⚠️ prefix 매칭은 String(data, 0, 4, US_ASCII) 로 정확히 4글자 ("reb:"처럼 colon 포함).


6. Piezo 응답 상세 (reb / red / ree / raa)

파일: 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)로 판별:

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

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 스케일링

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 길이 검증

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

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.

val info = String(data, 4, data.size - 4, US_ASCII)
    .replace(Regex("[\\x00-\\x1F\\x7F]"), " ")    // C0 control + DEL → 공백
    .trim()

8.3 파싱

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

9.1 패킷 구조

[tag 4B "rsn:"][millivolt 2B BE][...]

byte 4~5 = 배터리 전압 (mV) Big Endian.

9.2 mV → % 변환 (segmented curve)

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 가드

if (pct <= batteryLevel.value || batteryLevel.value == 0) {
    batteryLevel.value = pct
}
  • 새 값이 기존보다 크거나 같으면 무시 (노이즈 hysteresis)
  • 단 batteryLevel.value == 0 첫 응답은 항상 통과
  • ⚠️ 충전 중 상승은 반영 안 됨 — 의도된 동작

9.4 Polling 정책 (★ 2026-06-05 변경)

파일: BleManager.kt:517-565

1. 연결 직후 즉시 1회 msn? 송신
2. 3초 후 batteryLevel.value == 0 이면 재송신 (max 2회 재시도)
3. 30초 주기 정상 polling

→ 이전엔 "1회 송신만"이라 그 한 번이 큐 경합으로 손실되면 영원히 0%였음.


10. 측정 헤더 (rbb)

파일: BleManager.kt:1075-1078

[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 참고 (Vivamyo 화면 전용).


12. Pairing / Unpairing

12.1 Pairing

표준 GATT 연결만으로 끝. 별도 페어링 명령 없음. CCCD enable 완료 시 service ready.

12.2 Unpair (disconnectAndUnbond())

파일: BleManager.kt:262-310

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

각 세션 폴더에 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/medithings/vesiscan/ble/

관련 문서: iOS Porting Clinical Measurement, labdb.md