Files
VesiscanClinicalAndroid/app/src/main/java/com/medithings/vesiscan/dev/BatteryDrainTestDialog.kt
T
dw.jang f19a56694b 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>
2026-08-21 17:02:55 +09:00

207 lines
8.0 KiB
Kotlin

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 })
}
}