feat(dev): 배터리 부하 테스트 실측을 CSV 로 남긴다

50시간짜리 소모율 측정을 걸려고 보니 기록이 분석에 못 쓸 상태였다.

1) 테스트가 10초마다 굴리는 mbb? 의 응답(rbb)에 **전압·온도가 로그에 안 남았다.**
   파싱은 해서 화면에는 띄우면서 파일에는 "full measurement header (battery+IMU+temp)"
   라는 고정 문자열만 썼다. 즉 이 테스트의 유일한 10초 주기 표본이 파일에 없었다.
   (30초 주기 rsn 만 전압을 남기고 있었다.)

2) 진행 중 기록이 없었다. 시작·정지 두 줄뿐이라, 50시간을 걸어 놓고 중간에 프로세스가
   죽으면 정지 줄조차 안 남는다 — 어디까지가 유효한 구간인지 판별할 방법이 없다.

3) BLE 로그는 초당 수 줄이 섞여 들어간다(RSSI 가 68%를 차지). 50시간이면 수십만 줄에서
   전압만 골라내야 한다.

그래서 소모율 분석에 필요한 것만 별도 CSV 로 남긴다:
  Download/VesiScan_BattDrain_<시작시각>.csv
  time,elapsed_s,source,batt_mv,temp_c,connected,imu_*,full_*

10초 주기면 50시간에 18,000행이라 그대로 스프레드시트에 올라간다. 줄마다 flush 하므로
앱이 죽어도 그 시점까지는 남는다.

**rbb 가 안 와도 1분마다 한 행(source=tick)을 남긴다.** 프로브가 죽거나 BLE 가 끊기면
rbb 가 멈추는데 그때 CSV 도 같이 멈추면 "언제 끊겼는지"를 파일만 보고는 알 수 없다.
같은 주기로 BLE 로그에도 요약 한 줄을 남겨 두 파일을 맞춰 볼 수 있게 했다.

다이얼로그에 기록 파일명을 띄운다 — 장시간 돌린 뒤 Downloads 에서 어느 파일을 꺼내야
하는지 화면에서 바로 알아야 하고, 생성 실패도 여기서 보인다.

실기기 검증(23021RAA2Y · VBT26080001 · 141초): CSV 생성 · rbb 행 14건(전압 3849~3854mV,
온도 24.3~24.4C) · 60초 tick 1건 · stop 행 · BLE 로그의 rbb 줄에 값 표시 확인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 09:30:54 +09:00
parent 2e9b9b204c
commit cc950184cd
3 changed files with 131 additions and 2 deletions
@@ -1833,7 +1833,16 @@ class BleManager private constructor(private val context: Context) {
debugLogger.rx("rls", data.size, "led_state=$state") debugLogger.rx("rls", data.size, "led_state=$state")
} }
"rbb:" -> { "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) onMbbHeaderReceived?.invoke(data)
piezoCollector.addPacket(data) piezoCollector.addPacket(data)
} }
@@ -122,6 +122,17 @@ fun BatteryDrainTestDialog(
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
StatRow("소모율", "%.1f mV/h".format(mvPerHour)) 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 = { confirmButton = {
@@ -1,9 +1,14 @@
package com.medithings.vesiscan.dev package com.medithings.vesiscan.dev
import android.os.Environment
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import com.medithings.vesiscan.ble.BleManager import com.medithings.vesiscan.ble.BleManager
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/** /**
* Dev 전용 · 기기 배터리 성능(소모율) 테스트 드라이버. * Dev 전용 · 기기 배터리 성능(소모율) 테스트 드라이버.
@@ -54,9 +59,84 @@ object BatteryDrainTester {
/** 마지막 응답 수신 시각 (wall clock). 0 = 없음. */ /** 마지막 응답 수신 시각 (wall clock). 0 = 없음. */
val lastRxAtMs = mutableStateOf(0L) val lastRxAtMs = mutableStateOf(0L)
/** 진행 요약(BLE 로그 + CSV heartbeat)을 남기는 주기. */
const val SAMPLE_INTERVAL_MS = 60_000L
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private var ble: BleManager? = null 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 { 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 갱신과 동일 패턴). */ /** ⚠ BLE 콜백 스레드에서 호출됨. Compose state write 만 (기존 batteryLevel 갱신과 동일 패턴). */
private val frameTap: (String, ByteArray) -> Unit = { tag, data -> private val frameTap: (String, ByteArray) -> Unit = { tag, data ->
when (tag) { when (tag) {
@@ -112,6 +213,8 @@ object BatteryDrainTester {
val t = be16(data, data.size - 4) / 100f val t = be16(data, data.size - 4) / 100f
if (t in 0f..80f) lastTempC.value = t if (t in 0f..80f) lastTempC.value = t
} }
// 실측 표본 — 부하 주기(10초)마다 한 행.
csvRow("rbb")
} }
// 30초 주기 msn? polling 응답 — 부하 테스트가 굴리진 않지만 전압 소스로 활용. // 30초 주기 msn? polling 응답 — 부하 테스트가 굴리진 않지만 전압 소스로 활용.
"rsn" -> if (data.size >= 6) noteBatteryMv(be16(data, 4)) "rsn" -> if (data.size >= 6) noteBatteryMv(be16(data, 4))
@@ -145,19 +248,25 @@ object BatteryDrainTester {
startedAtMs.value = System.currentTimeMillis() startedAtMs.value = System.currentTimeMillis()
isRunning.value = true isRunning.value = true
bleManager.onResponseFrame = frameTap bleManager.onResponseFrame = frameTap
openCsv(startedAtMs.value)
bleManager.debugLogger.info( 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 응답과 겹치지 않게 한다. // mim 을 먼저 태워 첫 tick 이 mbb 응답과 겹치지 않게 한다.
handler.post(imuTick) handler.post(imuTick)
handler.postDelayed(fullTick, FULL_INTERVAL_MS) handler.postDelayed(fullTick, FULL_INTERVAL_MS)
handler.postDelayed(sampleTick, SAMPLE_INTERVAL_MS)
} }
fun stop() { fun stop() {
if (!isRunning.value) return if (!isRunning.value) return
csvRow("stop")
isRunning.value = false isRunning.value = false
handler.removeCallbacks(imuTick) handler.removeCallbacks(imuTick)
handler.removeCallbacks(fullTick) handler.removeCallbacks(fullTick)
handler.removeCallbacks(sampleTick)
closeCsv()
ble?.let { b -> ble?.let { b ->
if (b.onResponseFrame === frameTap) b.onResponseFrame = null if (b.onResponseFrame === frameTap) b.onResponseFrame = null
b.debugLogger.info( b.debugLogger.info(