Files
VesiscanClinicalAndroid/docs/iOS_PORTING_CLINICAL_MEASUREMENT.md
dw.jang 2801d2ae53 Build: productFlavors 추가 — demo(동결 안정판) / dev(개발 진행) + docs 정리
방광 모형 테스트가 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>
2026-06-08 11:49:10 +09:00

618 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<subjectId>_<device>_<posture>_<step>_<ts>/`)
- **저장 단위**: `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<Int, List<Int>>, // ch idx (0~5) → 100 samples
val imuSamples: List<ImuSample> // 일반적으로 15개 (FW FIFO)
)
```
### 2.3 PiezoChannelData / ImuSample
파일: `ble/PiezoPacketCollector.kt`, `ble/ImuPacketCollector.kt`
```kotlin
data class PiezoChannelData(val channel: Int, val buffer: List<UShort>)
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 스펙)