diff --git a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt index a4f0bad..677e4f8 100644 --- a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt +++ b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt @@ -1833,7 +1833,16 @@ class BleManager private constructor(private val context: Context) { debugLogger.rx("rls", data.size, "led_state=$state") } "rbb:" -> { - debugLogger.rx("rbb", data.size, "full measurement header (battery+IMU+temp)") + // 값을 함께 남긴다 — 이 응답이 배터리 소모 실측의 유일한 10초 주기 표본인데 + // 예전에는 고정 문자열만 남아 파일만 보고는 전압 추이를 전혀 알 수 없었다. + // batt 는 첫 필드라 offset 4, temp 는 CRC 직전이라 size-4 (BatteryDrainTester 와 동일 규약). + val detail = if (data.size >= 12) { + val mv = ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF) + val t = (((data[data.size - 4].toInt() and 0xFF) shl 8) or + (data[data.size - 3].toInt() and 0xFF)) / 100f + "battery=${mv}mV" + if (t in 0f..80f) " temp=%.2fC".format(t) else "" + } else "full measurement header (battery+IMU+temp)" + debugLogger.rx("rbb", data.size, detail) onMbbHeaderReceived?.invoke(data) piezoCollector.addPacket(data) } diff --git a/app/src/main/java/com/medithings/vesiscan/dev/BatteryDrainTestDialog.kt b/app/src/main/java/com/medithings/vesiscan/dev/BatteryDrainTestDialog.kt index 11bd285..0fef57c 100644 --- a/app/src/main/java/com/medithings/vesiscan/dev/BatteryDrainTestDialog.kt +++ b/app/src/main/java/com/medithings/vesiscan/dev/BatteryDrainTestDialog.kt @@ -122,6 +122,17 @@ fun BatteryDrainTestDialog( Spacer(Modifier.height(8.dp)) StatRow("소모율", "%.1f mV/h".format(mvPerHour)) } + + // 기록 파일 이름을 띄운다 — 장시간 돌린 뒤 Downloads 에서 어느 파일을 + // 꺼내야 하는지 화면에서 바로 알 수 있어야 한다. 열기 실패도 여기서 보인다. + if (running) { + Spacer(Modifier.height(8.dp)) + StatRow( + "기록", + BatteryDrainTester.csvFile?.name ?: "CSV 생성 실패", + valueColor = if (BatteryDrainTester.csvFile == null) MlCritical else Color.Unspecified, + ) + } } }, confirmButton = { diff --git a/app/src/main/java/com/medithings/vesiscan/dev/BatteryDrainTester.kt b/app/src/main/java/com/medithings/vesiscan/dev/BatteryDrainTester.kt index 7e47e09..a234228 100644 --- a/app/src/main/java/com/medithings/vesiscan/dev/BatteryDrainTester.kt +++ b/app/src/main/java/com/medithings/vesiscan/dev/BatteryDrainTester.kt @@ -1,9 +1,14 @@ package com.medithings.vesiscan.dev +import android.os.Environment import android.os.Handler import android.os.Looper import androidx.compose.runtime.mutableStateOf import com.medithings.vesiscan.ble.BleManager +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale /** * Dev 전용 · 기기 배터리 성능(소모율) 테스트 드라이버. @@ -54,9 +59,84 @@ object BatteryDrainTester { /** 마지막 응답 수신 시각 (wall clock). 0 = 없음. */ val lastRxAtMs = mutableStateOf(0L) + /** 진행 요약(BLE 로그 + CSV heartbeat)을 남기는 주기. */ + const val SAMPLE_INTERVAL_MS = 60_000L + private val handler = Handler(Looper.getMainLooper()) private var ble: BleManager? = null + // ── 실측 CSV ──────────────────────────────────────────────────────────── + // + // BLE 로그(VesiScan_BLE_*.log)에는 초당 수 줄이 섞여 들어가서, 50시간을 돌리면 + // 수십만 줄에서 전압만 골라내야 한다. 소모율 분석에 필요한 것은 (시각, 전압, 온도) + // 뿐이므로 그것만 따로 CSV 로 남긴다 — 10초 주기면 50시간에 18,000행이라 그대로 + // 스프레드시트에 올라간다. + // + // **rbb 가 안 와도 1분마다 한 행을 남긴다.** 프로브가 죽거나 BLE 가 끊기면 rbb 가 + // 멈추는데, 그때 CSV 도 같이 멈추면 "언제 끊겼는지"를 파일만 보고는 알 수 없다. + // 끊긴 구간에도 행이 있어야 그 구간이 보인다. + + /** 이번 테스트의 CSV. null = 아직 안 열렸거나 열기 실패. */ + var csvFile: File? = null + private set + + private var csvWriter: java.io.Writer? = null + private val csvLock = Any() + private val isoFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) + private val nameFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US) + + private fun openCsv(startedAt: Long) { + synchronized(csvLock) { + val name = "VesiScan_BattDrain_${nameFmt.format(Date(startedAt))}.csv" + // BLE 로그와 같은 자리에 둔다 — 한 번에 같이 꺼내게. + val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + try { + dir?.mkdirs() + val f = File(dir, name) + val w = java.io.BufferedWriter(java.io.FileWriter(f, true)) + w.write("time,elapsed_s,source,batt_mv,temp_c,connected,imu_sent,imu_recv,imu_skip,full_sent,full_recv,full_skip\n") + w.flush() + csvFile = f + csvWriter = w + } catch (_: Exception) { + csvFile = null + csvWriter = null + } + } + } + + /** + * CSV 한 행. [source] 는 `rbb`(테스트 부하의 응답) 또는 `tick`(1분 heartbeat). + * + * 줄마다 flush 한다 — 50시간짜리 측정에서 앱이 죽었을 때 버퍼에 남은 마지막 몇 + * 시간이 통째로 날아가면 그 테스트는 다시 돌려야 한다. + */ + private fun csvRow(source: String) { + synchronized(csvLock) { + val w = csvWriter ?: return + val now = System.currentTimeMillis() + val temp = lastTempC.value + try { + w.write( + "${isoFmt.format(Date(now))},${elapsedMs() / 1000},$source," + + "${lastBatteryMv.value}," + + (if (temp.isNaN()) "" else "%.2f".format(temp)) + "," + + "${if (ble?.isConnected?.value == true) 1 else 0}," + + "${imuSent.value},${imuRecv.value},${imuSkipped.value}," + + "${fullSent.value},${fullRecv.value},${fullSkipped.value}\n" + ) + w.flush() + } catch (_: Exception) {} + } + } + + private fun closeCsv() { + synchronized(csvLock) { + try { csvWriter?.apply { flush(); close() } } catch (_: Exception) {} + csvWriter = null + } + } + // ── 부하 루프 ── private val imuTick = object : Runnable { @@ -90,6 +170,27 @@ object BatteryDrainTester { } } + /** + * 1분 heartbeat — CSV 한 행 + BLE 로그 한 줄. + * + * 이게 있어야 **테스트가 언제 멈췄는지** 알 수 있다. 예전에는 시작·정지 두 줄뿐이라, + * 50시간을 걸어 놓고 중간에 프로세스가 죽으면 정지 줄조차 안 남아서 어디까지가 + * 유효한 구간인지 판별할 수 없었다. + */ + private val sampleTick = object : Runnable { + override fun run() { + if (!isRunning.value) return + csvRow("tick") + ble?.debugLogger?.info( + "BATT_DRAIN t=${elapsedMs() / 1000}s batt=${lastBatteryMv.value}mV " + + (if (lastTempC.value.isNaN()) "" else "temp=%.2fC ".format(lastTempC.value)) + + "mim=${imuRecv.value}/${imuSent.value}(skip ${imuSkipped.value}) " + + "mbb=${fullRecv.value}/${fullSent.value}(skip ${fullSkipped.value})" + ) + handler.postDelayed(this, SAMPLE_INTERVAL_MS) + } + } + /** ⚠ BLE 콜백 스레드에서 호출됨. Compose state write 만 (기존 batteryLevel 갱신과 동일 패턴). */ private val frameTap: (String, ByteArray) -> Unit = { tag, data -> when (tag) { @@ -112,6 +213,8 @@ object BatteryDrainTester { val t = be16(data, data.size - 4) / 100f if (t in 0f..80f) lastTempC.value = t } + // 실측 표본 — 부하 주기(10초)마다 한 행. + csvRow("rbb") } // 30초 주기 msn? polling 응답 — 부하 테스트가 굴리진 않지만 전압 소스로 활용. "rsn" -> if (data.size >= 6) noteBatteryMv(be16(data, 4)) @@ -145,19 +248,25 @@ object BatteryDrainTester { startedAtMs.value = System.currentTimeMillis() isRunning.value = true bleManager.onResponseFrame = frameTap + openCsv(startedAtMs.value) bleManager.debugLogger.info( - "BATT_DRAIN start · mim=${IMU_INTERVAL_MS}ms mbb=${FULL_INTERVAL_MS}ms" + "BATT_DRAIN start · mim=${IMU_INTERVAL_MS}ms mbb=${FULL_INTERVAL_MS}ms " + + "csv=${csvFile?.absolutePath ?: "OPEN_FAILED"}" ) // mim 을 먼저 태워 첫 tick 이 mbb 응답과 겹치지 않게 한다. handler.post(imuTick) handler.postDelayed(fullTick, FULL_INTERVAL_MS) + handler.postDelayed(sampleTick, SAMPLE_INTERVAL_MS) } fun stop() { if (!isRunning.value) return + csvRow("stop") isRunning.value = false handler.removeCallbacks(imuTick) handler.removeCallbacks(fullTick) + handler.removeCallbacks(sampleTick) + closeCsv() ble?.let { b -> if (b.onResponseFrame === frameTap) b.onResponseFrame = null b.debugLogger.info(