방광 모형 테스트가 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>
22 KiB
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 viatestId+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
- 우측 Unpair 버튼 (red outline) —
- 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 결과) + rawmax값 - 그래프 위 세로선 마커: ant=green, post=orange (alpha 0.75)
- 하단 컨트롤:
- Pause/Resume — measurement loop 토글
- Capture — 현재 시점 1 사이클 강제 저장 (옵션)
- End —
endMeasurement()+ CLINICAL_HOME 복귀
autoCaptureflag = 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 setch_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 확정
- 둘 다 범위 내면
forceBigEndianflag (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()):sendRaw(buildCommandASCII("msr", " "))— 기기 측 본딩 삭제 + reboot- 300ms 후 GATT disconnect/close
- Android removeBond — iOS는 사용자에게 "Settings → Bluetooth → Forget Device" 안내 (CoreBluetooth는 unpair API 없음)
- 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. 권장 포팅 순서
-
BLE 통신 layer 먼저
- CRC16 + Command builder
- CoreBluetooth manager (connect / discover / notify)
mtb?1회 송신 → reb×6 + raa + rim 콘솔 출력으로 검증- PiezoPacketCollector / ImuPacketCollector 포팅
-
데이터 모델 + 파일 저장
- ClinicalSession, MeasurementCycle, PiezoChannelData, ImuSample
- SessionStore — 폴더 생성, meta.json/measurement.json 작성
-
UI
- ClinicalHome (입력 폼)
- ClinicalLive (6채널 grid + 600ms 루프)
-
labdb REST
- LabdbCredentials (Keychain)
- LabdbClient (register/status/upload + JSONB sanitize)
- LabdbUploader (measurement.json → payload 변환)
-
부가 — 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 스펙)