# 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 스펙)