diff --git a/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt b/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt index 665d11c..aea0331 100644 --- a/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt +++ b/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt @@ -789,6 +789,7 @@ class BleManager private constructor(private val context: Context) { if (status == BluetoothGatt.GATT_SUCCESS) { if (txCharacteristic != null && rxCharacteristic != null) { isServiceReady.value = true + com.example.medilightv2android.services.AdcCsvLogger.newSession() startBatteryPolling() startWatchdog() sendDeviceInfoQuery() diff --git a/app/src/main/java/com/example/medilightv2android/services/AdcCsvLogger.kt b/app/src/main/java/com/example/medilightv2android/services/AdcCsvLogger.kt index e48c72b..7ea5702 100644 --- a/app/src/main/java/com/example/medilightv2android/services/AdcCsvLogger.kt +++ b/app/src/main/java/com/example/medilightv2android/services/AdcCsvLogger.kt @@ -6,19 +6,19 @@ import java.text.SimpleDateFormat import java.util.* /** - * ADC 데이터를 일별 CSV 파일로 저장. + * ADC 데이터를 세션별 CSV 파일로 저장. * - * 파일 위치: Downloads/VesiScan_ADC/YYYY-MM-DD.csv - * 행: scan_id × 6채널 (측정 1회 = 6행) - * 열: scan_id, timestamp, volume_ml, lr_ratio, channel, sample_0~sample_99 + * 파일 위치: Downloads/VesiScan_ADC/YYYY-MM-DD_HHmmss.csv + * newSession() 호출 시 새 파일로 전환 (BLE 연결 시 자동, 수동 호출 가능) */ object AdcCsvLogger { - private val dayFmt = SimpleDateFormat("yyyy-MM-dd", Locale.US) + private val sessionFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US) private val timeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) var lastScanId: Int = 0 private set private var scanCounter = 0 + private var sessionFile: File? = null private fun getDir(): File { val dir = File( @@ -29,24 +29,24 @@ object AdcCsvLogger { return dir } - private fun getTodayFile(): File { - return File(getDir(), "${dayFmt.format(Date())}.csv") + fun newSession() { + scanCounter = 0 + lastScanId = 0 + sessionFile = File(getDir(), "${sessionFmt.format(Date())}.csv") + } + + private fun getFile(): File { + if (sessionFile == null) newSession() + return sessionFile!! } - /** - * 6채널 ADC 데이터를 CSV에 append. - * - * @param channels 6채널 ADC 버퍼 (List>) - * @param volumeMl BV 추정 결과 (null이면 측정 실패) - * @param lrRatio lr_ratio 값 - */ fun log( channels: List>, volumeMl: Double? = null, lrRatio: Double = 1.0 ) { try { - val file = getTodayFile() + val file = getFile() val isNew = !file.exists() || file.length() == 0L scanCounter++ lastScanId = scanCounter @@ -58,7 +58,7 @@ object AdcCsvLogger { val thrStr = if (analyzer.useAdaptiveThreshold && analyzer.lastOtsuThreshold > 0) "%.0f".format(analyzer.lastOtsuThreshold) else "%.0f".format(com.example.medilightv2android.managers.GreenZoneConstants.lowEchoAmp) val dpsStr = "%.3f".format(com.example.medilightv2android.managers.PiezoHW.distancePerSample) - val sgStr = if (com.example.medilightv2android.managers.PiezoEchoAnalyzer.shared.useSgFilter) "ON" else "OFF" + val sgStr = if (analyzer.useSgFilter) "ON" else "OFF" java.io.FileWriter(file, !isNew).buffered().use { writer -> if (isNew) { @@ -76,7 +76,6 @@ object AdcCsvLogger { for (sample in buffer) { append(",${sample.toInt()}") } - // 100개 미만이면 빈 칸으로 채움 for (i in buffer.size until 100) { append(",") } @@ -85,8 +84,6 @@ object AdcCsvLogger { writer.newLine() } } - } catch (_: Exception) { - // 로깅 실패는 앱 crash 방지 - } + } catch (_: Exception) {} } } diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt index 5ed5efb..cf45846 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt @@ -117,7 +117,9 @@ fun PiezoMonitoringView(appState: AppState) { Log.d("PiezoMonitor", "Sending measurement command...") bleManager.piezoCollector.onMultiChannelComplete = { channels -> - handler.post { + // 분석을 백그라운드에서 수행 후 UI 업데이트만 메인으로 + Thread { + val analysisStart = System.currentTimeMillis() lastMultiChannelResult.clear() lastMultiChannelResult.addAll(channels) @@ -235,9 +237,9 @@ fun PiezoMonitoringView(appState: AppState) { bleManager.debugLogger.warn("BV_SKIP scan=${com.example.medilightv2android.services.AdcCsvLogger.lastScanId} center=$centerWallCount/4 total=$totalWallCount/6") com.example.medilightv2android.services.AdcCsvLogger.log(rawADC, null) if (!isAutoMeasuring) { - android.widget.Toast.makeText(context, + handler.post { android.widget.Toast.makeText(context, "측정 부족 (${centerWallCount}/4채널) — 센서 위치를 조절해주세요", - android.widget.Toast.LENGTH_SHORT).show() + android.widget.Toast.LENGTH_SHORT).show() } } } } else { @@ -246,44 +248,51 @@ fun PiezoMonitoringView(appState: AppState) { val failADC = channels.sortedBy { it.channel }.map { it.buffer } com.example.medilightv2android.services.AdcCsvLogger.log(failADC, null) if (!isAutoMeasuring) { - android.widget.Toast.makeText(context, + handler.post { android.widget.Toast.makeText(context, "방광이 감지되지 않았습니다. 센서 접촉을 확인해주세요.", - android.widget.Toast.LENGTH_SHORT).show() + android.widget.Toast.LENGTH_SHORT).show() } } } - // 볼륨 업데이트: Auto면 윈도우 trimmed mean (valid≥4만), Spot이면 즉시 - if (rawVolumeMl != null && rawVolumeMl > 0) { - if (isAutoMeasuring) { - if (totalWallCount >= 4) { - autoVolumeWindow.add(rawVolumeMl) - Log.d("PiezoMonitor", "AUTO window[${autoVolumeWindow.size}/$autoWindowSize] +${"%.1f".format(rawVolumeMl)}ml (valid=$totalWallCount)") + val finalRawVol = rawVolumeMl + val finalTotalWalls = totalWallCount + val analysisMs = System.currentTimeMillis() - analysisStart + Log.d("PiezoMonitor", "Analysis took ${analysisMs}ms") + + // UI 업데이트는 메인 스레드에서 + handler.post { + if (finalRawVol != null && finalRawVol > 0) { + if (isAutoMeasuring) { + if (finalTotalWalls >= 4) { + autoVolumeWindow.add(finalRawVol) + Log.d("PiezoMonitor", "AUTO window[${autoVolumeWindow.size}/$autoWindowSize] +${"%.1f".format(finalRawVol)}ml (valid=$finalTotalWalls)") + } else { + Log.d("PiezoMonitor", "AUTO skip ${"%.1f".format(finalRawVol)}ml (valid=$finalTotalWalls < 4)") + } + if (autoVolumeWindow.size >= autoWindowSize) { + val displayVol = trimmedMean(autoVolumeWindow.toList()) + lastVolumeMl = displayVol + maxVolumeMl = maxOf(maxVolumeMl, displayVol) + Log.d("PiezoMonitor", "AUTO trimmedMean=${"%.1f".format(displayVol)}ml from ${autoVolumeWindow.map { "%.0f".format(it) }}") + autoVolumeWindow.clear() + } } else { - Log.d("PiezoMonitor", "AUTO skip ${"%.1f".format(rawVolumeMl)}ml (valid=$totalWallCount < 4)") + lastVolumeMl = finalRawVol + maxVolumeMl = maxOf(maxVolumeMl, finalRawVol) } - if (autoVolumeWindow.size >= autoWindowSize) { - val displayVol = trimmedMean(autoVolumeWindow.toList()) - lastVolumeMl = displayVol - maxVolumeMl = maxOf(maxVolumeMl, displayVol) - Log.d("PiezoMonitor", "AUTO trimmedMean=${"%.1f".format(displayVol)}ml from ${autoVolumeWindow.map { "%.0f".format(it) }}") - autoVolumeWindow.clear() - } - } else { - lastVolumeMl = rawVolumeMl - maxVolumeMl = maxOf(maxVolumeMl, rawVolumeMl) } - } - if (lastVolumeMl > 0) { - val maxVol = appState.piezoSettings.maxVolume.toDouble() - val pct = if (maxVol > 0) (lastVolumeMl / maxVol).coerceAtMost(1.0) else 0.0 - val level = (pct * BladderLevel.MAX_LEVEL).toInt().coerceIn(1, BladderLevel.MAX_LEVEL) - appState.bladderLevel = appState.bladderLevel.copy(currentLevel = level) + if (lastVolumeMl > 0) { + val maxVol = appState.piezoSettings.maxVolume.toDouble() + val pct = if (maxVol > 0) (lastVolumeMl / maxVol).coerceAtMost(1.0) else 0.0 + val level = (pct * BladderLevel.MAX_LEVEL).toInt().coerceIn(1, BladderLevel.MAX_LEVEL) + appState.bladderLevel = appState.bladderLevel.copy(currentLevel = level) + } + isMeasuring = false + measureRetryCount = 0 + try { measureDoneSignal.complete(Unit) } catch (_: Exception) {} } - isMeasuring = false - measureRetryCount = 0 - try { measureDoneSignal.complete(Unit) } catch (_: Exception) {} - } + }.start() } bleManager.piezoCollector.onLog = { msg -> Log.d("PiezoCollector", msg) } diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt index db905e9..613ed8f 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt @@ -368,10 +368,12 @@ fun PlacementGuideView(appState: AppState) { verticalArrangement = Arrangement.spacedBy(6.dp) ) { Text("Echo Waveforms", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = MlPrimary) - for (chData in lastChannelData.filter { it.buffer.isNotEmpty() }) { - val detected = if (chData.channel < channelDetected.size) channelDetected[chData.channel] else false - val chAnalysis = analyzer.analyzeChannel(chData.buffer, chData.channel) - val showWalls = chAnalysis.result + for (ch in 0 until 6) { + val chData = lastChannelData.find { it.channel == ch } + val detected = if (ch < channelDetected.size) channelDetected[ch] else false + val showWalls = if (chData != null && chData.buffer.isNotEmpty()) { + analyzer.analyzeChannel(chData.buffer, ch).result + } else null Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Box( @@ -379,7 +381,7 @@ fun PlacementGuideView(appState: AppState) { .background(if (detected) Color(0xFF4CAF50) else Color.Red.copy(alpha = 0.4f)) ) Spacer(modifier = Modifier.width(4.dp)) - Text("CH${chData.channel}", fontSize = 11.sp, fontWeight = FontWeight.Bold) + Text("CH$ch", fontSize = 11.sp, fontWeight = FontWeight.Bold) if (showWalls != null) { Spacer(modifier = Modifier.width(6.dp)) Text( @@ -388,16 +390,23 @@ fun PlacementGuideView(appState: AppState) { ) } } - PlacementWaveformChart( - buffer = chData.buffer, - peakIndex = chData.peakIndex.toInt(), - analyzer = analyzer, - antIndex = showWalls?.ant, - postIndex = showWalls?.post, - modifier = Modifier.fillMaxWidth().height(80.dp) - ) + if (chData != null && chData.buffer.isNotEmpty()) { + PlacementWaveformChart( + buffer = chData.buffer, + peakIndex = chData.peakIndex.toInt(), + analyzer = analyzer, + antIndex = showWalls?.ant, + postIndex = showWalls?.post, + modifier = Modifier.fillMaxWidth().height(80.dp) + ) + } else { + Box(modifier = Modifier.fillMaxWidth().height(80.dp).background(Color.Gray.copy(alpha = 0.05f), RoundedCornerShape(4.dp)), + contentAlignment = Alignment.Center) { + Text("No data", fontSize = 10.sp, color = MlSecondaryText.copy(alpha = 0.5f)) + } + } } - if (chData.channel < lastChannelData.last().channel) { + if (ch < 5) { HorizontalDivider(color = Color.Gray.copy(alpha = 0.2f)) } }