방광 모형 테스트가 100% 통과하는 시점을 demo flavor로 동결하고, 모든
신규 작업은 dev에서만 진행하기 위한 빌드 분기 셋업.
gradle (app/build.gradle.kts):
- flavorDimensions: "channel"
- demo: applicationIdSuffix=".demo", versionNameSuffix="-demo",
BuildConfig.IS_DEMO=true, FLAVOR_LABEL="demo"
- dev: BuildConfig.IS_DEMO=false, FLAVOR_LABEL="dev"
- 두 flavor 동시 설치 가능 (applicationId 분리)
source set:
- src/demo/res/values/strings.xml — app_name "VesiScan Demo"
- src/dev/res/values/strings.xml — app_name "VesiScan Dev"
- src/demo/java/.gitkeep + src/dev/java/.gitkeep — 격리본 둘 위치 안내
운영 가이드: docs/FLAVOR_DEMO_STABLE.md
- Level 1 (BuildConfig 분기) vs Level 2 (source set 격리) 두 동결 방식
- 새 안정판 동결 시 git tag + src/demo/ 복사 절차
- 어떤 코드를 동결 후보로 둘지 (알고리즘/파라미터 권장, BLE/UI는 공유)
- 트러블슈팅 + 첫 동결 시점 권장 절차
.gitignore: /docs/ 제거 — 팀/iOS 개발자가 접근 가능하도록
- docs/BLE_PROTOCOL_REFERENCE.md
- docs/iOS_PORTING_CLINICAL_MEASUREMENT.md
- docs/FLAVOR_DEMO_STABLE.md 모두 신규 commit
검증:
./gradlew assembleDemoDebug + assembleDevDebug 둘 다 BUILD SUCCESSFUL
→ app/build/outputs/apk/demo/debug/app-demo-debug.apk
→ app/build/outputs/apk/dev/debug/app-dev-debug.apk
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
29 KiB
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)
| 역할 | 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:528+ watchdogJob
- 별도 스레드로 30초 주기 RX 마지막 수신 시간 확인
- N초 이상 무응답 + isConnected.value = true 라면 GATT 강제 reset → 재연결
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() (L475) |
rsp: |
IMU 단일 샘플 조회 |
4.1 maa / mtb / maa 송신 throttle (canSendMaa)
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 분기.
| 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)
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)
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)
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)
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 변경)
1. 연결 직후 즉시 1회 msn? 송신
2. 3초 후 batteryLevel.value == 0 이면 재송신 (max 2회 재시도)
3. 30초 주기 정상 polling
→ 이전엔 "1회 송신만"이라 그 한 번이 큐 경합으로 손실되면 영원히 0%였음.
10. 측정 헤더 (rbb)
[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) — 클라이언트 포맷은 동일
- FW 내부 측정 방식 변경됨 (TMP235 ADC → IMU TEMP_DATA register,
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())
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 (텍스트 디버그 로그)
각 세션 폴더에 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/