diff --git a/app/src/main/java/com/example/medilightv2android/services/ClinicalSessionStore.kt b/app/src/main/java/com/example/medilightv2android/services/ClinicalSessionStore.kt index 7a24992..e182677 100644 --- a/app/src/main/java/com/example/medilightv2android/services/ClinicalSessionStore.kt +++ b/app/src/main/java/com/example/medilightv2android/services/ClinicalSessionStore.kt @@ -2,13 +2,24 @@ package com.example.medilightv2android.services import android.content.Context import android.os.Environment +import com.example.medilightv2android.ble.ImuSample +import com.example.medilightv2android.ble.PiezoChannelData import com.example.medilightv2android.models.ClinicalSession +import org.json.JSONArray import org.json.JSONObject import java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +/** 한 측정 사이클 = mtb 응답 1회 = 6채널 piezo + 15 IMU 샘플. */ +data class MeasurementCycle( + val cycle: Int, + val timestampMs: Long, + val piezoChannels: Map>, // channel idx → samples + val imuSamples: List +) + /** * 임상 측정 단위(한 측정 = 한 라벨 = 한 폴더) 전역 store. * @@ -41,6 +52,9 @@ object ClinicalSessionStore { var lastTrueVolumeMl: String = "" // 문자열로 보관 (입력 폼과 동일 형식) var lastAbdomenThicknessMm: String = "" + /** End 시점에 measurement.json 통합 생성용 — 측정 중 사이클 누적 */ + private val cyclesBuffer = mutableListOf() + fun startMeasurement(context: Context, session: ClinicalSession) { currentSession = session lastExaminer = session.examiner @@ -48,10 +62,25 @@ object ClinicalSessionStore { lastSubjectId = session.subjectId lastTrueVolumeMl = session.trueVolumeMl?.toString() ?: "" lastAbdomenThicknessMm = session.abdomenThicknessMm?.toString() ?: "" + cyclesBuffer.clear() currentLogDir()?.mkdirs() writeMeta(context) } + /** 한 사이클(piezo + IMU) 누적 — End 시 measurement.json에 합쳐짐 */ + fun addCycle(piezo: List, imu: List) { + if (currentSession == null) return + cyclesBuffer.add(MeasurementCycle( + cycle = cyclesBuffer.size, + timestampMs = System.currentTimeMillis(), + piezoChannels = piezo.sortedBy { it.channel } + .associate { it.channel to it.buffer.map { s -> s.toInt() } }, + imuSamples = imu.toList(), + )) + } + + val cycleCount: Int get() = cyclesBuffer.size + /** 현재 측정의 폴더 (= 모든 로거의 저장 디렉토리). */ fun currentLogDir(): File? { val s = currentSession ?: return null @@ -110,17 +139,95 @@ object ClinicalSessionStore { File(dir, "meta.json").writeText(meta.toString(2)) } - /** 측정 종료: endedAt 기록 → meta.json 갱신 → currentSession=null */ + /** 측정 종료: endedAt → meta.json 갱신 → measurement.json 통합 생성 → currentSession=null */ fun endMeasurement(context: Context): File? { val s = currentSession ?: return null s.endedAt = System.currentTimeMillis() writeMeta(context) + writeMeasurementJson(context) val dir = currentLogDir() lastFinalizedFolder = dir currentSession = null + cyclesBuffer.clear() return dir } + /** + * End Session 시점에 모든 사이클을 단일 measurement.json으로 통합. + * 분석 PC에서 한 줄 로드: data = json.load(open('measurement.json')) + */ + private fun writeMeasurementJson(context: Context) { + val s = currentSession ?: return + val dir = currentLogDir() ?: return + dir.mkdirs() + try { + val root = JSONObject() + + // meta: writeMeta()와 동일 내용 포함 (단독 파일로 자족 가능) + val meta = JSONObject().apply { + put("subject_id", s.subjectId) + put("device_name", s.deviceName) + put("posture", s.posture.name) + put("step", s.step.name) + put("is_alignment", s.step.isAlignment) + put("examiner", s.examiner) + put("notes", s.notes) + put("label", s.label) + s.trueVolumeMl?.let { put("true_volume_ml", it) } + s.abdomenThicknessMm?.let { put("abdomen_thickness_mm", it) } + put("started_at", s.startedAt) + put("started_at_iso", + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(s.startedAt))) + s.endedAt?.let { + put("ended_at", it) + put("ended_at_iso", + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(it))) + put("duration_ms", it - s.startedAt) + } + put("captured_cycles", cyclesBuffer.size) + put("app_version_name", try { + context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "unknown" + } catch (_: Exception) { "unknown" }) + val ble = com.example.medilightv2android.ble.BleManager.current + ble?.firmwareVersion?.value?.takeIf { it.isNotEmpty() }?.let { put("firmware_version", it) } + ble?.hardwareVersion?.value?.takeIf { it.isNotEmpty() }?.let { put("hardware_version", it) } + ble?.serialNumber?.value?.takeIf { it.isNotEmpty() }?.let { put("serial_number", it) } + } + root.put("meta", meta) + + // cycles: 모든 사이클을 piezo + imu 묶음으로 + val cyclesArr = JSONArray() + for (c in cyclesBuffer) { + val piezo = JSONObject() + c.piezoChannels.forEach { (ch, samples) -> + piezo.put("CH$ch", JSONArray(samples)) + } + val imuArr = JSONArray() + for (sample in c.imuSamples) { + imuArr.put(JSONObject().apply { + put("ax", sample.ax.toDouble()) + put("ay", sample.ay.toDouble()) + put("az", sample.az.toDouble()) + put("gx", sample.gx.toDouble()) + put("gy", sample.gy.toDouble()) + put("gz", sample.gz.toDouble()) + }) + } + cyclesArr.put(JSONObject().apply { + put("cycle", c.cycle) + put("timestamp_ms", c.timestampMs) + put("piezo", piezo) + put("imu", imuArr) + }) + } + root.put("cycles", cyclesArr) + + File(dir, "measurement.json").writeText(root.toString(2)) + } catch (_: Exception) { + // 저장 실패해도 측정 자체는 막지 않음 + } + } + /** 측정 폐기 (메타/파일은 그대로 두되 store만 비움) */ fun discardMeasurement() { currentSession = null diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalLiveView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalLiveView.kt index c989b46..dc3d2a0 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalLiveView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalLiveView.kt @@ -74,24 +74,29 @@ fun ClinicalLiveView(appState: AppState) { var lastImu by remember { mutableStateOf>(emptyList()) } var lastCycleMs by remember { mutableLongStateOf(0L) } - // BLE 콜백 등록 — 측정 사이클 결과 받음 + // BLE 콜백 — mtb 응답은 reb×6 + raa(piezo 완료) → rim(IMU 완료) 순서. + // 한 사이클 = piezo 완료 + imu 완료. piezo가 먼저 오면 buffer에 보관, imu 도착 시 합쳐 push. + var pendingPiezo by remember { mutableStateOf?>(null) } + DisposableEffect(Unit) { bleManager.piezoCollector.onMultiChannelComplete = { channels -> lastChannels = channels - cycleCount++ - lastCycleMs = System.currentTimeMillis() - // 자동 저장 모드면 매 사이클 저장 - if (autoCapture && ClinicalSessionStore.currentSession != null) { - val rawADC = channels.sortedBy { it.channel }.map { it.buffer } - AdcCsvLogger.log(rawADC, null) - capturedCount++ - } + pendingPiezo = channels } bleManager.imuCollector.onComplete = { imuSamples -> lastImu = imuSamples - if (autoCapture && ClinicalSessionStore.currentSession != null) { + cycleCount++ + lastCycleMs = System.currentTimeMillis() + // 자동 저장: piezo + imu 묶어서 한 사이클로 누적 + 기존 CSV 저장 (재현용) + val piezo = pendingPiezo + if (autoCapture && piezo != null && ClinicalSessionStore.currentSession != null) { + ClinicalSessionStore.addCycle(piezo, imuSamples) + val rawADC = piezo.sortedBy { it.channel }.map { it.buffer } + AdcCsvLogger.log(rawADC, null) ImuCsvLogger.log(imuSamples) + capturedCount++ } + pendingPiezo = null } onDispose { bleManager.piezoCollector.onMultiChannelComplete = null @@ -195,10 +200,11 @@ fun ClinicalLiveView(appState: AppState) { Text(if (isMeasuring) "Pause" else "Resume", color = Color.White, fontWeight = FontWeight.SemiBold) } - // Manual Capture (자동 OFF일 때만 의미 있음) + // Manual Capture (Auto OFF일 때 의미 있음) Button( onClick = { if (lastChannels.isNotEmpty()) { + ClinicalSessionStore.addCycle(lastChannels, lastImu) val rawADC = lastChannels.sortedBy { it.channel }.map { it.buffer } AdcCsvLogger.log(rawADC, null) if (lastImu.isNotEmpty()) ImuCsvLogger.log(lastImu)