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:
@@ -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가 ` | ||||