diff --git a/.gitignore b/.gitignore index 4b210bf..a60a92b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ /local.properties /.idea/ .DS_Store -/docs/ /build /captures .externalNativeBuild diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f71d731..d5771d0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -29,6 +29,27 @@ android { ) } } + + // Product flavors — demo(동결 안정판) / dev(개발 진행). + // 자세한 운영 정책은 docs/FLAVOR_DEMO_STABLE.md 참고. + flavorDimensions += "channel" + productFlavors { + create("demo") { + dimension = "channel" + applicationIdSuffix = ".demo" + versionNameSuffix = "-demo" + buildConfigField("Boolean", "IS_DEMO", "true") + buildConfigField("String", "FLAVOR_LABEL", "\"demo\"") + // 시연/안정 검증용 — 방광 모형 테스트가 100% 통과하는 시점을 git tag로 동결. + // src/demo/* 하위에 동결 시점의 알고리즘/파라미터를 격리해 두면 main 변경에 영향받지 않음. + } + create("dev") { + dimension = "channel" + buildConfigField("Boolean", "IS_DEMO", "false") + buildConfigField("String", "FLAVOR_LABEL", "\"dev\"") + // 개발 진행 — 모든 신규 기능/실험/리팩토링은 dev에서만. + } + } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 diff --git a/app/src/demo/java/.gitkeep b/app/src/demo/java/.gitkeep new file mode 100644 index 0000000..ed5e2f0 --- /dev/null +++ b/app/src/demo/java/.gitkeep @@ -0,0 +1,10 @@ +# demo flavor source set +# +# 이 폴더에 있는 .kt 파일은 ONLY demo flavor 빌드 시에만 컴파일됩니다. +# main의 같은 패키지/클래스가 있어도 demo flavor에서는 이 폴더의 정의가 우선합니다. +# +# 용도: +# - 방광 모형 테스트가 100% 통과하는 시점의 알고리즘/파라미터를 격리해 동결. +# - 이후 src/main/ 코드가 변경되더라도 demo flavor 빌드는 영향받지 않음. +# +# 자세한 운영 정책: docs/FLAVOR_DEMO_STABLE.md diff --git a/app/src/demo/res/values/strings.xml b/app/src/demo/res/values/strings.xml new file mode 100644 index 0000000..84ae4e5 --- /dev/null +++ b/app/src/demo/res/values/strings.xml @@ -0,0 +1,3 @@ + + VesiScan Demo + diff --git a/app/src/dev/java/.gitkeep b/app/src/dev/java/.gitkeep new file mode 100644 index 0000000..73ff5b2 --- /dev/null +++ b/app/src/dev/java/.gitkeep @@ -0,0 +1,7 @@ +# dev flavor source set +# +# 이 폴더에 있는 .kt 파일은 ONLY dev flavor 빌드 시에만 컴파일됩니다. +# 일반 개발은 src/main/ 에서 진행하되, dev에서만 활성화하고 싶은 실험 코드가 +# 있다면 여기에 둘 수 있습니다. +# +# 자세한 운영 정책: docs/FLAVOR_DEMO_STABLE.md diff --git a/app/src/dev/res/values/strings.xml b/app/src/dev/res/values/strings.xml new file mode 100644 index 0000000..3c2c7d5 --- /dev/null +++ b/app/src/dev/res/values/strings.xml @@ -0,0 +1,3 @@ + + VesiScan Dev + diff --git a/docs/BLE_PROTOCOL_REFERENCE.md b/docs/BLE_PROTOCOL_REFERENCE.md new file mode 100644 index 0000000..fb3a936 --- /dev/null +++ b/docs/BLE_PROTOCOL_REFERENCE.md @@ -0,0 +1,579 @@ +# 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:528+` watchdogJob](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L528) + +- 별도 스레드로 30초 주기 RX 마지막 수신 시간 확인 +- N초 이상 무응답 + isConnected.value = true 라면 GATT 강제 reset → 재연결 + +--- + +## 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()` ([L475](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L475)) | `rsp:` | IMU 단일 샘플 조회 | + +### 4.1 maa / mtb / maa 송신 throttle (canSendMaa) + +파일: [`BleManager.kt:494-509`](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L494) + +`maa?`, `mtb?`, `sendChannelsOnly()`는 한 채널 그룹 응답이 280~420ms 소요되므로 다음 가드를 통과해야만 송신: + +``` +조건 동작 +───────────────────────────────────────────────── +piezoCollector busy AND <3000ms 경과 → DROP (THROTTLED) +piezoCollector busy AND ≥3000ms 경과 → FORCE (잔여 abandon) +정상 AND <600ms 경과 → DROP (THROTTLED) +정상 AND ≥600ms 경과 → ALLOW +``` + +→ 600ms 미만 간격으로 호출하면 자동 무시. ClinicalLive의 600ms `delay` 와 정확히 일치. + +### 4.2 자세한 송신 예 +```kotlin +fun sendMtb(): Boolean { + if (!canSendMaa("sendMtb")) return false // throttle + lastMaaSentMs = System.currentTimeMillis() + piezoCollector.startMultiChannel(6) // collector reset + imuCollector.reset() + sendRaw(CRC16.buildCommandASCII("mtb", " ")) // TX + return true +} +``` + +--- + +## 5. RX 응답 카탈로그 + +`onCharacteristicChanged` → `processReceivedData(data)` → prefix 4글자 추출 → when 분기. + +파일: [`BleManager.kt:986-1097`](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L986) + +| Prefix | 의미 | 패킷 길이 | 파싱 위치 | 콜백 | +|---|---|---|---|---| +| `rta:` `rta!` | NIRS power ON ack | — | [L991](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L991) | `onNirsPowerOnReceived` | +| `rsh:` `rsh!` `rqq:` `rqq!` | Sensor activated | — | [L995](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L995) | `onNirsSensorActivated` | +| `rag:` `rag!` | NIRS gain calibration | data.size - 6 = N×2 | [L999](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L999) | `onNirsMagReceived` | +| `rcj:` `rcj!` | NIRS MCJ raw | — | [L1003](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1003) | `onNirsMcjReceived` | +| `rpa:` | Piezo ack | — | [L1007](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1007) | `onPiezoDataReceived` | +| `rer:` | Preliminary header | — | [L1011](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1011) | (로그만) | +| `reb:` | Piezo ADC raw (1채널) | **210B** (신구조) | [L1014](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1014) | `piezoCollector.addPacket` | +| `red:` | Piezo continuation | — | [L1022](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1022) | `piezoCollector.addPacket` | +| `ree:` | Single-ch end | 8B | [L1026](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1026) | `piezoCollector.addPacket` | +| `raa:` | **All-ch complete** marker | 8B | [L1026](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1026) | `piezoCollector.addPacket` → set finalize | +| `rsn:` | Battery (mV) | ≥6B | [L1031](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1031) | `batteryLevel.value` 갱신 | +| `rid:` | Device info (FW/HW/SN) | 가변 | [L1045](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1045) | `firmwareVersion/...value` 갱신 | +| `rls:` | LED state | ≥5B | [L1067](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1067) | (로그만) | +| `rxs:` | Cmd not supported (echo) | 가변 | [L1071](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1071) | (로그만) | +| `rbb:` | Full measurement header | 22B | [L1075](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1075) | `onMbbHeaderReceived` + collector | +| `rsp:` | IMU 단일 샘플 | ≥16B | [L1080](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1080) | `onImuReceived` | +| `rim:` | **IMU 15 samples** | 188B | [L1086](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1086) | `imuCollector.parseRim` | +| `???` | Unknown | — | [L1091](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1091) | hex 로그 + warn | + +⚠️ prefix 매칭은 **`String(data, 0, 4, US_ASCII)`** 로 정확히 4글자 (`"reb:"`처럼 colon 포함). + +--- + +## 6. Piezo 응답 상세 (reb / red / ree / raa) + +파일: [`PiezoPacketCollector.kt`](../app/src/main/java/com/example/medilightv2android/ble/PiezoPacketCollector.kt) + +### 6.1 reb 신구조 (210B, FW 2026-06-02+) + +``` +[tag 4B "reb:"][ch_session 1B][ch_num 1B][num_sample 2B][adc 200B][crc 2B] + ↑0 ↑4 ↑5 ↑6 ↑8 ↑208 +``` + +- **ch_session** (byte 4): 0~255 순환. **동일 ch_session의 6개 reb = 한 measurement set** +- **ch_num** (byte 5): 0~5. **채널 indexing은 도착 순서가 아닌 이 필드로** +- **num_sample** (byte 6~7): repeat count (BE/LE 자동 감지) +- **ADC data** (byte 8~207): 100 samples × 2B (BE/LE 자동 감지) +- **CRC** (byte 208~209): LE + +### 6.2 Endian 자동 감지 +첫 채널(`ch_num == 0`)의 첫 ADC 샘플(byte 8~9)로 판별: + +```kotlin +val b8 = bytes[8] and 0xFF; val b9 = bytes[9] and 0xFF +val firstSampleLE = b8 or (b9 shl 8) +val firstSampleBE = (b8 shl 8) or b9 +detectedBigEndian = when { + firstSampleLE > 4095 && firstSampleBE <= 4095 -> true // 확실히 BE + firstSampleBE > 4095 && firstSampleLE <= 4095 -> false // 확실히 LE + else -> forceBigEndian // 둘 다 0..4095 → flag default (VBT=true) +} +``` + +12-bit ADC라 정상값은 0..4095. 한쪽이 이 범위를 벗어나면 그쪽 endian이 잘못된 해석. + +### 6.3 측정 set 식별 + 무효 패킷 정책 + +| 상황 | 동작 | +|---|---| +| 새 ch_session 시작 (currentSetSession == null) | session 등록, channelResults reset | +| ch_session 불일치 (set 진행 중 다른 session) | 이전 set 폐기, 새 session으로 시작 | +| ch_num이 0..5 범위 밖 | `isSetDropped = true`, 그 reb 폐기 | +| 같은 ch_num 중복 수신 | `isSetDropped = true`, 그 reb 폐기 | +| raa: 도착 + `isSetDropped == true` | 콜백 미호출, 다음 set 대기 | +| raa: 도착 + channelResults에 null 존재 | 콜백 미호출 (incomplete set) | +| raa: 도착 + 6채널 모두 채워짐 | `onMultiChannelComplete([PiezoChannelData × 6])` 호출 | + +### 6.4 raa 패킷 (set end marker) +``` +[tag 4B "raa:"][state 2B][crc 2B] = 8B +``` +state: 펌웨어 상태 코드. 현재 클라이언트는 사용 안 함 — 마커로만 처리. + +### 6.5 red 패킷 (continuation) +- 신구조에서는 210B reb 한 패킷에 다 들어와 사실상 미사용. +- multi-channel 모드에서는 무시 (`onLog "red: ignored"`). +- single-channel 모드(`mec?`)에서는 reb 데이터 분할 시 추가 데이터로 누적. + +### 6.6 ree 패킷 (single-channel end) +- multi-channel 모드에서는 무시. 신구조에서 채널 구분은 ch_num이 담당. +- single-channel 모드에서는 `finishSingleChannel()` 호출 → `onComplete(PiezoEchoResult)`. + +--- + +## 7. IMU 응답 상세 (rim) + +파일: [`ImuPacketCollector.kt`](../app/src/main/java/com/example/medilightv2android/ble/ImuPacketCollector.kt) + +### 7.1 패킷 구조 (188B) +``` +[tag 4B "rim:"][num_sample 2B BE][imu_raw 180B][crc 2B] + ↑0 ↑4 ↑6 ↑186 +``` + +`imu_raw` = N samples × 12B/sample, 모두 **BE int16**: +``` +[Ax 2B][Ay 2B][Az 2B][Gx 2B][Gy 2B][Gz 2B] +``` + +샘플 시간 순서: `samples[0]` = **oldest** (FIFO 순), `samples[N-1]` = **newest**. + +⚠️ **labdb payload에서 `sensor.imu` 단일 필드는 마지막(newest) 샘플 사용** — 측정 순간 자세를 정확히 반영. (이전 버그: 첫 샘플 사용으로 약간 옛 자세 반영) + +### 7.2 스케일링 +```kotlin +private const val ACCEL_LSB_PER_G = 8192f // ±4g FSR +private const val GYRO_LSB_PER_DPS = 65.536f // ±500 dps FSR + +ax = axRaw / ACCEL_LSB_PER_G // → g 단위 (1 = 중력 가속도) +gx = gxRaw / GYRO_LSB_PER_DPS // → dps 단위 +``` + +### 7.3 길이 검증 +```kotlin +val numSamples = readBe16(data, 4) +val payloadBytes = data.size - 6 - 2 // tag+num 빼고 CRC 빼고 +val expectedBytes = numSamples * 12 +if (payloadBytes < expectedBytes) { + onLog("rim: payload short ...") + return // 0 samples 처리 +} +``` + +→ 펌웨어가 num_sample=13으로 보고하면 13개만 파싱. 우리 파서는 잘림 없음. + +### 7.4 알려진 quirk — 첫 사이클 IMU +- 측정 시작 직후 첫 mtb 응답의 rim이 **13 samples**로 도착할 수 있음 (FW IMU FIFO가 아직 15개 미충전 상태) +- 25 cycles 중 cycle 0만 13, 나머지는 15 +- 분석 시 cycle 0 제외 또는 padding 처리. 측정 자체에는 영향 없음. + +--- + +## 8. 디바이스 정보 (rid) + +파일: [`BleManager.kt:1045-1066`](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1045) + +### 8.1 패킷 구조 +``` +[tag 4B "rid:"][ASCII payload 가변] +``` + +payload 예: `"VBTHW0100 VBT26040001 VBTFW0111"` (HW, Serial, FW 순서) + +### 8.2 NULL byte sanitize (★ 중요) +BLE 응답 끝에 0x00 padding이 붙는 경우가 있음. `String(US_ASCII).trim()`은 whitespace만 제거하고 NULL은 남김 → labdb upload 시 PostgreSQL JSONB가 0x00 거부 → HTTP 500. + +```kotlin +val info = String(data, 4, data.size - 4, US_ASCII) + .replace(Regex("[\\x00-\\x1F\\x7F]"), " ") // C0 control + DEL → 공백 + .trim() +``` + +### 8.3 파싱 +```kotlin +val parts = info.split(Regex("\\s+")).filter { it.isNotBlank() } +val hw = parts.firstOrNull { it.startsWith("VBTHW") } ?: "" +val fw = parts.firstOrNull { it.startsWith("VBTFW") } ?: "" +val sn = parts.firstOrNull { it.startsWith("VBT") && !it.startsWith("VBTHW") && !it.startsWith("VBTFW") } ?: "" +``` + +`handler.post { ... }` 로 메인 스레드에서 `hardwareVersion / firmwareVersion / serialNumber` mutableState 갱신. + +--- + +## 9. 배터리 (rsn) + +파일: [`BleManager.kt:1031-1043`](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1031) + +### 9.1 패킷 구조 +``` +[tag 4B "rsn:"][millivolt 2B BE][...] +``` + +byte 4~5 = 배터리 전압 (mV) Big Endian. + +### 9.2 mV → % 변환 (segmented curve) +```kotlin +val millivolts = ((data[4] and 0xFF) shl 8) or (data[5] and 0xFF) +val pct = when { + millivolts <= 3500 -> 0 // 방전 + millivolts <= 3700 -> ((millivolts - 3500) * 5 / 200) // 0~5% + else -> 5 + (millivolts - 3700) * 95 / 400 // 5~100% +}.coerceIn(0, 100) +``` + +### 9.3 Hysteresis 가드 +```kotlin +if (pct <= batteryLevel.value || batteryLevel.value == 0) { + batteryLevel.value = pct +} +``` +- 새 값이 기존보다 크거나 같으면 무시 (노이즈 hysteresis) +- 단 `batteryLevel.value == 0` 첫 응답은 항상 통과 +- ⚠️ 충전 중 상승은 반영 안 됨 — 의도된 동작 + +### 9.4 Polling 정책 (★ 2026-06-05 변경) +파일: [`BleManager.kt:517-565`](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L517) + +``` +1. 연결 직후 즉시 1회 msn? 송신 +2. 3초 후 batteryLevel.value == 0 이면 재송신 (max 2회 재시도) +3. 30초 주기 정상 polling +``` + +→ 이전엔 "1회 송신만"이라 그 한 번이 큐 경합으로 손실되면 영원히 0%였음. + +--- + +## 10. 측정 헤더 (rbb) + +파일: [`BleManager.kt:1075-1078`](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1075) + +``` +[tag 4B "rbb:"][batt 2B][IMU 2B][temp 2B][crc 2B] = 22B +``` + +- `batt`: 배터리 mV (uint16 BE) +- `IMU`: IMU 상태 (uint16 BE) — 의미는 펌웨어 정의 +- `temp`: 온도 × 100 °C (uint16 BE). 변환: `temp / 100 = °C` + - FW 내부 측정 방식 변경됨 (TMP235 ADC → IMU TEMP_DATA register, `T = 25 + raw/128`, 4× oversampling) — 클라이언트 포맷은 동일 + +`mbb?` 응답의 첫 패킷. 이후 reb×6 + raa 가 따라옴. + +콜백: `onMbbHeaderReceived?.invoke(data)` + collector에 전달 (collector는 rbb를 skip 처리). + +--- + +## 11. NIRS 응답 (rta / rsh / rqq / rag / rcj) + +| Prefix | 용도 | 콜백 | +|---|---|---| +| `rta:` `rta!` | NIRS power ON 완료 | `onNirsPowerOnReceived` | +| `rsh:` `rsh!` | Sensor activated (calibration mode) | `onNirsSensorActivated` | +| `rqq:` `rqq!` | Sensor activated (monitoring mode) | `onNirsSensorActivated` | +| `rag:` `rag!` | Gain calibration 결과. `(data.size - 6) / 2`개 값 | `onNirsMagReceived(data)` | +| `rcj:` `rcj!` | MCJ raw 패킷 — Vivamyo monitoring 데이터 | `onNirsMcjReceived(data)` | + +`!` 변형은 펌웨어 측 에러/특수 상태. 처리 자체는 `:` 와 동일하지만 디버그 로그에 prefix 그대로 기록. + +NIRS 자세한 파싱은 [`NirsManager`](../app/src/main/java/com/example/medilightv2android/managers/NirsManager.kt) 참고 (Vivamyo 화면 전용). + +--- + +## 12. Pairing / Unpairing + +### 12.1 Pairing +표준 GATT 연결만으로 끝. 별도 페어링 명령 없음. CCCD enable 완료 시 service ready. + +### 12.2 Unpair (`disconnectAndUnbond()`) +파일: [`BleManager.kt:262-310`](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L262) + +``` +1. isUserDisconnect = true ← auto-reconnect 차단 flag +2. cancelAutoReconnect() +3. stopWatchdog() +4. stopBatteryPolling() +5. BleForegroundService.stop(context) +6. connectionTimer cancel +7. sendRaw(buildCommandASCII("msr", " ")) ← 기기에 unbond + reboot 명령 +8. (300ms 대기) + ├─ gatt.disconnect() + gatt.close() + ├─ characteristic/connectedDeviceName/batteryLevel 등 reset + ├─ device.removeBond() (리플렉션) ← Android 본딩 정보 제거 + └─ SharedPreferences "last_device_*" 제거 +``` + +→ 기기와 앱 양측이 페어링/본딩 정보를 완전히 잃은 상태. 다음 사용 시 DeviceScan부터. + +--- + +## 13. 알려진 quirk / Edge cases + +| 항목 | 원인 | 대응 | +|---|---|---| +| **첫 cycle IMU 13/15 samples** | FW FIFO 미충전 | 분석 시 cycle 0 제외 / payload는 그대로 저장 | +| **rid: trailing NULL byte** | FW response padding | 클라이언트 [0x00-0x1F,0x7F] strip 필수 | +| **연결 직후 배터리 0%** | msn? 1회 송신만 + 큐 경합으로 첫 응답 누락 | 즉시 1회 + 3s 재시도 + 30s polling으로 보강 | +| **reb 신구조 미적용 펌웨어** | 구버전 단말 | 미지원. 현재는 신버전(210B) 전용 | +| **maa/mtb 너무 빠른 연속 호출** | < 600ms 간격 | `canSendMaa()` 가드로 자동 drop | +| **사용자가 모르게 측정 끊김** | 펌웨어 reboot / BLE 거리 이슈 | watchdog + scheduleAutoReconnect 백그라운드 처리 (UI banner는 표시 안 함) | +| **Endian 잘못 감지** | 첫 ADC 샘플이 0..4095 양쪽 모두 가능한 값 | `forceBigEndian = true` (VBT 기본)로 fallback | +| **rbb 22B 길이 검증** | 부분 수신 케이스 | length 가드 + onMbbHeaderReceived에서만 콜백 발사 | + +--- + +## 14. 디버깅 가이드 + +### 14.1 로그 태그 +- `BleManager` — 연결/송신/큐 상태 +- `LabdbClient` — REST 호출 payload + 응답 +- `PiezoCollector` — 채널 set 진행 / drop 사유 +- `ImuCollector` — rim sample count + +### 14.2 ble.log (텍스트 디버그 로그) +파일: [`BleDebugLogger.kt`](../app/src/main/java/com/example/medilightv2android/services/BleDebugLogger.kt) + +각 세션 폴더에 `ble.log` 자동 작성: +``` +[12:34:56.123] RX reb (210B) session=2 ch=0 100samples +[12:34:56.145] RX reb (210B) session=2 ch=1 100samples +... +[12:34:56.310] RX raa (8B) all-ch complete +[12:34:56.450] RX rim (188B) IMU 15 samples +``` + +set 무효 시 drop 사유까지 함께 기록되어 사후 분석 가능. + +### 14.3 직접 점검 명령 +- adb logcat 필터: `adb logcat -s BleManager PiezoCollector LabdbClient` +- BLE 패킷 sniffing: nRF Connect 앱 또는 별도 sniffer + +--- + +## 15. 빠른 참조 — 한 사이클 sequence (mtb) + +``` +T+0 App sendMtb() + ├─ canSendMaa() 통과 확인 + ├─ piezoCollector.startMultiChannel(6) + ├─ imuCollector.reset() + └─ TX: [m t b ? ] + CRC (7 bytes) + +T+~10 Device (mtb? 수신, 측정 시작) + +T+~80 Device reb (ch=0, session=N, 210B) ───────► piezoCollector.processRebPacket +T+~110 Device reb (ch=1, session=N, 210B) ───────► (channelResults[1] = ...) +T+~140 Device reb (ch=2, session=N, 210B) +T+~170 Device reb (ch=3, session=N, 210B) +T+~200 Device reb (ch=4, session=N, 210B) +T+~230 Device reb (ch=5, session=N, 210B) +T+~260 Device raa (8B) ──────────────────────────► piezoCollector.processEndPacket + (6채널 다 채워짐 → 콜백) + onMultiChannelComplete([Ch0..Ch5]) +T+~290 Device rim (15 samples, 188B) ────────────► imuCollector.parseRim + onComplete(samples) + └─ pendingPiezo + imuSamples를 묶어 addCycle + +T+600 App delay(600 - 290 = 310ms) 끝 → 다시 sendMtb() + (한 사이클 평균 280~420ms + 안전 마진 = 600ms 주기 안정) +``` + +평균 한 사이클: 280~420ms. ClinicalLive 600ms 주기와 정확히 맞춤. + +--- + +## 16. iOS 포팅 매핑 힌트 + +| Kotlin/Android | Swift/iOS | +|---|---| +| `BluetoothGatt` | `CBPeripheral` | +| `BluetoothGattCharacteristic` | `CBCharacteristic` | +| `gatt.connectGatt(...)` | `centralManager.connect(peripheral)` | +| `gatt.discoverServices()` | `peripheral.discoverServices(nil)` | +| `gatt.setCharacteristicNotification` + CCCD write | `peripheral.setNotifyValue(true, for:)` | +| `gatt.requestMtu(247)` | iOS는 자동 협상 (최대 185 또는 ATT_MTU 결과 사용) | +| `gatt.requestConnectionPriority(HIGH)` | iOS 직접 API 없음 — peripheral 측 connection interval 설정 의존 | +| `txCharacteristic.value = data` + writeCharacteristic | `peripheral.writeValue(data, for:tx, type: .withoutResponse)` | +| `onCharacteristicChanged(value)` | `peripheral(_:didUpdateValueFor:error:)` | +| `setOnDescriptorWrite` | `peripheral(_:didUpdateNotificationStateFor:error:)` | +| `device.removeBond` (리플렉션) | **없음** — 사용자에게 "Settings → Bluetooth → Forget Device" 안내 | +| `mutableStateOf` | `@Published` / `@State` | + +--- + +**원본 소스**: `app/src/main/java/com/example/medilightv2android/ble/` +- [`BleManager.kt`](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt) +- [`CRC16.kt`](../app/src/main/java/com/example/medilightv2android/ble/CRC16.kt) +- [`PiezoPacketCollector.kt`](../app/src/main/java/com/example/medilightv2android/ble/PiezoPacketCollector.kt) +- [`ImuPacketCollector.kt`](../app/src/main/java/com/example/medilightv2android/ble/ImuPacketCollector.kt) + +**관련 문서**: [iOS Porting Clinical Measurement](iOS_PORTING_CLINICAL_MEASUREMENT.md), [labdb.md](../labdb.md) diff --git a/docs/FLAVOR_DEMO_STABLE.md b/docs/FLAVOR_DEMO_STABLE.md new file mode 100644 index 0000000..9581fb9 --- /dev/null +++ b/docs/FLAVOR_DEMO_STABLE.md @@ -0,0 +1,215 @@ +# Flavor 운영 정책 — demo (동결 안정판) vs dev (개발 진행) + +방광 모형 테스트가 100% 통과하는 시점을 **demo flavor**로 동결하고, 모든 신규 작업은 **dev flavor**에서만 진행하기 위한 가이드. + +--- + +## 1. 두 flavor의 정체성 + +| 항목 | demo | dev | +|---|---|---| +| 목적 | 시연/검증 — 결과 재현성 보장 | 개발 진행 — 모든 신규 기능/실험 | +| applicationId | `com.example.medilightv2android.demo` | `com.example.medilightv2android` | +| 표시명 | VesiScan Demo | VesiScan Dev | +| versionName suffix | `-demo` | (없음) | +| BuildConfig.IS_DEMO | `true` | `false` | +| BuildConfig.FLAVOR_LABEL | `"demo"` | `"dev"` | +| 동시 설치 | 가능 (applicationId 다름) | 가능 | + +→ 한 단말에 두 빌드를 동시에 설치해서 같은 측정을 양쪽에서 돌려보고 결과 비교 가능. + +--- + +## 2. 빌드 명령 + +```bash +# demo flavor +./gradlew assembleDemoDebug # debug APK +./gradlew assembleDemoRelease # release APK + +# dev flavor +./gradlew assembleDevDebug # debug APK (일상 개발) +./gradlew assembleDevRelease + +# 두 flavor 모두 빌드 +./gradlew assembleDebug +``` + +Android Studio에서는 **Build Variants** 패널에서 `demoDebug` / `devDebug` / ... 중 선택. + +--- + +## 3. 동결 메커니즘 — 두 가지 레벨 + +### Level 1: BuildConfig flag 분기 (가장 단순) + +코드 안에서 분기: +```kotlin +if (BuildConfig.IS_DEMO) { + // demo 전용 동작 (예: 디버그 panel 숨김, 특정 알고리즘 강제) +} else { + // dev 전용 +} +``` + +장점: 같은 파일에서 처리 가능. +단점: 시간이 지나면서 main 코드가 변경되면 demo 동작도 같이 영향받음. **진정한 동결은 안 됨.** + +### Level 2: Source set 격리 (★ 진짜 동결) + +`src/demo/java/` 또는 `src/demo/res/` 에 동결 시점의 클래스/리소스를 복사하면, **dev 작업 중 main이 바뀌어도 demo flavor 빌드는 이 격리본을 사용**합니다. + +**Gradle 컴파일 우선순위:** +``` +demo flavor 빌드 → src/main/* + src/demo/* (같은 클래스명이면 src/demo/가 우선) +dev flavor 빌드 → src/main/* + src/dev/* +``` + +**예시:** V4.1 wall detection 알고리즘을 2026-06-08 시점에 동결하려면: +``` +src/main/java/com/example/.../walldetect/V41Detector.kt ← 계속 변경됨 (dev에서 사용) +src/demo/java/com/example/.../walldetect/V41Detector.kt ← 2026-06-08 시점 복사본 (demo에서 사용) +``` + +main의 V41Detector를 리팩토링/실험해도 demo 빌드는 항상 격리본을 컴파일. + +⚠️ **주의:** +- 격리한 클래스의 시그니처가 main의 의존 코드와 호환되어야 함 (메서드 이름/파라미터 동일하게 유지) +- main에서 V41Detector 호출부의 시그니처가 바뀌면 demo flavor 빌드 깨짐 — 그땐 src/demo/도 같이 업데이트하거나, 인터페이스를 두고 그 구현만 분리 + +--- + +## 4. 권장 동결 운영 절차 + +### 4.1 새 안정판 동결 시 + +방광 모형 테스트가 100% 통과하는 시점을 확인하면: + +```bash +# 1) 현재 상태에 tag 부여 (snapshot 보존) +git tag -a demo-stable-v1.0 -m "Demo stable: bladder phantom 100% pass — 2026-06-08" +git push origin demo-stable-v1.0 + +# 2) 동결할 핵심 클래스/리소스를 src/demo/로 복사 +# 예: V4.1, PiezoSettings 기본값, GreenZoneConstants 등 +cp app/src/main/java/.../walldetect/V41Detector.kt \ + app/src/demo/java/.../walldetect/V41Detector.kt + +# 3) demo flavor가 격리본을 잘 쓰는지 빌드 + 시연용 폰에 설치해 검증 +./gradlew installDemoDebug + +# 4) commit +git add app/src/demo/ && git commit -m "Demo flavor: freeze V4.1/PiezoSettings (v1.0)" +``` + +### 4.2 일상 개발 (dev) + +- `src/main/` 만 변경 — `src/demo/`는 건드리지 않음 +- `assembleDevDebug` 로만 작업 +- demo 격리본의 시그니처를 깨뜨리는 API 변경은 피하거나, demo도 같이 업데이트 + +### 4.3 다음 안정판 갱신 + +새로운 안정판이 검증되면: +```bash +# 이전 격리본 백업 (옵션 — git history에 남아있음) +mv app/src/demo/java/.../V41Detector.kt /tmp/V41Detector.v1.0.kt + +# 최신 main 코드를 다시 src/demo/로 복사 +cp app/src/main/java/.../V41Detector.kt app/src/demo/java/.../V41Detector.kt + +# 새 tag +git tag -a demo-stable-v1.1 -m "Demo stable v1.1: ..." + +# commit +git add app/src/demo/ && git commit -m "Demo flavor: refresh freeze to v1.1" +``` + +--- + +## 5. 어떤 코드를 동결 후보로? + +방광 모형 테스트 결과에 직접 영향을 주는 것들: + +| 카테고리 | 후보 | 위치 | +|---|---|---| +| Wall detection | `V41Detector`, `PiezoEchoAnalyzer*` | `walldetect/`, `managers/PiezoEchoAnalyzer*.kt` | +| 측정 파라미터 | `PiezoSettings` 기본값, `GreenZoneConstants`, `PiezoHW` preset | `models/`, `managers/` | +| BLE 파싱 | `PiezoPacketCollector`, `ImuPacketCollector` | `ble/` | +| Volume 계산 | `PiezoConstants.volumeMl()` 식 | `managers/PiezoEchoAnalyzer.kt` | + +⚠️ **BLE 통신 layer (BleManager, CRC16)는 펌웨어와 직결되므로 보통 같이 가야 함** — 동결하면 펌웨어 업데이트 대응 불가. + +⚠️ **UI/저장/labdb는 동결할 필요 거의 없음** — 결과값 자체에는 영향 안 줌. + +→ 처음엔 **알고리즘 + 측정 파라미터**만 동결, 나머지는 main 공유 권장. + +--- + +## 6. BuildConfig 활용 예시 + +런타임 분기가 필요한 경우 (격리할 만큼 크지 않은 케이스): + +```kotlin +// 예: dev에만 Clinical 메뉴 노출 +if (com.example.medilightv2android.BuildConfig.IS_DEMO.not()) { + Button(onClick = { goClinicalHome() }) { Text("Clinical R&D") } +} + +// 예: demo에서는 항상 fixed parameter +val maxVolume = if (com.example.medilightv2android.BuildConfig.IS_DEMO) { + 500 // 동결 기본값 +} else { + appState.piezoSettings.maxVolume // 사용자 조정 허용 +} + +// UI에서 flavor 표시 (디버그용) +Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR_LABEL})", + fontSize = 10.sp, color = MlSecondaryText) +``` + +--- + +## 7. CI / 배포 시 주의 + +- **demo flavor APK는 출시/시연 전용** — Play Store 등 외부 배포는 dev flavor 사용 +- demo flavor의 applicationId가 `.demo`로 끝나므로 ANR/crash 리포트도 자동 구분됨 +- demo는 안정성 보장이 핵심 — 새 라이브러리/SDK 업데이트도 보수적으로 + +--- + +## 8. 트러블슈팅 + +### Q. demo flavor 빌드 시 main의 변경사항이 자꾸 반영됨 +A. src/demo/ 에 격리본을 복사하지 않았기 때문. Level 2 동결을 적용해야 함. BuildConfig.IS_DEMO 분기만으로는 진짜 동결 안 됨. + +### Q. 같은 클래스를 src/main/과 src/demo/에 둘 다 두면 어떻게 됨? +A. demo flavor 빌드에서는 src/demo/ 가 우선. dev flavor 빌드에서는 src/main/ 만 사용 (src/demo는 무시). + +### Q. src/demo/와 src/dev/ 둘 다 같은 클래스가 있으면? +A. 각 flavor 빌드는 자기 flavor source set만 봄. demo 빌드 = main + demo, dev 빌드 = main + dev. 충돌 안 남. + +### Q. Test (src/test/, src/androidTest/) 는 flavor 별로 분리되나? +A. 가능. `src/testDemo/`, `src/testDev/`로 분리. 다만 현재 프로젝트엔 테스트가 거의 없어 신경 안 써도 됨. + +### Q. Gradle sync 후 Build Variants에 demo/dev가 안 보임 +A. Android Studio 좌측 하단 **Build Variants** 패널을 켜고 Gradle sync 재실행. 처음 한 번은 sync에 30초~1분 걸림. + +--- + +## 9. 첫 동결 시점 권장 절차 (지금 바로 할 일) + +1. 현재 dev 빌드를 방광 모형으로 검증 — 결과값이 만족스러우면 진행 +2. `git tag -a demo-stable-v1.0 -m "Demo baseline 2026-06-08"` + push +3. 검증된 알고리즘 클래스를 `src/demo/java/...` 로 복사 +4. `./gradlew installDemoDebug` 로 시연 폰에 설치 +5. 같은 모형으로 demo 빌드 한 번 더 측정 — 동일 결과 확인 +6. 이후 dev에서 자유롭게 작업. demo는 건드리지 않음. + +--- + +**관련 파일:** +- `app/build.gradle.kts` — productFlavors 정의 +- `app/src/demo/`, `app/src/dev/` — flavor 전용 source set +- `app/src/demo/res/values/strings.xml` — app_name "VesiScan Demo" +- `app/src/dev/res/values/strings.xml` — app_name "VesiScan Dev" diff --git a/docs/iOS_PORTING_CLINICAL_MEASUREMENT.md b/docs/iOS_PORTING_CLINICAL_MEASUREMENT.md new file mode 100644 index 0000000..b53609a --- /dev/null +++ b/docs/iOS_PORTING_CLINICAL_MEASUREMENT.md @@ -0,0 +1,617 @@ +# iOS Porting Guide — Clinical Measurement + +VesiScan Basic Android v1.1.0-design의 **Clinical Measurement (사내 임상 R&D)** 모드를 iOS로 포팅하기 위한 종합 가이드. 코드 위치는 모두 Android 소스 기준. + +--- + +## 0. TL;DR — 한눈 흐름 + +``` +[HOME] + ↓ Clinical 진입 (Dev 모드 한정) +[CLINICAL_HOME] ← BLE 연결 + 라벨 입력 + labdb 등록/상태 + ↓ Start +[CLINICAL_LIVE] ← 6채널 + IMU 실시간 + 자동 저장 + ↓ End / Back +[ClinicalSessionStore.endMeasurement()] + ├─ meta.json finalize + ├─ measurement.json 작성 (모든 cycle 통합) + └─ labdb /upload/json 자동 호출 (active 상태일 때만) +↓ +[CLINICAL_HOME] ← "Last saved" 카드 + 수동 재시도 +``` + +핵심: +- **mtb? 600ms 주기 루프** — 한 사이클 = piezo 6채널(`reb`×6 + `raa`) + IMU 15 samples(`rim`) +- **한 측정 = 한 폴더** (`Downloads/VesiScan_Sessions/____/`) +- **저장 단위**: `measurement.json` (단일 파일, 모든 cycle 통합) + 기존 CSV는 보조 +- **업로드**: labdb REST, dataType `"001"` (내부 임상), idempotent via `testId`+`rowIndex` + +--- + +## 1. UI 화면 시퀀스 + +### 1.1 ClinicalHome (입력 폼) +파일: `ui/views/clinical/ClinicalHomeView.kt` + +**구성:** +- Top bar (← back to HOME, "Clinical Measurement") +- **BLE 상태 카드**: 연결됨이면 device name + FW version, 아니면 "Connect" 버튼 → DeviceScan + - 우측 **Unpair 버튼** (red outline) — `bleManager.disconnectAndUnbond()` + confirm dialog +- **labdb 상태 카드**: `not_registered` / `pending` / `active` / `revoked` / error + - 미등록 시 **Register** 버튼 / 등록됨이면 **Refresh** 버튼 + - 진입 시 자동 `checkStatus()` 호출 +- **Last saved 카드** (직전 측정 폴더): 업로드 성공/실패 표시 + - 실패 시 **Upload** 버튼 (수동 재시도) +- **입력 폼**: + - Posture (SUPINE/SITTING/STANDING) — segmented + - Step (ALIGN_0CM~ALIGN_4CM/BV) — segmented + 설명 라인 + - Subject ID (string, 예: `S001`) + - True Volume (mL, 외부 초음파 참값) — Double? + - Abdomen (mm, 복부 두께) — Double? + - Examiner, Notes +- **Start Live Measurement** 버튼 → 세션 생성 + CLINICAL_LIVE 진입 + +**Start 동작:** +```kotlin +ClinicalSessionStore.startMeasurement(context, ClinicalSession( + deviceName = bleManager.connectedDeviceName.value, // e.g. "VBT26050202" + posture, step, examiner, notes, subjectId, + trueVolumeMl, abdomenThicknessMm +)) +appState.currentScreen = CLINICAL_LIVE +``` + +- `startMeasurement`은 폴더를 생성하고 `meta.json`을 즉시 작성. +- 직전 입력값은 `ClinicalSessionStore.lastExaminer/lastNotes/lastSubjectId/...`에 보관되어 다음 측정 시 자동 prefill. + +### 1.2 ClinicalLive (6채널 라이브) +파일: `ui/views/clinical/ClinicalLiveView.kt` + +**구성:** +- Top bar: `Live · {label}` + `S=... TV=...mL Ab=...mm` 서브 + `#{cycleCount} ✓{capturedCount}` +- **6채널 2×3 grid**: + - 각 채널 = raw ADC waveform (Canvas) + - 헤더: `CH{n}` + `a{antIdx} p{postIdx}` (V4.1 결과) + raw `max` 값 + - 그래프 위 세로선 마커: ant=green, post=orange (alpha 0.75) +- 하단 컨트롤: + - **Pause/Resume** — measurement loop 토글 + - **Capture** — 현재 시점 1 사이클 강제 저장 (옵션) + - **End** — `endMeasurement()` + CLINICAL_HOME 복귀 +- `autoCapture` flag = true가 기본. 매 사이클 자동 저장. + +**측정 루프 (LaunchedEffect):** +```kotlin +while (isMeasuring) { + bleManager.sendMtb() // "mtb?" + CRC16 + delay(600) // 600ms 주기 +} +``` + +`autoScanIntervalMs` 설정값을 사용해도 됨. 기본 600ms. + +### 1.3 화면 전이 / Back 처리 +- ClinicalLive Back → `endMeasurement()` → ClinicalHome +- ClinicalLive에서 measurement 진행 중 BLE 끊김 → 측정 일시정지 (banner 없이 silent reconnect) +- ClinicalHome Back → `inClinicalFlow = false` + HOME 복귀 + +--- + +## 2. 데이터 모델 + +### 2.1 ClinicalSession +파일: `models/ClinicalSession.kt` + +```kotlin +data class ClinicalSession( + val deviceName: String, // BLE 기기 이름 (예: "VBT26050202") + val posture: ClinicalPosture, // SUPINE / SITTING / STANDING + val step: ClinicalStep, // ALIGN_0CM..ALIGN_4CM / BV + val examiner: String = "", + val notes: String = "", + val subjectId: String = "", + val trueVolumeMl: Double? = null, // 외부 측정기 참값 + val abdomenThicknessMm: Double? = null, // 복부 두께 참값 + val startedAt: Long = System.currentTimeMillis(), + var endedAt: Long? = null +) + +enum class ClinicalPosture { SUPINE, SITTING, STANDING } +enum class ClinicalStep(val label, val description, val isAlignment) { + ALIGN_0CM("0 cm", "기기 아래 끝이 치골 바로 위", true), + ALIGN_1CM("1 cm", "치골 위로 1 cm", true), + ALIGN_2CM("2 cm", "치골 위로 2 cm", true), + ALIGN_3CM("3 cm", "치골 위로 3 cm", true), + ALIGN_4CM("4 cm", "치골 위로 4 cm", true), + BV("BV", "Cradle 부착 후 본측정", false) +} + +// 폴더명: "{subjectId}_{deviceName}_{posture}_{step}_{yyyy-MM-dd_HHmmss}" +// 라벨: "{deviceName}_{posture}_{step}" +``` + +### 2.2 MeasurementCycle (한 사이클) +파일: `services/ClinicalSessionStore.kt` + +```kotlin +data class MeasurementCycle( + val cycle: Int, // 0부터 증가 + val timestampMs: Long, // 사이클 도착 시점 + val piezoChannels: Map>, // ch idx (0~5) → 100 samples + val imuSamples: List // 일반적으로 15개 (FW FIFO) +) +``` + +### 2.3 PiezoChannelData / ImuSample +파일: `ble/PiezoPacketCollector.kt`, `ble/ImuPacketCollector.kt` + +```kotlin +data class PiezoChannelData(val channel: Int, val buffer: List) +data class ImuSample( + val ax: Float, val ay: Float, val az: Float, // g 단위 + val gx: Float, val gy: Float, val gz: Float // dps 단위 +) +``` + +--- + +## 3. BLE 프로토콜 + +### 3.1 UUID (Nordic UART Service 호환) +파일: `ble/BleManager.kt:45-48` + +| 역할 | UUID | +|---|---| +| Service | `6E400001-B5A3-F393-E0A9-E50E24DCCA9E` | +| TX (write) | `6E400002-B5A3-F393-E0A9-E50E24DCCA9E` | +| RX (notify) | `6E400003-B5A3-F393-E0A9-E50E24DCCA9E` | +| CCCD | `00002902-0000-1000-8000-00805f9b34fb` | + +iOS는 CoreBluetooth `CBPeripheral.discoverServices/Characteristics` 후 RX에 `setNotifyValue(true)` 활성화. + +### 3.2 명령 빌드 (CRC16-CCITT) +파일: `ble/CRC16.kt` + +``` +Polynomial: 0x1021 +Initial value: 0xFFFF +ASCII command: "{cmd}?{params}" + CRC (Little Endian 2B) +``` + +예: `mtb?` 명령 → `[0x6D, 0x74, 0x62, 0x3F, CRC_LO, CRC_HI]` (6 bytes) + +Swift 의사코드: +```swift +func crc16Ccitt(_ data: [UInt8]) -> UInt16 { + var crc: UInt16 = 0xFFFF + for byte in data { + crc ^= UInt16(byte) << 8 + for _ in 0..<8 { + crc = (crc & 0x8000) != 0 ? (crc << 1) ^ 0x1021 : crc << 1 + } + } + return crc +} + +func buildCommandASCII(_ cmd: String, _ params: String = " ") -> Data { + let body = "\(cmd)?\(params)".data(using: .utf8)! + let crc = crc16Ccitt(Array(body)) + return body + Data([UInt8(crc & 0xFF), UInt8(crc >> 8)]) // LE +} +``` + +### 3.3 명령 카탈로그 + +| TX | 응답 prefix | 용도 | +|---|---|---| +| `mtb?` | reb×6 + raa + **rim** | 6채널 + IMU 한 사이클 (★ Clinical에서 사용) | +| `mbb?` | rbb + reb×6 + raa | 배터리 헤더 포함 한 사이클 | +| `maa?` | reb×6 + raa | 6채널만 (no IMU) — 도넛차트에서 사용 | +| `mid?` | rid: | 디바이스 정보 (FW/HW/SN) | +| `msr?` | — | 본딩 삭제 + 재부팅 (Unpair용) | + +Clinical Live에서는 **`mtb?`** 한 종류만 600ms 주기로 송신. + +### 3.4 응답 파싱 — reb (신구조 210B) + +⚠️ **펌웨어 업데이트 (2026-06-02 이후)** 로 reb payload 앞에 ch_info 2B 추가. 구조: + +``` +reb: [tag 4B][ch_session 1B][ch_num 1B][num_sample 2B][adc 200B][crc 2B] = 210B + ↑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) + +**Endian 감지** — 첫 채널(`ch_num==0`)의 첫 ADC 샘플(byte 8~9)로 판별: +- LE 해석값이 4095(12-bit 최대)를 초과하면 BE 확정 +- 둘 다 범위 내면 `forceBigEndian` flag (VBT 기기 = BE) + +**무효 패킷 정책:** +- ch_session 불일치 (set 진행 중 다른 session 도착) → 이전 set 폐기, 새 set 시작 +- 같은 ch_num 중복 (set 내 같은 채널 두 번) → `isSetDropped = true` +- ch_num이 0..5 범위 밖 → drop +- raa에서 6채널 중 일부 누락 → 폐기 (no callback) +- 정상 set만 `onMultiChannelComplete([PiezoChannelData × 6])` 호출 + +iOS 포팅 시 [`PiezoPacketCollector.kt`](../app/src/main/java/com/example/medilightv2android/ble/PiezoPacketCollector.kt) 로직 그대로 옮길 것. + +### 3.5 응답 파싱 — raa (set 종료) + +``` +raa: [tag 4B][state 2B][crc 2B] = 8B +``` + +set의 6개 reb가 다 도착하고 나서 발생. 위 무효 정책에 따라 콜백 결정. + +### 3.6 응답 파싱 — rim (IMU 15 samples) +파일: `ble/ImuPacketCollector.kt` + +``` +rim: [tag 4B][num_sample 2B BE][imu_raw 180B][crc 2B] = 188B +imu_raw = 15 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. + +**스케일링:** +- Accel ±4g FSR: `1 LSB = 1/8192 g` → `value = raw / 8192f` +- Gyro ±500dps FSR: `1 LSB = 1/65.536 dps` → `value = raw / 65.536f` + +### 3.7 응답 파싱 — rid (디바이스 정보) +파일: `ble/BleManager.kt:997-1018` + +``` +rid: [tag 4B] + ASCII payload (e.g. "VBTHW0100 VBT26040001 VBTFW0111") +``` + +⚠️ **NULL byte 제거 필수**: +```kotlin +val info = String(data, 4, data.size - 4, Charsets.US_ASCII) + .replace(Regex("[\\x00-\\x1F\\x7F]"), " ").trim() +``` + +이후 split + `startsWith("VBTHW"/"VBTFW"/"VBT")`로 hw/fw/serial 추출. +→ 이거 안 하면 labdb 업로드 시 PostgreSQL JSONB가 0x00을 거부해 HTTP 500. + +### 3.8 Pairing/Unpair +- **연결**: standard CoreBluetooth `connect(peripheral)` +- **Unpair** (`bleManager.disconnectAndUnbond()`): + 1. `sendRaw(buildCommandASCII("msr", " "))` — 기기 측 본딩 삭제 + reboot + 2. 300ms 후 GATT disconnect/close + 3. Android removeBond — iOS는 사용자에게 "Settings → Bluetooth → Forget Device" 안내 (CoreBluetooth는 unpair API 없음) + 4. SharedPreferences clear + +--- + +## 4. 측정 사이클 흐름 + +### 4.1 mtb 한 사이클 sequence +``` +[App] sendMtb() + ├─ piezoCollector.startMultiChannel(6) ← reset state + ├─ imuCollector.reset() + └─ TX: "mtb?" + CRC + + (~330ms 후 응답 시작) + +[Device → App] notify 연속 패킷: + reb (ch 0) → reb (ch 1) → ... → reb (ch 5) + ↑ 각 패킷마다 piezoCollector.addPacket() + raa + ↑ piezoCollector.addPacket() → onMultiChannelComplete([Ch0..Ch5]) + rim + ↑ imuCollector.parseRim() → onComplete(samples) + +[App] onMultiChannelComplete: + - lastChannels = channels + - pendingPiezo = channels ← IMU 도착 대기 + - (선택) V4.1 wall detection 호출 + +[App] onComplete (IMU 도착): + - lastImu = imuSamples + - cycleCount++ + - if (autoCapture && pendingPiezo != null): + ClinicalSessionStore.addCycle(pendingPiezo, imuSamples) + AdcCsvLogger.log(rawADC) + ImuCsvLogger.log(imuSamples) + capturedCount++ + - pendingPiezo = null + +[Loop] delay(autoScanIntervalMs ~600ms) → 다시 sendMtb() +``` + +### 4.2 pendingPiezo 패턴 (핵심) +piezo set이 먼저 완료되고 IMU가 뒤에 도착하므로, **piezo를 임시 보관했다가 IMU 도착 시 묶어서 한 사이클로 push**. + +iOS Swift 의사코드: +```swift +var pendingPiezo: [PiezoChannelData]? + +piezoCollector.onMultiChannelComplete = { channels in + self.lastChannels = channels + self.pendingPiezo = channels +} +imuCollector.onComplete = { samples in + self.cycleCount += 1 + if self.autoCapture, let piezo = self.pendingPiezo, store.currentSession != nil { + store.addCycle(piezo: piezo, imu: samples) + // CSV log... + } + self.pendingPiezo = nil +} +``` + +### 4.3 알려진 quirk — 첫 사이클 IMU +첫 mtb 응답의 rim이 15개가 아닌 **13개**로 올 수 있음 (FW IMU FIFO가 아직 안 채워진 상태). 25 cycles 중 cycle 0만 13개, 나머지는 15개. 분석 시 cycle 0 제외 또는 padding 처리. + +--- + +## 5. 파일 저장 구조 + +### 5.1 디렉토리 +``` +{Documents}/VesiScan_Sessions/{folderName}/ + ├─ meta.json ← 측정 시작 시 + 종료 시 갱신 + ├─ measurement.json ← End Session 시점에 통합 작성 (★ 분석 메인) + ├─ adc.csv ← 보조 (기존 호환) + ├─ imu.csv ← 보조 + ├─ ble.log ← 디버그 로그 + ├─ labdb_upload.json ← 업로드 성공 시 응답 본문 + └─ labdb_upload_error.json ← 업로드 실패 시 에러 본문 +``` + +iOS는 `FileManager.urls(for: .documentDirectory, in: .userDomainMask).first` 하위에 `VesiScan_Sessions/{folderName}/` 생성. + +### 5.2 meta.json 스키마 +```json +{ + "data_type": "001", + "device_name": "VBT26050301", + "posture": "SUPINE", + "step": "ALIGN_0CM", + "is_alignment": true, + "examiner": "dwj", + "notes": "", + "label": "VBT26050301_SUPINE_ALIGN_0CM", + "subject_id": "S001", + "true_volume_ml": 300.0, + "abdomen_thickness_mm": 22.0, + "started_at": 1780303011907, + "started_at_iso": "2026-06-01T17:36:51.907+09:00", + "ended_at": 1780303030315, + "ended_at_iso": "2026-06-01T17:37:10.315+09:00", + "duration_ms": 18408, + "app_version_name": "1.1.0-design", + "app_version_code": 25, + "device_model": "samsung SM-A245N", + "device_manufacturer": "samsung", + "android_release": "14", + "android_sdk": 34, + "firmware_version": "VBTFW0116", + "hardware_version": "VBTHW0100", + "serial_number": "VBT26050301" +} +``` + +### 5.3 measurement.json 스키마 +End Session 시점에 한 번에 작성. + +```json +{ + "meta": { /* meta.json과 동일 내용 + "captured_cycles": N */ }, + "cycles": [ + { + "cycle": 0, + "timestamp_ms": 1780303012533, + "piezo": { + "CH0": [2295, 2309, ...100개], + "CH1": [...], "CH2": [...], "CH3": [...], "CH4": [...], "CH5": [...] + }, + "imu": [ + {"ax":0.0002, "ay":-0.0052, "az":1.02, "gx":-0.03, "gy":-0.13, "gz":-0.68}, + ...15개 (또는 cycle 0은 13개) + ] + }, + { "cycle": 1, ... }, + ... + ] +} +``` + +이게 분석 PC에서 한 줄로 로드: `data = json.load(open("measurement.json"))` + +--- + +## 6. labdb 업로드 (REST) + +### 6.1 엔드포인트 +파일: `services/labdb/LabdbClient.kt` / `labdb.md` 참고 + +| Method | Path | 인증 | 응답 | +|---|---|---|---| +| POST | `/api/v1/devices/register` | — | `{deviceId, apiKey, status, message}` | +| GET | `/api/v1/devices/status` | X-API-Key | `{status: "active"|"pending"|"revoked", ...}` | +| POST | `/api/v1/upload/json` | X-API-Key | `{ok, sessionId, created, totalRecords, inserted, duplicates, errors}` | + +Base URL: `https://labdb.medithings.net/api/v1` + +### 6.2 register payload +```json +{ + "appName": "VesiScan", + "deviceName": "iPhone 15 Pro", + "fwVersion": "VBTFW0116", // optional, BLE 기기 펌웨어 + "hwNumber": "VBTHW0100", // optional + "serialNumber": "VBT26050301",// optional + "appVersion": "1.1.0-ios" +} +``` + +응답의 `apiKey`는 **Keychain에 안전 저장**. 재등록 금지 (`pending` device 누적). 401/404 응답 시에만 clear 후 재등록. + +### 6.3 status 응답 분기 +| HTTP | code/status | 동작 | +|---|---|---| +| 200 | `active` | 업로드 가능 | +| 200 | `pending` | "admin 승인 대기" 안내 | +| 200 | `revoked` | "관리자 문의" 안내 | +| 401 | `INVALID_API_KEY` | Keychain clear + 재등록 | +| 404 | `DEVICE_DELETED` | Keychain clear + 재등록 | + +### 6.4 upload payload (measurement.json → labdb 변환) +파일: `services/labdb/LabdbUploader.kt` + +```json +{ + "testId": "S001_VBT26050301_SUPINE_AL_2026-06-01_17-36-51", + "dataType": "001", + "sessionName": "VBT26050301_SUPINE_ALIGN_0CM", + "memo": "...", + "savedAt": "2026-06-01T17:37:10.315+09:00", + "params": { + "posture":"SUPINE", "step":"ALIGN_0CM", "is_alignment":true, + "examiner":"dwj", "subject_id":"S001", + "true_volume_ml":300.0, "abdomen_thickness_mm":22.0, + "firmware_version":"VBTFW0116", "app_version":"1.1.0-ios", + "captured_cycles":25 + }, + "recordCount": 25, + "records": [ + { + "rowIndex": 0, + "datetime": "2026-06-01T17:36:52.533+09:00", + "commandType": "MTB", + "sensor": { + "imu": {"ax":..., "ay":..., "az":..., "gx":..., "gy":..., "gz":...}, + ← cycle 내 IMU 마지막(가장 최근) 샘플 + "imu_samples": [ /* 전체 15 samples */ ], + "imu_sample_count": 15 + }, + "channels": [ + {"ch":0, "peak":2362, "peakIdx":0, "data":[2362, 2322, ..., 100개]}, + {"ch":1, ...}, ..., {"ch":5, ...} + ] + }, + ... + ] +} +``` + +⚠️ **testId 제약**: `^[A-Za-z0-9_-]{1,40}$`. 폴더명이 40자 초과 시 앞 31자 + `_` + SHA-1 8자 prefix로 축약. + +⚠️ **NULL byte sanitize**: payload의 모든 string에서 0x00 ~ 0x1F, 0x7F 제거 후 직렬화. PostgreSQL JSONB가 `` 거부 → HTTP 500. + +```kotlin +// JSONB-safe sanitizer (LabdbClient.sanitizeForJsonb 참고) +fun stripControl(s: String) = s.filter { it.code !in 0..31 && it.code != 127 || it == '\t' || it == '\n' || it == '\r' } +``` + +Swift: +```swift +func jsonbSafe(_ s: String) -> String { + return s.filter { ch in + guard let v = ch.unicodeScalars.first?.value else { return true } + if v == 9 || v == 10 || v == 13 { return true } + return v > 31 && v != 127 + } +} +``` + +### 6.5 업로드 시점 정책 +- **End Session 자동 호출**: `endMeasurement()` 마지막에 `cyclesBuffer.size > 0` && `creds.lastStatus == "active"`일 때만 +- **fire-and-forget** — 백그라운드 비동기, 실패해도 폴더는 그대로 유지 +- 성공: 폴더에 `labdb_upload.json` 작성 (응답 body) +- 실패: 폴더에 `labdb_upload_error.json` 작성 (`error`, `message`, `httpStatus`, `code`, `failedAt`) +- **수동 재시도**: ClinicalHome "Last saved" 카드의 Upload 버튼 → `uploadBlocking(folder)` 직접 호출 + +### 6.6 Rate limit +- `/upload/json`: 10 req/min per device +- 일반 요청: 100 req/min + +iOS는 OkHttp 대신 `URLSession` + `async/await`로 매핑. timeout: connect 15s, read/write 60s. + +--- + +## 7. 알려진 이슈 / Edge Cases + +| 항목 | 상태 | 대응 | +|---|---|---| +| 첫 cycle IMU 13/15 samples | FW 측 FIFO 미충전 | 분석 시 cycle 0 제외 권장 | +| BLE rid: trailing NULL | 펌웨어 응답 padding | 클라이언트 [0x00-0x1F,0x7F] strip 필수 | +| reb 신구조 (210B) | FW 2026-06-02 이후 | 구버전(208B) 단말 미지원 — 신버전만 | +| testId 40자 초과 | 폴더명 그대로 사용 시 | 앞 31자 + `_` + SHA-1 8자 | +| pending devices 누적 | register 실패/중복 호출 | apiKey 저장 후 재등록 금지, admin 콘솔에서 정리 | +| BLE 끊김 banner | UX 피드백으로 제거 | 백그라운드 자동 재연결만 동작 (CoreBluetooth는 retry 직접 구현 필요) | + +--- + +## 8. Kotlin → Swift 매핑 힌트 + +| Kotlin (Android) | Swift (iOS) | +|---|---| +| OkHttp + Request.Builder | URLSession + async/await | +| Jetpack Compose | SwiftUI | +| MutableStateOf | @State, @Published | +| EncryptedSharedPreferences | Keychain (kSecClassGenericPassword) | +| Environment.getExternalStoragePublicDirectory | FileManager.urls(.documentDirectory) | +| ByteArray | Data | +| ToString(US_ASCII) | String(data:encoding:.ascii) | +| org.json.JSONObject | JSONSerialization or Codable | +| Coroutine + Dispatchers.IO | Task + .background | +| android.bluetooth.BluetoothGatt | CoreBluetooth.CBPeripheral | +| writeCharacteristic | peripheral.writeValue(_:for:type:) | +| setCharacteristicNotification | peripheral.setNotifyValue(true, for:) | + +--- + +## 9. 권장 포팅 순서 + +1. **BLE 통신 layer 먼저** + - CRC16 + Command builder + - CoreBluetooth manager (connect / discover / notify) + - `mtb?` 1회 송신 → reb×6 + raa + rim 콘솔 출력으로 검증 + - PiezoPacketCollector / ImuPacketCollector 포팅 + +2. **데이터 모델 + 파일 저장** + - ClinicalSession, MeasurementCycle, PiezoChannelData, ImuSample + - SessionStore — 폴더 생성, meta.json/measurement.json 작성 + +3. **UI** + - ClinicalHome (입력 폼) + - ClinicalLive (6채널 grid + 600ms 루프) + +4. **labdb REST** + - LabdbCredentials (Keychain) + - LabdbClient (register/status/upload + JSONB sanitize) + - LabdbUploader (measurement.json → payload 변환) + +5. **부가 — V4.1 wall detection 화면 오버레이** (선택) + - 분석은 외부 처리이지만 화면 표시는 V4.1 호출 + - walldetect 모듈은 분리 가능한 Swift 포팅이거나 서버 호출 + +--- + +## 10. 검증 체크리스트 + +- [ ] BLE 연결 시 mid? → rid: 응답에서 FW/HW/SN 정확히 파싱 (NULL byte 제거 확인) +- [ ] mtb? 한 사이클 응답에서 reb 6개의 ch_num이 0~5 정확히 들어옴 +- [ ] reb의 num_sample 필드 = 100 (또는 펌웨어 설정값) +- [ ] ADC endian 자동 감지 — VBT는 BE +- [ ] rim 15 samples 파싱, 첫 사이클만 13으로 와도 정상 처리 +- [ ] 한 사이클 = piezo set 완료 + imu 완료가 묶여 addCycle 호출 +- [ ] End Session 시 measurement.json에 `meta + cycles[N]` 완전 기록 +- [ ] labdb register → admin 승인 → status=active → upload 200/201 +- [ ] testId 길이 / 영문/숫자/언더바/하이픈만 확인 +- [ ] payload 직렬화 전 NULL byte/제어문자 제거 +- [ ] 업로드 실패 시 폴더에 error 마커 + 수동 재시도 가능 + +--- + +**원본 Android 소스**: `c:\Projects\medilightv2android` (Gitea: `medithings-rnd/VesiscanBasicAndroid`) +**관련 문서**: `labdb.md` (서버 REST 스펙)