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:
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user