feat(dev): 기기 배터리 성능 테스트용 임시 부하 버튼 (mim 1s / mbb 10s)

프로브 배터리 소모율 실측을 위해 홈 화면(dev 모드)에 고정 트래픽 부하를
거는 임시 버튼 추가.

- BatteryDrainTester: Handler 기반 루프 소유자. mim? 1초 / mbb? 10초 주기.
  화면 이동·백그라운드와 무관하게 계속 돌고 "정지" 로만 종료.
  mim 은 isMtbBusy 앞에서 skip (imuCollector.reset() 이 mtb/mbb 의 rim 파괴
  방지 · BLE_PROTOCOL_REFERENCE §2.6). 미연결 구간은 송신만 skip 해 auto-
  reconnect 후 부하 자동 재개.
- BatteryDrainTestDialog/Button: 송수신 카운터 · 배터리 전압 delta · 온도 ·
  마지막 응답 경과 · mV/h 소모율 실시간 표시.
- BleManager.onResponseFrame: 계측 전용 수신 프레임 tap 추가 (기존 콜백
  clobber 없이 rim/ric/rbb/rsn 집계). 기능 로직은 이 콜백에 비의존.
- PiezoMonitoringView: 부하 테스트 중이면 자체 mim 폴링 skip (중복 트래픽 방지).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 17:02:55 +09:00
parent 1f2fc56008
commit f19a56694b
5 changed files with 425 additions and 1 deletions
@@ -292,6 +292,12 @@ class BleManager private constructor(private val context: Context) {
var onReconnectionFailed: (() -> Unit)? = null
var onImuReceived: ((ByteArray) -> Unit)? = null
var onMbbHeaderReceived: ((ByteArray) -> Unit)? = null
/**
* 모든 수신 프레임 tap (3-char 태그 + raw). 파싱이 끝난 뒤 CommandQueue 완료 판정 직전 호출.
* 계측/진단 전용 (dev 배터리 부하 테스트) — 기능 로직은 이 콜백에 의존하지 않는다.
* ⚠ BLE 콜백 스레드에서 호출됨. 무거운 작업 금지.
*/
var onResponseFrame: ((String, ByteArray) -> Unit)? = null
val isBluetoothReady: Boolean get() = bluetoothAdapter?.isEnabled == true
@@ -1760,6 +1766,7 @@ class BleManager private constructor(private val context: Context) {
// True 면 pending 해제 + 큐에 대기 중이던 다음 명령 dispatch.
if (data.size >= 4) {
val tag3 = prefix.take(3)
try { onResponseFrame?.invoke(tag3, data) } catch (_: Exception) {}
commandQueue.onResponseTag(tag3)
}
}
@@ -0,0 +1,206 @@
package com.medithings.vesiscan.dev
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.ble.BleManager
import com.medithings.vesiscan.ui.theme.MlCritical
import com.medithings.vesiscan.ui.theme.MlSecondaryText
import com.medithings.vesiscan.ui.theme.MlSuccess
/**
* Dev 전용 · 기기 배터리 성능 테스트 패널.
*
* mim? 1초 / mbb? 10초 고정 부하를 걸고 송수신 카운터 · 배터리 전압 추이를 실시간 표시.
* 루프 소유자는 [BatteryDrainTester] (Handler 기반) 이므로 이 다이얼로그를 닫아도
* 테스트는 계속 진행된다. 종료는 "정지" 버튼으로만.
*/
@Composable
fun BatteryDrainTestDialog(
bleManager: BleManager,
onDismiss: () -> Unit,
) {
val running by BatteryDrainTester.isRunning
val connected by bleManager.isConnected
val battPct by bleManager.batteryLevel
val imuSent by BatteryDrainTester.imuSent
val imuRecv by BatteryDrainTester.imuRecv
val imuSkipped by BatteryDrainTester.imuSkipped
val fullSent by BatteryDrainTester.fullSent
val fullRecv by BatteryDrainTester.fullRecv
val fullSkipped by BatteryDrainTester.fullSkipped
val firstMv by BatteryDrainTester.firstBatteryMv
val lastMv by BatteryDrainTester.lastBatteryMv
val tempC by BatteryDrainTester.lastTempC
val lastRxAt by BatteryDrainTester.lastRxAtMs
// 경과 시간 1초 tick — 테스트 중일 때만 재구성.
var nowMs by remember { mutableStateOf(System.currentTimeMillis()) }
LaunchedEffect(running) {
while (running) {
nowMs = System.currentTimeMillis()
kotlinx.coroutines.delay(1000)
}
nowMs = System.currentTimeMillis()
}
// nowMs 를 key 로 둬서 tick 마다 다시 계산 (elapsedMs() 자체는 Compose state 가 아님).
val elapsed = remember(nowMs, running) { BatteryDrainTester.elapsedMs() }
val silenceSec = if (lastRxAt == 0L) -1L else (nowMs - lastRxAt) / 1000
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text("🔋 배터리 성능 테스트 (dev)", fontWeight = FontWeight.Bold, fontSize = 17.sp)
},
text = {
Column {
Text(
"mim? 1초 주기 · mbb? 10초 주기 고정 부하.\n" +
"다이얼로그를 닫아도 테스트는 계속 진행됩니다.",
fontSize = 12.sp, color = MlSecondaryText
)
Spacer(Modifier.height(12.dp))
StatRow("상태", if (running) "RUNNING" else "정지",
valueColor = if (running) MlSuccess else MlSecondaryText)
StatRow("BLE", if (connected) "연결됨" else "끊김",
valueColor = if (connected) MlSuccess else MlCritical)
StatRow("경과", BatteryDrainTester.formatElapsed(elapsed))
Spacer(Modifier.height(8.dp))
StatRow("mim (IMU)", "$imuRecv / $imuSent" + if (imuSkipped > 0) " (skip $imuSkipped)" else "")
StatRow("mbb (전체정보)", "$fullRecv / $fullSent" + if (fullSkipped > 0) " (skip $fullSkipped)" else "")
StatRow(
"마지막 응답",
when {
silenceSec < 0 -> "—"
else -> "${silenceSec}초 전"
},
valueColor = if (running && silenceSec > 15) MlCritical else Color.Unspecified
)
Spacer(Modifier.height(8.dp))
StatRow("배터리", if (battPct > 0) "$battPct%" else "—")
StatRow(
"전압",
if (lastMv > 0) {
val delta = lastMv - firstMv
"${lastMv}mV" + if (firstMv > 0 && delta != 0) " (${if (delta > 0) "+" else ""}$delta)" else ""
} else "—"
)
StatRow("온도", if (tempC.isNaN()) "—" else "%.2f°C".format(tempC))
if (elapsed > 0 && firstMv > 0 && lastMv in 1 until firstMv) {
val mvPerHour = (firstMv - lastMv) * 3_600_000.0 / elapsed
Spacer(Modifier.height(8.dp))
StatRow("소모율", "%.1f mV/h".format(mvPerHour))
}
}
},
confirmButton = {
Button(
onClick = { BatteryDrainTester.toggle(bleManager) },
enabled = running || connected,
colors = ButtonDefaults.buttonColors(
containerColor = if (running) MlCritical else MlSuccess
)
) {
Text(if (running) "정지" else "시작", color = Color.White, fontWeight = FontWeight.SemiBold)
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("닫기") }
}
)
}
@Composable
private fun StatRow(label: String, value: String, valueColor: Color = Color.Unspecified) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(label, fontSize = 13.sp, color = MlSecondaryText)
Text(
value,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
fontFamily = FontFamily.Monospace,
color = valueColor
)
}
}
/**
* HomeView 하단 dev 진입 버튼. 테스트 진행 중이면 라벨에 경과/카운터를 요약 표시해
* 다이얼로그를 열지 않아도 살아있는지 확인 가능.
*/
@Composable
fun BatteryDrainTestButton(
bleManager: BleManager,
modifier: Modifier = Modifier,
) {
val running by BatteryDrainTester.isRunning
var showDialog by remember { mutableStateOf(false) }
var nowMs by remember { mutableStateOf(System.currentTimeMillis()) }
LaunchedEffect(running) {
while (running) {
nowMs = System.currentTimeMillis()
kotlinx.coroutines.delay(1000)
}
}
// nowMs 를 key 로 둬서 tick 마다 라벨의 경과 시간을 다시 계산.
val elapsedLabel = remember(nowMs, running) {
BatteryDrainTester.formatElapsed(BatteryDrainTester.elapsedMs())
}
Button(
onClick = { showDialog = true },
modifier = modifier.height(48.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (running) MlCritical else Color(0xFF455A64)
)
) {
Text(
if (running)
"🔋 부하테스트 中 · $elapsedLabel"
else
"🔋 배터리 성능 테스트 (mim 1s / mbb 10s)",
color = Color.White, fontWeight = FontWeight.SemiBold, fontSize = 13.sp
)
}
if (showDialog) {
BatteryDrainTestDialog(bleManager = bleManager, onDismiss = { showDialog = false })
}
}
@@ -0,0 +1,195 @@
package com.medithings.vesiscan.dev
import android.os.Handler
import android.os.Looper
import androidx.compose.runtime.mutableStateOf
import com.medithings.vesiscan.ble.BleManager
/**
* Dev 전용 · 기기 배터리 성능(소모율) 테스트 드라이버.
*
* 목적: 프로브를 고정 트래픽 부하에 두고 배터리가 얼마나 버티는지 실측.
*
* 부하 프로파일:
* - `mim?` (IMU FIFO 15 sample) — **1초 주기**
* - `mbb?` (전체정보: battery + IMU + temp + 6ch piezo) — **10초 주기**
*
* 설계 노트
* - 명령 송신은 [BleManager.commandQueue] (depth=1 sequential) 를 그대로 탄다.
* 즉 mim 과 mbb 가 겹쳐도 GATT 로는 직렬화되므로 firmware queue overrun
* (VBTFW0121 freeze) 위험이 없다.
* - 그럼에도 `mim` 은 [BleManager.isMtbBusy] 를 앞에서 확인해 skip 한다.
* `sendImuFifoQuery()` 가 `imuCollector.reset()` 을 즉시 (큐 대기 전에) 호출하므로
* 진행 중인 mtb/mbb 의 rim 을 파괴할 수 있기 때문. (BLE_PROTOCOL_REFERENCE §2.6)
* - 미연결 구간에서는 송신만 skip 하고 루프는 유지한다. BleManager 의 auto-reconnect
* 가 복구하면 부하가 자동 재개돼야 장시간(수 시간) 테스트가 끊기지 않는다.
* - Handler(mainLooper) 기반이라 화면 이동/백그라운드와 무관하게 계속 돈다.
* 반드시 [stop] 으로 명시적으로 끝낼 것.
*/
object BatteryDrainTester {
const val IMU_INTERVAL_MS = 1_000L
const val FULL_INTERVAL_MS = 10_000L
// ── 관찰 가능한 상태 (Compose UI 가 구독) ──
val isRunning = mutableStateOf(false)
/** 테스트 시작 시각 (elapsedRealtime 기준 아님 · wall clock). 0 = 미시작. */
val startedAtMs = mutableStateOf(0L)
val imuSent = mutableStateOf(0)
val imuRecv = mutableStateOf(0)
/** isMtbBusy · 미연결로 송신을 건너뛴 mim tick 수. */
val imuSkipped = mutableStateOf(0)
val fullSent = mutableStateOf(0)
val fullRecv = mutableStateOf(0)
val fullSkipped = mutableStateOf(0)
/** 마지막으로 관측한 배터리 전압 (mV) · rbb 헤더 또는 rsn 응답에서 갱신. 0 = 미측정. */
val lastBatteryMv = mutableStateOf(0)
/** 테스트 시작 후 처음 관측한 배터리 전압 (mV). 소모량 delta 계산 기준. */
val firstBatteryMv = mutableStateOf(0)
/** 마지막 rbb 온도 (°C). Float.NaN = 미측정. */
val lastTempC = mutableStateOf(Float.NaN)
/** 마지막 응답 수신 시각 (wall clock). 0 = 없음. */
val lastRxAtMs = mutableStateOf(0L)
private val handler = Handler(Looper.getMainLooper())
private var ble: BleManager? = null
// ── 부하 루프 ──
private val imuTick = object : Runnable {
override fun run() {
val b = ble
if (b == null || !isRunning.value) return
when {
!b.isConnected.value -> imuSkipped.value++
// mtb/mbb 응답 stream 진행 중 — imuCollector.reset() 이 그 rim 을 파괴하므로 skip.
b.isMtbBusy -> imuSkipped.value++
else -> {
b.sendImuFifoQuery()
imuSent.value++
}
}
handler.postDelayed(this, IMU_INTERVAL_MS)
}
}
private val fullTick = object : Runnable {
override fun run() {
val b = ble
if (b == null || !isRunning.value) return
if (!b.isConnected.value) {
fullSkipped.value++
} else {
b.sendFullMeasurement()
fullSent.value++
}
handler.postDelayed(this, FULL_INTERVAL_MS)
}
}
/** ⚠ BLE 콜백 스레드에서 호출됨. Compose state write 만 (기존 batteryLevel 갱신과 동일 패턴). */
private val frameTap: (String, ByteArray) -> Unit = { tag, data ->
when (tag) {
// 완결된 IMU 프레임 1건 = mim 응답 1건.
"rim" -> { imuRecv.value++; lastRxAtMs.value = System.currentTimeMillis() }
// 저 MTU 분할 응답 — 마지막 chunk 에서만 1건으로 집계.
"ric" -> if (isRicLastChunk(data)) {
imuRecv.value++; lastRxAtMs.value = System.currentTimeMillis()
}
// 전체정보 헤더 = mbb 응답 1건.
// [tag 4B][batt 2B][IMU ...][temp 2B][crc 2B] = 22B
// ⚠ 문서(§10)의 필드 폭 합이 22B 와 맞지 않아 IMU 폭이 불확실하다.
// batt 는 첫 필드라 offset 4 로 확정, temp 는 CRC 직전 필드라
// `size - 4` 로 잡으면 IMU 폭(2B/12B) 과 무관하게 맞는다.
"rbb" -> {
fullRecv.value++
lastRxAtMs.value = System.currentTimeMillis()
if (data.size >= 12) {
noteBatteryMv(be16(data, 4))
val t = be16(data, data.size - 4) / 100f
if (t in 0f..80f) lastTempC.value = t
}
}
// 30초 주기 msn? polling 응답 — 부하 테스트가 굴리진 않지만 전압 소스로 활용.
"rsn" -> if (data.size >= 6) noteBatteryMv(be16(data, 4))
}
}
private fun noteBatteryMv(mv: Int) {
if (mv !in 2000..5000) return // 파싱 오류 / 미측정 값 방어
if (firstBatteryMv.value == 0) firstBatteryMv.value = mv
lastBatteryMv.value = mv
}
private fun be16(data: ByteArray, idx: Int): Int =
((data[idx].toInt() and 0xFF) shl 8) or (data[idx + 1].toInt() and 0xFF)
/** ric: [tag 4B][off 2B][total 2B][chunk 2B][IMU 12B×chunk][crc 2B] */
private fun isRicLastChunk(data: ByteArray): Boolean {
if (data.size < 10) return false
val off = be16(data, 4)
val total = be16(data, 6)
val chunk = be16(data, 8)
return total > 0 && off + chunk >= total
}
// ── 제어 ──
fun start(bleManager: BleManager) {
if (isRunning.value) return
ble = bleManager
resetCounters()
startedAtMs.value = System.currentTimeMillis()
isRunning.value = true
bleManager.onResponseFrame = frameTap
bleManager.debugLogger.info(
"BATT_DRAIN start · mim=${IMU_INTERVAL_MS}ms mbb=${FULL_INTERVAL_MS}ms"
)
// mim 을 먼저 태워 첫 tick 이 mbb 응답과 겹치지 않게 한다.
handler.post(imuTick)
handler.postDelayed(fullTick, FULL_INTERVAL_MS)
}
fun stop() {
if (!isRunning.value) return
isRunning.value = false
handler.removeCallbacks(imuTick)
handler.removeCallbacks(fullTick)
ble?.let { b ->
if (b.onResponseFrame === frameTap) b.onResponseFrame = null
b.debugLogger.info(
"BATT_DRAIN stop · elapsed=${elapsedMs() / 1000}s " +
"mim=${imuRecv.value}/${imuSent.value} mbb=${fullRecv.value}/${fullSent.value} " +
"batt=${firstBatteryMv.value}→${lastBatteryMv.value}mV"
)
}
ble = null
}
fun toggle(bleManager: BleManager) {
if (isRunning.value) stop() else start(bleManager)
}
private fun resetCounters() {
imuSent.value = 0; imuRecv.value = 0; imuSkipped.value = 0
fullSent.value = 0; fullRecv.value = 0; fullSkipped.value = 0
lastBatteryMv.value = 0; firstBatteryMv.value = 0
lastTempC.value = Float.NaN
lastRxAtMs.value = 0L
}
fun elapsedMs(): Long =
if (startedAtMs.value == 0L) 0L else System.currentTimeMillis() - startedAtMs.value
/** "1h 23m 45s" 포맷. */
fun formatElapsed(ms: Long): String {
val total = ms / 1000
val h = total / 3600
val m = (total % 3600) / 60
val s = total % 60
return if (h > 0) "${h}h ${m}m ${s}s" else if (m > 0) "${m}m ${s}s" else "${s}s"
}
}
@@ -158,6 +158,18 @@ fun HomeView(appState: AppState) {
) {
Text(stringResource(R.string.home_clinical_mode_dev), color = Color.White, fontWeight = FontWeight.SemiBold)
}
// 2026-08-21 · 기기 배터리 성능(소모율) 실측용 임시 부하 테스트.
// mim? 1초 / mbb? 10초 고정 주기. PiezoMonitoring 에서 연결한 뒤 홈으로
// 돌아와 실행. 루프는 BatteryDrainTester (Handler) 가 소유하므로 화면을
// 벗어나도 계속 돌고, "정지" 를 눌러야 끝난다.
com.medithings.vesiscan.dev.BatteryDrainTestButton(
bleManager = remember { com.medithings.vesiscan.ble.BleManager.getInstance(context) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
.padding(bottom = 8.dp)
)
}
// 2026-08-03 (cloud-mvp 672e679 이식): 프로브 firmware 업데이트 진입점.
TextButton(
@@ -889,7 +889,11 @@ fun PiezoMonitoringView(appState: AppState) {
// 2026-07-08: isMtbBusy 시 skip. 다른 화면 (Alignment) 이 mtb 폴링 중이면
// mim 의 imuCollector.reset() 이 mtb 의 rim 파괴 + GATT queue 혼잡 →
// firmware freeze 실측 확인. 다음 tick 에 재시도.
if (!bleManager.isMtbBusy) bleManager.sendImuFifoQuery()
// 2026-08-21: dev 배터리 부하 테스트가 이미 mim 을 1초 주기로 돌리는 중이면
// 여기서 또 쏘지 않는다 (부하 프로파일 오염 + 중복 트래픽 방지).
if (!bleManager.isMtbBusy &&
!com.medithings.vesiscan.dev.BatteryDrainTester.isRunning.value
) bleManager.sendImuFifoQuery()
}
// isAutoMeasuring=true 면 mtb 의 rim: 가 알아서 posture 갱신, mim skip
}