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

22 KiB
Raw Permalink Blame History

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 동작:

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):

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

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

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

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 의사코드:

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 로직 그대로 옮길 것.

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 제거 필수:

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 의사코드:

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 스키마

{
  "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 시점에 한 번에 작성.

{
  "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"
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

{
  "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

{
  "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.

// 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:

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