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>
This commit is contained in:
2026-06-08 11:49:10 +09:00
parent 95572ac076
commit 2801d2ae53
9 changed files with 1455 additions and 1 deletions
+617
View File
@@ -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/<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 스펙)