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,579 @@
|
||||
# BLE Protocol Reference — VBT Device
|
||||
|
||||
VesiScan Basic Android 앱이 VBT 디바이스(VBTFW0116+ 펌웨어)와 주고받는 BLE 명령/응답 전체 정리. 코드 위치는 모두 `app/src/main/java/com/example/medilightv2android/ble/` 기준.
|
||||
|
||||
작성일 / 검증 FW: 2026-06-05 / **VBTFW0116** (reb 신구조 적용)
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요 — 통신 스택
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 앱 UI (ClinicalLive / PiezoMonitoring / Placement / Calib) │
|
||||
└────────┬──────────────────────┬─────────────────────────────┘
|
||||
│ │
|
||||
│ TX (송신) │ RX (콜백)
|
||||
▼ ▲
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ BleManager │
|
||||
│ ├─ sendXxx() → CRC16.build → sendRaw → GATT write │
|
||||
│ └─ processReceivedData() → prefix when 분기 │
|
||||
│ ├─ "reb:" → piezoCollector.addPacket() │
|
||||
│ ├─ "raa:" → piezoCollector.addPacket() │
|
||||
│ ├─ "rim:" → imuCollector.parseRim() │
|
||||
│ ├─ "rsn:" → batteryLevel 갱신 │
|
||||
│ ├─ "rid:" → fw/hw/sn 파싱 │
|
||||
│ └─ ... NIRS 분기 (rta/rsh/rqq/rag/rcj) │
|
||||
└──────┬──────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ CCCD notify
|
||||
▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Android BluetoothGatt │
|
||||
│ Nordic UART Service │
|
||||
│ ├─ TX char 6E400002-... (write w/o resp) │
|
||||
│ └─ RX char 6E400003-... (notify) │
|
||||
└──────────────────────────────────────────────┘
|
||||
│
|
||||
│ BLE 2.0+ Link Layer
|
||||
▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ VBT Device (Nordic nRF52 + custom FW) │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. BLE 연결 Lifecycle
|
||||
|
||||
### 2.1 UUID (Nordic UART Service)
|
||||
파일: [`BleManager.kt:45-48`](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L45)
|
||||
|
||||
| 역할 | UUID |
|
||||
|---|---|
|
||||
| Service | `6E400001-B5A3-F393-E0A9-E50E24DCCA9E` |
|
||||
| TX (앱→기기, write w/o response) | `6E400002-B5A3-F393-E0A9-E50E24DCCA9E` |
|
||||
| RX (기기→앱, notify) | `6E400003-B5A3-F393-E0A9-E50E24DCCA9E` |
|
||||
| CCCD (notify enable) | `00002902-0000-1000-8000-00805f9b34fb` |
|
||||
|
||||
### 2.2 연결 시퀀스
|
||||
|
||||
```
|
||||
1. Scan → Advertising name 또는 service UUID 매칭
|
||||
2. device.connectGatt(context, false, callback, TRANSPORT_LE)
|
||||
3. onConnectionStateChange(STATE_CONNECTED)
|
||||
└─ requestMtu(247)
|
||||
4. onMtuChanged(mtu=...)
|
||||
└─ gatt.discoverServices()
|
||||
5. onServicesDiscovered
|
||||
└─ getService(SERVICE_UUID)
|
||||
├─ txCharacteristic = getCharacteristic(TX_CHAR_UUID)
|
||||
├─ rxCharacteristic = getCharacteristic(RX_CHAR_UUID)
|
||||
├─ setCharacteristicNotification(rx, true)
|
||||
└─ rx.getDescriptor(CCCD).writeDescriptor(ENABLE_NOTIFICATION_VALUE)
|
||||
6. onDescriptorWrite(status=SUCCESS, uuid=CCCD)
|
||||
├─ requestConnectionPriority(CONNECTION_PRIORITY_HIGH) ← ★ throughput 핵심
|
||||
├─ isServiceReady.value = true
|
||||
├─ AdcCsvLogger.newSession()
|
||||
├─ BleForegroundService.start(context)
|
||||
├─ startBatteryPolling() ← 즉시 msn? + 3s 재시도 + 30s 주기
|
||||
└─ startWatchdog() ← 30s zombie 감지
|
||||
7. mid? 1회 (FW/HW/SN 식별) — 사용자가 직접 호출 또는 화면 진입 시
|
||||
```
|
||||
|
||||
### 2.3 MTU / Connection Priority
|
||||
- `requestMtu(247)` — Nordic 기기 권장 최대. 210B의 reb 한 패킷이 분할 없이 들어옴.
|
||||
- `CONNECTION_PRIORITY_HIGH` — peripheral 수락 시 connection interval ≈ 15ms. Central Link Layer ACK 속도 ↑ → FW SoftDevice TX queue 포화 빈도 ↓.
|
||||
- 두 협상 모두 디스커버리 + CCCD 활성화 이후에 진행. 너무 일찍 호출하면 reject.
|
||||
|
||||
### 2.4 자동 재연결 (silent)
|
||||
파일: [`BleManager.kt:601` scheduleAutoReconnect](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L601)
|
||||
|
||||
- 의도하지 않은 disconnect → `scheduleAutoReconnect()` 자동 호출
|
||||
- 마지막 연결 디바이스 주소를 `SharedPreferences`에 저장 → scan + reconnect 시도
|
||||
- UI banner는 표시하지 않음 (사내 피드백 반영) — 백그라운드만 동작
|
||||
- 측정 중이었으면 측정 일시정지, 복구 시 사용자가 다시 시작
|
||||
|
||||
### 2.5 Watchdog (좀비 감지)
|
||||
파일: [`BleManager.kt:528+` watchdogJob](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L528)
|
||||
|
||||
- 별도 스레드로 30초 주기 RX 마지막 수신 시간 확인
|
||||
- N초 이상 무응답 + isConnected.value = true 라면 GATT 강제 reset → 재연결
|
||||
|
||||
---
|
||||
|
||||
## 3. 명령 빌더 — CRC16-CCITT
|
||||
|
||||
파일: [`CRC16.kt`](../app/src/main/java/com/example/medilightv2android/ble/CRC16.kt)
|
||||
|
||||
```
|
||||
Polynomial: 0x1021
|
||||
Initial value: 0xFFFF
|
||||
CRC append: Little Endian 2 bytes (lo, hi)
|
||||
```
|
||||
|
||||
### 3.1 buildCommandASCII (가장 흔히 사용)
|
||||
```kotlin
|
||||
fun buildCommandASCII(cmd: String, asciiParams: String = ""): ByteArray
|
||||
```
|
||||
- 형식: `"{cmd}?{ascii_params}"` + CRC (LE 2B)
|
||||
- 예: `buildCommandASCII("mtb", " ")` → `[0x6D, 0x74, 0x62, 0x3F, 0x20, CRC_LO, CRC_HI]` (7 bytes)
|
||||
- 파라미터 공백 1자는 펌웨어가 요구하는 separator (모든 ASCII 명령 공통)
|
||||
|
||||
### 3.2 buildCommandBE (Big Endian 정수 파라미터)
|
||||
```kotlin
|
||||
fun buildCommandBE(cmd: String, params: IntArray): ByteArray
|
||||
```
|
||||
- 형식: `"{cmd}?"` + params × 2B (high, low) + CRC (LE 2B)
|
||||
- 예: `buildCommandBE("mta", intArrayOf(1))` → `mta?` + `[0x00, 0x01]` + CRC
|
||||
|
||||
### 3.3 buildCommand (Little Endian 정수 파라미터)
|
||||
```kotlin
|
||||
fun buildCommand(cmd: String, params: IntArray): ByteArray
|
||||
```
|
||||
- 형식: `"{cmd}?"` + params × 2B (low, high) + CRC (LE 2B)
|
||||
- (현재 코드에서 직접 호출 위치 없음 — 호환성 유지)
|
||||
|
||||
### 3.4 verify
|
||||
```kotlin
|
||||
fun verify(data: ByteArray): Boolean
|
||||
```
|
||||
- 응답의 마지막 2B를 CRC로 간주, 나머지로 다시 계산해 일치 여부 검증
|
||||
- 현재 RX 파서는 **CRC 검증을 skip**하고 길이/prefix만 보고 처리 (성능 우선). 의심 케이스에선 디버그용으로 사용.
|
||||
|
||||
---
|
||||
|
||||
## 4. TX 명령 카탈로그
|
||||
|
||||
`sendRaw(...)` 는 write w/o response. txCharacteristic이 null이면 silently drop + log.
|
||||
|
||||
| 명령 | 빌더 | Send 함수 | 응답 prefix | 용도 |
|
||||
|---|---|---|---|---|
|
||||
| `msr?` | ASCII " " | `disconnectAndUnbond()` 내부 ([L276](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L276)) | — | 본딩 wipe + reboot |
|
||||
| `mpa?` | BE [freq, cycles] | `sendPiezoPowerOn(freq=2, cycles=5)` ([L404](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L404)) | `rpa:` | Piezo power ON |
|
||||
| `mps?` | ASCII " " | `sendPiezoStop()` ([L408](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L408)) | — | Piezo stop |
|
||||
| `mec?` | BE [freq, delay, 140, cycles, 1, ch] | `sendBurst(...)` ([L412](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L412)) | `reb:` 단일 채널 | Single-channel burst |
|
||||
| `maa?` | ASCII " " | `sendAllChannels()` / `sendChannelsOnly()` ([L416, L513](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L416)) | reb×6 + raa | 6채널 sweep (도넛/placement) |
|
||||
| `mtb?` | ASCII " " | `sendMtb()` ([L430](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L430)) | reb×6 + raa + **rim** | 6채널 + IMU (★ Clinical) |
|
||||
| `mbb?` | ASCII " " | `sendFullMeasurement()` ([L480](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L480)) | rbb + reb×6 + raa | 측정 헤더(batt+temp+IMU) + 6채널 |
|
||||
| `mta?` | BE [0 or 1] | `sendNirsPowerOn()/Off()` ([L441/445](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L441)) | `rta:` | NIRS power |
|
||||
| `mqq?` | ASCII " " | `sendMqqQuery()` ([L449](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L449)) | `rqq:` | NIRS sensor activate |
|
||||
| `mag?` | ASCII " " | `sendMagQuery()` ([L453](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L453)) | `rag:` | NIRS gain calibration |
|
||||
| `mcj?` | ASCII " " | `sendMcjQuery()` ([L457](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L457)) | `rcj:` | NIRS MCJ raw |
|
||||
| `msn?` | BE [0] | `sendBatteryQuery()` ([L463](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L463)) | `rsn:` | 배터리 mV 조회 |
|
||||
| `mid?` | ASCII " " | `sendDeviceInfoQuery()` ([L467](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L467)) | `rid:` | FW/HW/Serial 조회 |
|
||||
| `mls?` | BE [state] | `sendLedMode(state)` ([L471](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L471)) | `rls:` | LED 모드 변경 |
|
||||
| `msp?` | ASCII " " | `sendImuQuery()` ([L475](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L475)) | `rsp:` | IMU 단일 샘플 조회 |
|
||||
|
||||
### 4.1 maa / mtb / maa 송신 throttle (canSendMaa)
|
||||
|
||||
파일: [`BleManager.kt:494-509`](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L494)
|
||||
|
||||
`maa?`, `mtb?`, `sendChannelsOnly()`는 한 채널 그룹 응답이 280~420ms 소요되므로 다음 가드를 통과해야만 송신:
|
||||
|
||||
```
|
||||
조건 동작
|
||||
─────────────────────────────────────────────────
|
||||
piezoCollector busy AND <3000ms 경과 → DROP (THROTTLED)
|
||||
piezoCollector busy AND ≥3000ms 경과 → FORCE (잔여 abandon)
|
||||
정상 AND <600ms 경과 → DROP (THROTTLED)
|
||||
정상 AND ≥600ms 경과 → ALLOW
|
||||
```
|
||||
|
||||
→ 600ms 미만 간격으로 호출하면 자동 무시. ClinicalLive의 600ms `delay` 와 정확히 일치.
|
||||
|
||||
### 4.2 자세한 송신 예
|
||||
```kotlin
|
||||
fun sendMtb(): Boolean {
|
||||
if (!canSendMaa("sendMtb")) return false // throttle
|
||||
lastMaaSentMs = System.currentTimeMillis()
|
||||
piezoCollector.startMultiChannel(6) // collector reset
|
||||
imuCollector.reset()
|
||||
sendRaw(CRC16.buildCommandASCII("mtb", " ")) // TX
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. RX 응답 카탈로그
|
||||
|
||||
`onCharacteristicChanged` → `processReceivedData(data)` → prefix 4글자 추출 → when 분기.
|
||||
|
||||
파일: [`BleManager.kt:986-1097`](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L986)
|
||||
|
||||
| Prefix | 의미 | 패킷 길이 | 파싱 위치 | 콜백 |
|
||||
|---|---|---|---|---|
|
||||
| `rta:` `rta!` | NIRS power ON ack | — | [L991](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L991) | `onNirsPowerOnReceived` |
|
||||
| `rsh:` `rsh!` `rqq:` `rqq!` | Sensor activated | — | [L995](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L995) | `onNirsSensorActivated` |
|
||||
| `rag:` `rag!` | NIRS gain calibration | data.size - 6 = N×2 | [L999](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L999) | `onNirsMagReceived` |
|
||||
| `rcj:` `rcj!` | NIRS MCJ raw | — | [L1003](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1003) | `onNirsMcjReceived` |
|
||||
| `rpa:` | Piezo ack | — | [L1007](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1007) | `onPiezoDataReceived` |
|
||||
| `rer:` | Preliminary header | — | [L1011](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1011) | (로그만) |
|
||||
| `reb:` | Piezo ADC raw (1채널) | **210B** (신구조) | [L1014](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1014) | `piezoCollector.addPacket` |
|
||||
| `red:` | Piezo continuation | — | [L1022](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1022) | `piezoCollector.addPacket` |
|
||||
| `ree:` | Single-ch end | 8B | [L1026](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1026) | `piezoCollector.addPacket` |
|
||||
| `raa:` | **All-ch complete** marker | 8B | [L1026](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1026) | `piezoCollector.addPacket` → set finalize |
|
||||
| `rsn:` | Battery (mV) | ≥6B | [L1031](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1031) | `batteryLevel.value` 갱신 |
|
||||
| `rid:` | Device info (FW/HW/SN) | 가변 | [L1045](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1045) | `firmwareVersion/...value` 갱신 |
|
||||
| `rls:` | LED state | ≥5B | [L1067](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1067) | (로그만) |
|
||||
| `rxs:` | Cmd not supported (echo) | 가변 | [L1071](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1071) | (로그만) |
|
||||
| `rbb:` | Full measurement header | 22B | [L1075](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1075) | `onMbbHeaderReceived` + collector |
|
||||
| `rsp:` | IMU 단일 샘플 | ≥16B | [L1080](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1080) | `onImuReceived` |
|
||||
| `rim:` | **IMU 15 samples** | 188B | [L1086](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1086) | `imuCollector.parseRim` |
|
||||
| `???` | Unknown | — | [L1091](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1091) | hex 로그 + warn |
|
||||
|
||||
⚠️ prefix 매칭은 **`String(data, 0, 4, US_ASCII)`** 로 정확히 4글자 (`"reb:"`처럼 colon 포함).
|
||||
|
||||
---
|
||||
|
||||
## 6. Piezo 응답 상세 (reb / red / ree / raa)
|
||||
|
||||
파일: [`PiezoPacketCollector.kt`](../app/src/main/java/com/example/medilightv2android/ble/PiezoPacketCollector.kt)
|
||||
|
||||
### 6.1 reb 신구조 (210B, FW 2026-06-02+)
|
||||
|
||||
```
|
||||
[tag 4B "reb:"][ch_session 1B][ch_num 1B][num_sample 2B][adc 200B][crc 2B]
|
||||
↑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): LE
|
||||
|
||||
### 6.2 Endian 자동 감지
|
||||
첫 채널(`ch_num == 0`)의 첫 ADC 샘플(byte 8~9)로 판별:
|
||||
|
||||
```kotlin
|
||||
val b8 = bytes[8] and 0xFF; val b9 = bytes[9] and 0xFF
|
||||
val firstSampleLE = b8 or (b9 shl 8)
|
||||
val firstSampleBE = (b8 shl 8) or b9
|
||||
detectedBigEndian = when {
|
||||
firstSampleLE > 4095 && firstSampleBE <= 4095 -> true // 확실히 BE
|
||||
firstSampleBE > 4095 && firstSampleLE <= 4095 -> false // 확실히 LE
|
||||
else -> forceBigEndian // 둘 다 0..4095 → flag default (VBT=true)
|
||||
}
|
||||
```
|
||||
|
||||
12-bit ADC라 정상값은 0..4095. 한쪽이 이 범위를 벗어나면 그쪽 endian이 잘못된 해석.
|
||||
|
||||
### 6.3 측정 set 식별 + 무효 패킷 정책
|
||||
|
||||
| 상황 | 동작 |
|
||||
|---|---|
|
||||
| 새 ch_session 시작 (currentSetSession == null) | session 등록, channelResults reset |
|
||||
| ch_session 불일치 (set 진행 중 다른 session) | 이전 set 폐기, 새 session으로 시작 |
|
||||
| ch_num이 0..5 범위 밖 | `isSetDropped = true`, 그 reb 폐기 |
|
||||
| 같은 ch_num 중복 수신 | `isSetDropped = true`, 그 reb 폐기 |
|
||||
| raa: 도착 + `isSetDropped == true` | 콜백 미호출, 다음 set 대기 |
|
||||
| raa: 도착 + channelResults에 null 존재 | 콜백 미호출 (incomplete set) |
|
||||
| raa: 도착 + 6채널 모두 채워짐 | `onMultiChannelComplete([PiezoChannelData × 6])` 호출 |
|
||||
|
||||
### 6.4 raa 패킷 (set end marker)
|
||||
```
|
||||
[tag 4B "raa:"][state 2B][crc 2B] = 8B
|
||||
```
|
||||
state: 펌웨어 상태 코드. 현재 클라이언트는 사용 안 함 — 마커로만 처리.
|
||||
|
||||
### 6.5 red 패킷 (continuation)
|
||||
- 신구조에서는 210B reb 한 패킷에 다 들어와 사실상 미사용.
|
||||
- multi-channel 모드에서는 무시 (`onLog "red: ignored"`).
|
||||
- single-channel 모드(`mec?`)에서는 reb 데이터 분할 시 추가 데이터로 누적.
|
||||
|
||||
### 6.6 ree 패킷 (single-channel end)
|
||||
- multi-channel 모드에서는 무시. 신구조에서 채널 구분은 ch_num이 담당.
|
||||
- single-channel 모드에서는 `finishSingleChannel()` 호출 → `onComplete(PiezoEchoResult)`.
|
||||
|
||||
---
|
||||
|
||||
## 7. IMU 응답 상세 (rim)
|
||||
|
||||
파일: [`ImuPacketCollector.kt`](../app/src/main/java/com/example/medilightv2android/ble/ImuPacketCollector.kt)
|
||||
|
||||
### 7.1 패킷 구조 (188B)
|
||||
```
|
||||
[tag 4B "rim:"][num_sample 2B BE][imu_raw 180B][crc 2B]
|
||||
↑0 ↑4 ↑6 ↑186
|
||||
```
|
||||
|
||||
`imu_raw` = N 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**.
|
||||
|
||||
⚠️ **labdb payload에서 `sensor.imu` 단일 필드는 마지막(newest) 샘플 사용** — 측정 순간 자세를 정확히 반영. (이전 버그: 첫 샘플 사용으로 약간 옛 자세 반영)
|
||||
|
||||
### 7.2 스케일링
|
||||
```kotlin
|
||||
private const val ACCEL_LSB_PER_G = 8192f // ±4g FSR
|
||||
private const val GYRO_LSB_PER_DPS = 65.536f // ±500 dps FSR
|
||||
|
||||
ax = axRaw / ACCEL_LSB_PER_G // → g 단위 (1 = 중력 가속도)
|
||||
gx = gxRaw / GYRO_LSB_PER_DPS // → dps 단위
|
||||
```
|
||||
|
||||
### 7.3 길이 검증
|
||||
```kotlin
|
||||
val numSamples = readBe16(data, 4)
|
||||
val payloadBytes = data.size - 6 - 2 // tag+num 빼고 CRC 빼고
|
||||
val expectedBytes = numSamples * 12
|
||||
if (payloadBytes < expectedBytes) {
|
||||
onLog("rim: payload short ...")
|
||||
return // 0 samples 처리
|
||||
}
|
||||
```
|
||||
|
||||
→ 펌웨어가 num_sample=13으로 보고하면 13개만 파싱. 우리 파서는 잘림 없음.
|
||||
|
||||
### 7.4 알려진 quirk — 첫 사이클 IMU
|
||||
- 측정 시작 직후 첫 mtb 응답의 rim이 **13 samples**로 도착할 수 있음 (FW IMU FIFO가 아직 15개 미충전 상태)
|
||||
- 25 cycles 중 cycle 0만 13, 나머지는 15
|
||||
- 분석 시 cycle 0 제외 또는 padding 처리. 측정 자체에는 영향 없음.
|
||||
|
||||
---
|
||||
|
||||
## 8. 디바이스 정보 (rid)
|
||||
|
||||
파일: [`BleManager.kt:1045-1066`](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1045)
|
||||
|
||||
### 8.1 패킷 구조
|
||||
```
|
||||
[tag 4B "rid:"][ASCII payload 가변]
|
||||
```
|
||||
|
||||
payload 예: `"VBTHW0100 VBT26040001 VBTFW0111"` (HW, Serial, FW 순서)
|
||||
|
||||
### 8.2 NULL byte sanitize (★ 중요)
|
||||
BLE 응답 끝에 0x00 padding이 붙는 경우가 있음. `String(US_ASCII).trim()`은 whitespace만 제거하고 NULL은 남김 → labdb upload 시 PostgreSQL JSONB가 0x00 거부 → HTTP 500.
|
||||
|
||||
```kotlin
|
||||
val info = String(data, 4, data.size - 4, US_ASCII)
|
||||
.replace(Regex("[\\x00-\\x1F\\x7F]"), " ") // C0 control + DEL → 공백
|
||||
.trim()
|
||||
```
|
||||
|
||||
### 8.3 파싱
|
||||
```kotlin
|
||||
val parts = info.split(Regex("\\s+")).filter { it.isNotBlank() }
|
||||
val hw = parts.firstOrNull { it.startsWith("VBTHW") } ?: ""
|
||||
val fw = parts.firstOrNull { it.startsWith("VBTFW") } ?: ""
|
||||
val sn = parts.firstOrNull { it.startsWith("VBT") && !it.startsWith("VBTHW") && !it.startsWith("VBTFW") } ?: ""
|
||||
```
|
||||
|
||||
`handler.post { ... }` 로 메인 스레드에서 `hardwareVersion / firmwareVersion / serialNumber` mutableState 갱신.
|
||||
|
||||
---
|
||||
|
||||
## 9. 배터리 (rsn)
|
||||
|
||||
파일: [`BleManager.kt:1031-1043`](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1031)
|
||||
|
||||
### 9.1 패킷 구조
|
||||
```
|
||||
[tag 4B "rsn:"][millivolt 2B BE][...]
|
||||
```
|
||||
|
||||
byte 4~5 = 배터리 전압 (mV) Big Endian.
|
||||
|
||||
### 9.2 mV → % 변환 (segmented curve)
|
||||
```kotlin
|
||||
val millivolts = ((data[4] and 0xFF) shl 8) or (data[5] and 0xFF)
|
||||
val pct = when {
|
||||
millivolts <= 3500 -> 0 // 방전
|
||||
millivolts <= 3700 -> ((millivolts - 3500) * 5 / 200) // 0~5%
|
||||
else -> 5 + (millivolts - 3700) * 95 / 400 // 5~100%
|
||||
}.coerceIn(0, 100)
|
||||
```
|
||||
|
||||
### 9.3 Hysteresis 가드
|
||||
```kotlin
|
||||
if (pct <= batteryLevel.value || batteryLevel.value == 0) {
|
||||
batteryLevel.value = pct
|
||||
}
|
||||
```
|
||||
- 새 값이 기존보다 크거나 같으면 무시 (노이즈 hysteresis)
|
||||
- 단 `batteryLevel.value == 0` 첫 응답은 항상 통과
|
||||
- ⚠️ 충전 중 상승은 반영 안 됨 — 의도된 동작
|
||||
|
||||
### 9.4 Polling 정책 (★ 2026-06-05 변경)
|
||||
파일: [`BleManager.kt:517-565`](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L517)
|
||||
|
||||
```
|
||||
1. 연결 직후 즉시 1회 msn? 송신
|
||||
2. 3초 후 batteryLevel.value == 0 이면 재송신 (max 2회 재시도)
|
||||
3. 30초 주기 정상 polling
|
||||
```
|
||||
|
||||
→ 이전엔 "1회 송신만"이라 그 한 번이 큐 경합으로 손실되면 영원히 0%였음.
|
||||
|
||||
---
|
||||
|
||||
## 10. 측정 헤더 (rbb)
|
||||
|
||||
파일: [`BleManager.kt:1075-1078`](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L1075)
|
||||
|
||||
```
|
||||
[tag 4B "rbb:"][batt 2B][IMU 2B][temp 2B][crc 2B] = 22B
|
||||
```
|
||||
|
||||
- `batt`: 배터리 mV (uint16 BE)
|
||||
- `IMU`: IMU 상태 (uint16 BE) — 의미는 펌웨어 정의
|
||||
- `temp`: 온도 × 100 °C (uint16 BE). 변환: `temp / 100 = °C`
|
||||
- FW 내부 측정 방식 변경됨 (TMP235 ADC → IMU TEMP_DATA register, `T = 25 + raw/128`, 4× oversampling) — 클라이언트 포맷은 동일
|
||||
|
||||
`mbb?` 응답의 첫 패킷. 이후 reb×6 + raa 가 따라옴.
|
||||
|
||||
콜백: `onMbbHeaderReceived?.invoke(data)` + collector에 전달 (collector는 rbb를 skip 처리).
|
||||
|
||||
---
|
||||
|
||||
## 11. NIRS 응답 (rta / rsh / rqq / rag / rcj)
|
||||
|
||||
| Prefix | 용도 | 콜백 |
|
||||
|---|---|---|
|
||||
| `rta:` `rta!` | NIRS power ON 완료 | `onNirsPowerOnReceived` |
|
||||
| `rsh:` `rsh!` | Sensor activated (calibration mode) | `onNirsSensorActivated` |
|
||||
| `rqq:` `rqq!` | Sensor activated (monitoring mode) | `onNirsSensorActivated` |
|
||||
| `rag:` `rag!` | Gain calibration 결과. `(data.size - 6) / 2`개 값 | `onNirsMagReceived(data)` |
|
||||
| `rcj:` `rcj!` | MCJ raw 패킷 — Vivamyo monitoring 데이터 | `onNirsMcjReceived(data)` |
|
||||
|
||||
`!` 변형은 펌웨어 측 에러/특수 상태. 처리 자체는 `:` 와 동일하지만 디버그 로그에 prefix 그대로 기록.
|
||||
|
||||
NIRS 자세한 파싱은 [`NirsManager`](../app/src/main/java/com/example/medilightv2android/managers/NirsManager.kt) 참고 (Vivamyo 화면 전용).
|
||||
|
||||
---
|
||||
|
||||
## 12. Pairing / Unpairing
|
||||
|
||||
### 12.1 Pairing
|
||||
표준 GATT 연결만으로 끝. 별도 페어링 명령 없음. CCCD enable 완료 시 service ready.
|
||||
|
||||
### 12.2 Unpair (`disconnectAndUnbond()`)
|
||||
파일: [`BleManager.kt:262-310`](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L262)
|
||||
|
||||
```
|
||||
1. isUserDisconnect = true ← auto-reconnect 차단 flag
|
||||
2. cancelAutoReconnect()
|
||||
3. stopWatchdog()
|
||||
4. stopBatteryPolling()
|
||||
5. BleForegroundService.stop(context)
|
||||
6. connectionTimer cancel
|
||||
7. sendRaw(buildCommandASCII("msr", " ")) ← 기기에 unbond + reboot 명령
|
||||
8. (300ms 대기)
|
||||
├─ gatt.disconnect() + gatt.close()
|
||||
├─ characteristic/connectedDeviceName/batteryLevel 등 reset
|
||||
├─ device.removeBond() (리플렉션) ← Android 본딩 정보 제거
|
||||
└─ SharedPreferences "last_device_*" 제거
|
||||
```
|
||||
|
||||
→ 기기와 앱 양측이 페어링/본딩 정보를 완전히 잃은 상태. 다음 사용 시 DeviceScan부터.
|
||||
|
||||
---
|
||||
|
||||
## 13. 알려진 quirk / Edge cases
|
||||
|
||||
| 항목 | 원인 | 대응 |
|
||||
|---|---|---|
|
||||
| **첫 cycle IMU 13/15 samples** | FW FIFO 미충전 | 분석 시 cycle 0 제외 / payload는 그대로 저장 |
|
||||
| **rid: trailing NULL byte** | FW response padding | 클라이언트 [0x00-0x1F,0x7F] strip 필수 |
|
||||
| **연결 직후 배터리 0%** | msn? 1회 송신만 + 큐 경합으로 첫 응답 누락 | 즉시 1회 + 3s 재시도 + 30s polling으로 보강 |
|
||||
| **reb 신구조 미적용 펌웨어** | 구버전 단말 | 미지원. 현재는 신버전(210B) 전용 |
|
||||
| **maa/mtb 너무 빠른 연속 호출** | < 600ms 간격 | `canSendMaa()` 가드로 자동 drop |
|
||||
| **사용자가 모르게 측정 끊김** | 펌웨어 reboot / BLE 거리 이슈 | watchdog + scheduleAutoReconnect 백그라운드 처리 (UI banner는 표시 안 함) |
|
||||
| **Endian 잘못 감지** | 첫 ADC 샘플이 0..4095 양쪽 모두 가능한 값 | `forceBigEndian = true` (VBT 기본)로 fallback |
|
||||
| **rbb 22B 길이 검증** | 부분 수신 케이스 | length 가드 + onMbbHeaderReceived에서만 콜백 발사 |
|
||||
|
||||
---
|
||||
|
||||
## 14. 디버깅 가이드
|
||||
|
||||
### 14.1 로그 태그
|
||||
- `BleManager` — 연결/송신/큐 상태
|
||||
- `LabdbClient` — REST 호출 payload + 응답
|
||||
- `PiezoCollector` — 채널 set 진행 / drop 사유
|
||||
- `ImuCollector` — rim sample count
|
||||
|
||||
### 14.2 ble.log (텍스트 디버그 로그)
|
||||
파일: [`BleDebugLogger.kt`](../app/src/main/java/com/example/medilightv2android/services/BleDebugLogger.kt)
|
||||
|
||||
각 세션 폴더에 `ble.log` 자동 작성:
|
||||
```
|
||||
[12:34:56.123] RX reb (210B) session=2 ch=0 100samples
|
||||
[12:34:56.145] RX reb (210B) session=2 ch=1 100samples
|
||||
...
|
||||
[12:34:56.310] RX raa (8B) all-ch complete
|
||||
[12:34:56.450] RX rim (188B) IMU 15 samples
|
||||
```
|
||||
|
||||
set 무효 시 drop 사유까지 함께 기록되어 사후 분석 가능.
|
||||
|
||||
### 14.3 직접 점검 명령
|
||||
- adb logcat 필터: `adb logcat -s BleManager PiezoCollector LabdbClient`
|
||||
- BLE 패킷 sniffing: nRF Connect 앱 또는 별도 sniffer
|
||||
|
||||
---
|
||||
|
||||
## 15. 빠른 참조 — 한 사이클 sequence (mtb)
|
||||
|
||||
```
|
||||
T+0 App sendMtb()
|
||||
├─ canSendMaa() 통과 확인
|
||||
├─ piezoCollector.startMultiChannel(6)
|
||||
├─ imuCollector.reset()
|
||||
└─ TX: [m t b ? <SP>] + CRC (7 bytes)
|
||||
|
||||
T+~10 Device (mtb? 수신, 측정 시작)
|
||||
|
||||
T+~80 Device reb (ch=0, session=N, 210B) ───────► piezoCollector.processRebPacket
|
||||
T+~110 Device reb (ch=1, session=N, 210B) ───────► (channelResults[1] = ...)
|
||||
T+~140 Device reb (ch=2, session=N, 210B)
|
||||
T+~170 Device reb (ch=3, session=N, 210B)
|
||||
T+~200 Device reb (ch=4, session=N, 210B)
|
||||
T+~230 Device reb (ch=5, session=N, 210B)
|
||||
T+~260 Device raa (8B) ──────────────────────────► piezoCollector.processEndPacket
|
||||
(6채널 다 채워짐 → 콜백)
|
||||
onMultiChannelComplete([Ch0..Ch5])
|
||||
T+~290 Device rim (15 samples, 188B) ────────────► imuCollector.parseRim
|
||||
onComplete(samples)
|
||||
└─ pendingPiezo + imuSamples를 묶어 addCycle
|
||||
|
||||
T+600 App delay(600 - 290 = 310ms) 끝 → 다시 sendMtb()
|
||||
(한 사이클 평균 280~420ms + 안전 마진 = 600ms 주기 안정)
|
||||
```
|
||||
|
||||
평균 한 사이클: 280~420ms. ClinicalLive 600ms 주기와 정확히 맞춤.
|
||||
|
||||
---
|
||||
|
||||
## 16. iOS 포팅 매핑 힌트
|
||||
|
||||
| Kotlin/Android | Swift/iOS |
|
||||
|---|---|
|
||||
| `BluetoothGatt` | `CBPeripheral` |
|
||||
| `BluetoothGattCharacteristic` | `CBCharacteristic` |
|
||||
| `gatt.connectGatt(...)` | `centralManager.connect(peripheral)` |
|
||||
| `gatt.discoverServices()` | `peripheral.discoverServices(nil)` |
|
||||
| `gatt.setCharacteristicNotification` + CCCD write | `peripheral.setNotifyValue(true, for:)` |
|
||||
| `gatt.requestMtu(247)` | iOS는 자동 협상 (최대 185 또는 ATT_MTU 결과 사용) |
|
||||
| `gatt.requestConnectionPriority(HIGH)` | iOS 직접 API 없음 — peripheral 측 connection interval 설정 의존 |
|
||||
| `txCharacteristic.value = data` + writeCharacteristic | `peripheral.writeValue(data, for:tx, type: .withoutResponse)` |
|
||||
| `onCharacteristicChanged(value)` | `peripheral(_:didUpdateValueFor:error:)` |
|
||||
| `setOnDescriptorWrite` | `peripheral(_:didUpdateNotificationStateFor:error:)` |
|
||||
| `device.removeBond` (리플렉션) | **없음** — 사용자에게 "Settings → Bluetooth → Forget Device" 안내 |
|
||||
| `mutableStateOf` | `@Published` / `@State` |
|
||||
|
||||
---
|
||||
|
||||
**원본 소스**: `app/src/main/java/com/example/medilightv2android/ble/`
|
||||
- [`BleManager.kt`](../app/src/main/java/com/example/medilightv2android/ble/BleManager.kt)
|
||||
- [`CRC16.kt`](../app/src/main/java/com/example/medilightv2android/ble/CRC16.kt)
|
||||
- [`PiezoPacketCollector.kt`](../app/src/main/java/com/example/medilightv2android/ble/PiezoPacketCollector.kt)
|
||||
- [`ImuPacketCollector.kt`](../app/src/main/java/com/example/medilightv2android/ble/ImuPacketCollector.kt)
|
||||
|
||||
**관련 문서**: [iOS Porting Clinical Measurement](iOS_PORTING_CLINICAL_MEASUREMENT.md), [labdb.md](../labdb.md)
|
||||
@@ -0,0 +1,215 @@
|
||||
# Flavor 운영 정책 — demo (동결 안정판) vs dev (개발 진행)
|
||||
|
||||
방광 모형 테스트가 100% 통과하는 시점을 **demo flavor**로 동결하고, 모든 신규 작업은 **dev flavor**에서만 진행하기 위한 가이드.
|
||||
|
||||
---
|
||||
|
||||
## 1. 두 flavor의 정체성
|
||||
|
||||
| 항목 | demo | dev |
|
||||
|---|---|---|
|
||||
| 목적 | 시연/검증 — 결과 재현성 보장 | 개발 진행 — 모든 신규 기능/실험 |
|
||||
| applicationId | `com.example.medilightv2android.demo` | `com.example.medilightv2android` |
|
||||
| 표시명 | VesiScan Demo | VesiScan Dev |
|
||||
| versionName suffix | `-demo` | (없음) |
|
||||
| BuildConfig.IS_DEMO | `true` | `false` |
|
||||
| BuildConfig.FLAVOR_LABEL | `"demo"` | `"dev"` |
|
||||
| 동시 설치 | 가능 (applicationId 다름) | 가능 |
|
||||
|
||||
→ 한 단말에 두 빌드를 동시에 설치해서 같은 측정을 양쪽에서 돌려보고 결과 비교 가능.
|
||||
|
||||
---
|
||||
|
||||
## 2. 빌드 명령
|
||||
|
||||
```bash
|
||||
# demo flavor
|
||||
./gradlew assembleDemoDebug # debug APK
|
||||
./gradlew assembleDemoRelease # release APK
|
||||
|
||||
# dev flavor
|
||||
./gradlew assembleDevDebug # debug APK (일상 개발)
|
||||
./gradlew assembleDevRelease
|
||||
|
||||
# 두 flavor 모두 빌드
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
Android Studio에서는 **Build Variants** 패널에서 `demoDebug` / `devDebug` / ... 중 선택.
|
||||
|
||||
---
|
||||
|
||||
## 3. 동결 메커니즘 — 두 가지 레벨
|
||||
|
||||
### Level 1: BuildConfig flag 분기 (가장 단순)
|
||||
|
||||
코드 안에서 분기:
|
||||
```kotlin
|
||||
if (BuildConfig.IS_DEMO) {
|
||||
// demo 전용 동작 (예: 디버그 panel 숨김, 특정 알고리즘 강제)
|
||||
} else {
|
||||
// dev 전용
|
||||
}
|
||||
```
|
||||
|
||||
장점: 같은 파일에서 처리 가능.
|
||||
단점: 시간이 지나면서 main 코드가 변경되면 demo 동작도 같이 영향받음. **진정한 동결은 안 됨.**
|
||||
|
||||
### Level 2: Source set 격리 (★ 진짜 동결)
|
||||
|
||||
`src/demo/java/` 또는 `src/demo/res/` 에 동결 시점의 클래스/리소스를 복사하면, **dev 작업 중 main이 바뀌어도 demo flavor 빌드는 이 격리본을 사용**합니다.
|
||||
|
||||
**Gradle 컴파일 우선순위:**
|
||||
```
|
||||
demo flavor 빌드 → src/main/* + src/demo/* (같은 클래스명이면 src/demo/가 우선)
|
||||
dev flavor 빌드 → src/main/* + src/dev/*
|
||||
```
|
||||
|
||||
**예시:** V4.1 wall detection 알고리즘을 2026-06-08 시점에 동결하려면:
|
||||
```
|
||||
src/main/java/com/example/.../walldetect/V41Detector.kt ← 계속 변경됨 (dev에서 사용)
|
||||
src/demo/java/com/example/.../walldetect/V41Detector.kt ← 2026-06-08 시점 복사본 (demo에서 사용)
|
||||
```
|
||||
|
||||
main의 V41Detector를 리팩토링/실험해도 demo 빌드는 항상 격리본을 컴파일.
|
||||
|
||||
⚠️ **주의:**
|
||||
- 격리한 클래스의 시그니처가 main의 의존 코드와 호환되어야 함 (메서드 이름/파라미터 동일하게 유지)
|
||||
- main에서 V41Detector 호출부의 시그니처가 바뀌면 demo flavor 빌드 깨짐 — 그땐 src/demo/도 같이 업데이트하거나, 인터페이스를 두고 그 구현만 분리
|
||||
|
||||
---
|
||||
|
||||
## 4. 권장 동결 운영 절차
|
||||
|
||||
### 4.1 새 안정판 동결 시
|
||||
|
||||
방광 모형 테스트가 100% 통과하는 시점을 확인하면:
|
||||
|
||||
```bash
|
||||
# 1) 현재 상태에 tag 부여 (snapshot 보존)
|
||||
git tag -a demo-stable-v1.0 -m "Demo stable: bladder phantom 100% pass — 2026-06-08"
|
||||
git push origin demo-stable-v1.0
|
||||
|
||||
# 2) 동결할 핵심 클래스/리소스를 src/demo/로 복사
|
||||
# 예: V4.1, PiezoSettings 기본값, GreenZoneConstants 등
|
||||
cp app/src/main/java/.../walldetect/V41Detector.kt \
|
||||
app/src/demo/java/.../walldetect/V41Detector.kt
|
||||
|
||||
# 3) demo flavor가 격리본을 잘 쓰는지 빌드 + 시연용 폰에 설치해 검증
|
||||
./gradlew installDemoDebug
|
||||
|
||||
# 4) commit
|
||||
git add app/src/demo/ && git commit -m "Demo flavor: freeze V4.1/PiezoSettings (v1.0)"
|
||||
```
|
||||
|
||||
### 4.2 일상 개발 (dev)
|
||||
|
||||
- `src/main/` 만 변경 — `src/demo/`는 건드리지 않음
|
||||
- `assembleDevDebug` 로만 작업
|
||||
- demo 격리본의 시그니처를 깨뜨리는 API 변경은 피하거나, demo도 같이 업데이트
|
||||
|
||||
### 4.3 다음 안정판 갱신
|
||||
|
||||
새로운 안정판이 검증되면:
|
||||
```bash
|
||||
# 이전 격리본 백업 (옵션 — git history에 남아있음)
|
||||
mv app/src/demo/java/.../V41Detector.kt /tmp/V41Detector.v1.0.kt
|
||||
|
||||
# 최신 main 코드를 다시 src/demo/로 복사
|
||||
cp app/src/main/java/.../V41Detector.kt app/src/demo/java/.../V41Detector.kt
|
||||
|
||||
# 새 tag
|
||||
git tag -a demo-stable-v1.1 -m "Demo stable v1.1: ..."
|
||||
|
||||
# commit
|
||||
git add app/src/demo/ && git commit -m "Demo flavor: refresh freeze to v1.1"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 어떤 코드를 동결 후보로?
|
||||
|
||||
방광 모형 테스트 결과에 직접 영향을 주는 것들:
|
||||
|
||||
| 카테고리 | 후보 | 위치 |
|
||||
|---|---|---|
|
||||
| Wall detection | `V41Detector`, `PiezoEchoAnalyzer*` | `walldetect/`, `managers/PiezoEchoAnalyzer*.kt` |
|
||||
| 측정 파라미터 | `PiezoSettings` 기본값, `GreenZoneConstants`, `PiezoHW` preset | `models/`, `managers/` |
|
||||
| BLE 파싱 | `PiezoPacketCollector`, `ImuPacketCollector` | `ble/` |
|
||||
| Volume 계산 | `PiezoConstants.volumeMl()` 식 | `managers/PiezoEchoAnalyzer.kt` |
|
||||
|
||||
⚠️ **BLE 통신 layer (BleManager, CRC16)는 펌웨어와 직결되므로 보통 같이 가야 함** — 동결하면 펌웨어 업데이트 대응 불가.
|
||||
|
||||
⚠️ **UI/저장/labdb는 동결할 필요 거의 없음** — 결과값 자체에는 영향 안 줌.
|
||||
|
||||
→ 처음엔 **알고리즘 + 측정 파라미터**만 동결, 나머지는 main 공유 권장.
|
||||
|
||||
---
|
||||
|
||||
## 6. BuildConfig 활용 예시
|
||||
|
||||
런타임 분기가 필요한 경우 (격리할 만큼 크지 않은 케이스):
|
||||
|
||||
```kotlin
|
||||
// 예: dev에만 Clinical 메뉴 노출
|
||||
if (com.example.medilightv2android.BuildConfig.IS_DEMO.not()) {
|
||||
Button(onClick = { goClinicalHome() }) { Text("Clinical R&D") }
|
||||
}
|
||||
|
||||
// 예: demo에서는 항상 fixed parameter
|
||||
val maxVolume = if (com.example.medilightv2android.BuildConfig.IS_DEMO) {
|
||||
500 // 동결 기본값
|
||||
} else {
|
||||
appState.piezoSettings.maxVolume // 사용자 조정 허용
|
||||
}
|
||||
|
||||
// UI에서 flavor 표시 (디버그용)
|
||||
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR_LABEL})",
|
||||
fontSize = 10.sp, color = MlSecondaryText)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. CI / 배포 시 주의
|
||||
|
||||
- **demo flavor APK는 출시/시연 전용** — Play Store 등 외부 배포는 dev flavor 사용
|
||||
- demo flavor의 applicationId가 `.demo`로 끝나므로 ANR/crash 리포트도 자동 구분됨
|
||||
- demo는 안정성 보장이 핵심 — 새 라이브러리/SDK 업데이트도 보수적으로
|
||||
|
||||
---
|
||||
|
||||
## 8. 트러블슈팅
|
||||
|
||||
### Q. demo flavor 빌드 시 main의 변경사항이 자꾸 반영됨
|
||||
A. src/demo/ 에 격리본을 복사하지 않았기 때문. Level 2 동결을 적용해야 함. BuildConfig.IS_DEMO 분기만으로는 진짜 동결 안 됨.
|
||||
|
||||
### Q. 같은 클래스를 src/main/과 src/demo/에 둘 다 두면 어떻게 됨?
|
||||
A. demo flavor 빌드에서는 src/demo/ 가 우선. dev flavor 빌드에서는 src/main/ 만 사용 (src/demo는 무시).
|
||||
|
||||
### Q. src/demo/와 src/dev/ 둘 다 같은 클래스가 있으면?
|
||||
A. 각 flavor 빌드는 자기 flavor source set만 봄. demo 빌드 = main + demo, dev 빌드 = main + dev. 충돌 안 남.
|
||||
|
||||
### Q. Test (src/test/, src/androidTest/) 는 flavor 별로 분리되나?
|
||||
A. 가능. `src/testDemo/`, `src/testDev/`로 분리. 다만 현재 프로젝트엔 테스트가 거의 없어 신경 안 써도 됨.
|
||||
|
||||
### Q. Gradle sync 후 Build Variants에 demo/dev가 안 보임
|
||||
A. Android Studio 좌측 하단 **Build Variants** 패널을 켜고 Gradle sync 재실행. 처음 한 번은 sync에 30초~1분 걸림.
|
||||
|
||||
---
|
||||
|
||||
## 9. 첫 동결 시점 권장 절차 (지금 바로 할 일)
|
||||
|
||||
1. 현재 dev 빌드를 방광 모형으로 검증 — 결과값이 만족스러우면 진행
|
||||
2. `git tag -a demo-stable-v1.0 -m "Demo baseline 2026-06-08"` + push
|
||||
3. 검증된 알고리즘 클래스를 `src/demo/java/...` 로 복사
|
||||
4. `./gradlew installDemoDebug` 로 시연 폰에 설치
|
||||
5. 같은 모형으로 demo 빌드 한 번 더 측정 — 동일 결과 확인
|
||||
6. 이후 dev에서 자유롭게 작업. demo는 건드리지 않음.
|
||||
|
||||
---
|
||||
|
||||
**관련 파일:**
|
||||
- `app/build.gradle.kts` — productFlavors 정의
|
||||
- `app/src/demo/`, `app/src/dev/` — flavor 전용 source set
|
||||
- `app/src/demo/res/values/strings.xml` — app_name "VesiScan Demo"
|
||||
- `app/src/dev/res/values/strings.xml` — app_name "VesiScan Dev"
|
||||
@@ -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가 ` | ||||