From c6e84bd6c01312854dccf35406242413d02a2cd7 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Tue, 28 Apr 2026 17:27:32 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20watchdog=20heartbeat,=20=EB=B0=B0?= =?UTF-8?q?=ED=84=B0=EB=A6=AC=20=EA=B3=84=EC=82=B0,=20placement=20?= =?UTF-8?q?=EB=94=94=EB=B0=94=EC=9A=B4=EC=8A=A4+=EB=B0=A9=ED=96=A5=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BLE: - watchdog idle heartbeat: 10초 무통신 시 IMU query로 keepalive - 배터리: 3500-3700mV→0-5%, 3700-4100mV→5-100%, 증가 무시 - 배터리 표시: 아이콘→퍼센트 숫자 (색상 유지) Placement: - 힌트 디바운스: 같은 phase 2회 연속일 때만 변경 (깜빡임 방지) - 방향 힌트 반전: CH3만 잡힘→아래로↓, CH0만 잡힘→위로↑ Co-Authored-By: Claude Opus 4.6 (1M context) --- .../medilightv2android/ble/BleManager.kt | 19 +++++++--- .../views/monitoring/PiezoMonitoringView.kt | 9 +---- .../ui/views/monitoring/PlacementGuideView.kt | 38 ++++++++++++------- 3 files changed, 41 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt b/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt index a7548db..665d11c 100644 --- a/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt +++ b/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt @@ -477,11 +477,15 @@ class BleManager private constructor(private val context: Context) { if (!isConnected.value) break val silenceMs = System.currentTimeMillis() - lastRxTimestamp + + // Idle 10초 이상이면 IMU query로 heartbeat + if (silenceMs > 10000 && silenceMs <= watchdogTimeoutMs) { + handler.post { sendImuQuery() } + } + if (silenceMs > watchdogTimeoutMs) { - // 직접 로그 기록 (handler 블로킹 무관) debugLogger.error("WATCHDOG_TIMEOUT silence=${silenceMs}ms — forcing reconnect") - // GATT close는 어느 스레드에서든 호출 가능 watchdogRunning = false try { bluetoothGatt?.disconnect() @@ -491,7 +495,6 @@ class BleManager private constructor(private val context: Context) { txCharacteristic = null rxCharacteristic = null - // UI 업데이트 + reconnect는 메인 스레드 handler.post { stopBatteryPolling() isConnected.value = false @@ -872,8 +875,14 @@ class BleManager private constructor(private val context: Context) { "rsn:" -> { if (data.size >= 6) { val millivolts = ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF) - val pct = ((millivolts - 3700) * 100 / 500).coerceIn(0, 100) - batteryLevel.value = pct + val pct = when { + millivolts <= 3500 -> 0 + millivolts <= 3700 -> ((millivolts - 3500) * 5 / 200).coerceIn(0, 5) + else -> (5 + (millivolts - 3700) * 95 / 400).coerceIn(5, 100) + } + if (pct <= batteryLevel.value || batteryLevel.value == 0) { + batteryLevel.value = pct + } debugLogger.rx("rsn", data.size, "battery=${millivolts}mV (${pct}%)") } } diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt index f5d9b1a..ac32780 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt @@ -543,19 +543,14 @@ fun PiezoMonitoringView(appState: AppState) { } Spacer(modifier = Modifier.weight(1f)) - // Battery icon (color by level) + // Battery percentage if (!isDemoMode) { val batteryColor = when { batteryLvl >= 60 -> Color(0xFF4CAF50) batteryLvl >= 30 -> Color(0xFFFF9800) else -> Color.Red } - val batteryIcon = when { - batteryLvl >= 60 -> Icons.Default.BatteryFull - batteryLvl >= 30 -> Icons.Default.Battery4Bar - else -> Icons.Default.Battery1Bar - } - Icon(batteryIcon, "Battery $batteryLvl%", tint = batteryColor, modifier = Modifier.size(iconSize)) + Text("$batteryLvl%", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = batteryColor) } // Connection dot diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt index 38e6b79..d7aced1 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt @@ -68,6 +68,8 @@ fun PlacementGuideView(appState: AppState) { var scanCount by remember { mutableIntStateOf(0) } var isLocked by remember { mutableStateOf(false) } var lastChannelData by remember { mutableStateOf>(emptyList()) } + var debounceCount by remember { mutableIntStateOf(0) } + var lastGuidePhase by remember { mutableStateOf(PlacementPhase.VERTICAL) } // 2단계 순차 탐색: VERTICAL → LATERAL → GREEN var placementPhase by remember { mutableStateOf(PlacementPhase.VERTICAL) } @@ -338,17 +340,28 @@ fun PlacementGuideView(appState: AppState) { "%.1f".format(detachResult.meanAbs)} std=${"%.1f".format(detachResult.stdVal)}") } else { val guideResult = computePlacementGuide(detected, urineLens, allWalls, scanCount, placementPhase) - directionHint = guideResult.hint - directionIcon = guideResult.icon - placementScore = guideResult.score - placementPhase = guideResult.nextPhase + + // 디바운스: 같은 phase가 2회 연속일 때만 힌트 변경 + if (guideResult.nextPhase == lastGuidePhase) { + debounceCount++ + } else { + debounceCount = 1 + lastGuidePhase = guideResult.nextPhase + } + + if (debounceCount >= 2 || guideResult.isPass) { + directionHint = guideResult.hint + directionIcon = guideResult.icon + placementScore = guideResult.score + placementPhase = guideResult.nextPhase + } if (guideResult.isPass) { isLocked = true bleManager.sendLedMode(6) // ALIGN_COMPLETE bleManager.debugLogger.info("PLACEMENT_GREEN cv=${"%.3f".format(guideResult.cv)} lr_dev=${"%.3f".format(guideResult.lrDev)} score=${guideResult.score}") } else { - isLocked = false // Green 벗어나면 다시 가이드 + isLocked = false bleManager.sendLedMode(5) // ALIGN_SEARCHING bleManager.debugLogger.info("PLACEMENT_${guideResult.phase.name} score=${guideResult.score} hint=${guideResult.hint}") } @@ -713,14 +726,13 @@ private fun computePlacementGuide( val ch0 = detected.getOrElse(0) { false } val ch3 = detected.getOrElse(3) { false } val hint = when { - ch3 && !ch0 -> "센서를 위로 이동하세요 ↑" - !ch3 && ch0 -> "센서를 아래로 이동하세요 ↓" - !ch3 && !ch0 -> "센서를 아래에서 위로 천천히 이동하세요" - else -> "센서를 아래에서 위로 천천히 이동하세요" + ch3 && !ch0 -> "센서를 아래로 이동하세요 ↓" + !ch3 && ch0 -> "센서를 위로 이동하세요 ↑" + else -> "센서를 천천히 위아래로 이동하세요" } val icon = when { - ch3 && !ch0 -> "arrow.up" - !ch3 && ch0 -> "arrow.down" + ch3 && !ch0 -> "arrow.down" + !ch3 && ch0 -> "arrow.up" else -> "arrow.up.arrow.down" } return PlacementGuideResult(hint, icon, centerDetected * 10, @@ -746,10 +758,10 @@ private fun computePlacementGuide( } else { // 크기 편차 큼 → 상하 더 조절 return if (len3 > len0 * 2) { - PlacementGuideResult("센서를 위로 더 이동하세요 ↑ (CH0 약함)", "arrow.up", 25, + PlacementGuideResult("센서를 아래로 더 이동하세요 ↓ (CH0 약함)", "arrow.down", 25, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) } else { - PlacementGuideResult("센서를 아래로 더 이동하세요 ↓ (CH3 약함)", "arrow.down", 25, + PlacementGuideResult("센서를 위로 더 이동하세요 ↑ (CH3 약함)", "arrow.up", 25, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) } }