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 324f175..25a8187 100644 --- a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt +++ b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt @@ -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) } } diff --git a/app/src/main/java/com/medithings/vesiscan/dev/BatteryDrainTestDialog.kt b/app/src/main/java/com/medithings/vesiscan/dev/BatteryDrainTestDialog.kt new file mode 100644 index 0000000..11bd285 --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/dev/BatteryDrainTestDialog.kt @@ -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 }) + } +} diff --git a/app/src/main/java/com/medithings/vesiscan/dev/BatteryDrainTester.kt b/app/src/main/java/com/medithings/vesiscan/dev/BatteryDrainTester.kt new file mode 100644 index 0000000..7e47e09 --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/dev/BatteryDrainTester.kt @@ -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" + } +} diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/home/HomeView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/home/HomeView.kt index c39fadf..b5dc17e 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/home/HomeView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/home/HomeView.kt @@ -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( diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt index 3b2b698..66589a2 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt @@ -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 }