fix: watchdog heartbeat, 배터리 계산, placement 디바운스+방향 수정

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 17:27:32 +09:00
parent 5cdae8a86e
commit c6e84bd6c0
3 changed files with 41 additions and 25 deletions
@@ -477,11 +477,15 @@ class BleManager private constructor(private val context: Context) {
if (!isConnected.value) break if (!isConnected.value) break
val silenceMs = System.currentTimeMillis() - lastRxTimestamp val silenceMs = System.currentTimeMillis() - lastRxTimestamp
// Idle 10초 이상이면 IMU query로 heartbeat
if (silenceMs > 10000 && silenceMs <= watchdogTimeoutMs) {
handler.post { sendImuQuery() }
}
if (silenceMs > watchdogTimeoutMs) { if (silenceMs > watchdogTimeoutMs) {
// 직접 로그 기록 (handler 블로킹 무관)
debugLogger.error("WATCHDOG_TIMEOUT silence=${silenceMs}ms — forcing reconnect") debugLogger.error("WATCHDOG_TIMEOUT silence=${silenceMs}ms — forcing reconnect")
// GATT close는 어느 스레드에서든 호출 가능
watchdogRunning = false watchdogRunning = false
try { try {
bluetoothGatt?.disconnect() bluetoothGatt?.disconnect()
@@ -491,7 +495,6 @@ class BleManager private constructor(private val context: Context) {
txCharacteristic = null txCharacteristic = null
rxCharacteristic = null rxCharacteristic = null
// UI 업데이트 + reconnect는 메인 스레드
handler.post { handler.post {
stopBatteryPolling() stopBatteryPolling()
isConnected.value = false isConnected.value = false
@@ -872,8 +875,14 @@ class BleManager private constructor(private val context: Context) {
"rsn:" -> { "rsn:" -> {
if (data.size >= 6) { if (data.size >= 6) {
val millivolts = ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF) val millivolts = ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF)
val pct = ((millivolts - 3700) * 100 / 500).coerceIn(0, 100) 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 batteryLevel.value = pct
}
debugLogger.rx("rsn", data.size, "battery=${millivolts}mV (${pct}%)") debugLogger.rx("rsn", data.size, "battery=${millivolts}mV (${pct}%)")
} }
} }
@@ -543,19 +543,14 @@ fun PiezoMonitoringView(appState: AppState) {
} }
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
// Battery icon (color by level) // Battery percentage
if (!isDemoMode) { if (!isDemoMode) {
val batteryColor = when { val batteryColor = when {
batteryLvl >= 60 -> Color(0xFF4CAF50) batteryLvl >= 60 -> Color(0xFF4CAF50)
batteryLvl >= 30 -> Color(0xFFFF9800) batteryLvl >= 30 -> Color(0xFFFF9800)
else -> Color.Red else -> Color.Red
} }
val batteryIcon = when { Text("$batteryLvl%", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = batteryColor)
batteryLvl >= 60 -> Icons.Default.BatteryFull
batteryLvl >= 30 -> Icons.Default.Battery4Bar
else -> Icons.Default.Battery1Bar
}
Icon(batteryIcon, "Battery $batteryLvl%", tint = batteryColor, modifier = Modifier.size(iconSize))
} }
// Connection dot // Connection dot
@@ -68,6 +68,8 @@ fun PlacementGuideView(appState: AppState) {
var scanCount by remember { mutableIntStateOf(0) } var scanCount by remember { mutableIntStateOf(0) }
var isLocked by remember { mutableStateOf(false) } var isLocked by remember { mutableStateOf(false) }
var lastChannelData by remember { mutableStateOf<List<PiezoChannelData>>(emptyList()) } var lastChannelData by remember { mutableStateOf<List<PiezoChannelData>>(emptyList()) }
var debounceCount by remember { mutableIntStateOf(0) }
var lastGuidePhase by remember { mutableStateOf(PlacementPhase.VERTICAL) }
// 2단계 순차 탐색: VERTICAL → LATERAL → GREEN // 2단계 순차 탐색: VERTICAL → LATERAL → GREEN
var placementPhase by remember { mutableStateOf(PlacementPhase.VERTICAL) } var placementPhase by remember { mutableStateOf(PlacementPhase.VERTICAL) }
@@ -338,17 +340,28 @@ fun PlacementGuideView(appState: AppState) {
"%.1f".format(detachResult.meanAbs)} std=${"%.1f".format(detachResult.stdVal)}") "%.1f".format(detachResult.meanAbs)} std=${"%.1f".format(detachResult.stdVal)}")
} else { } else {
val guideResult = computePlacementGuide(detected, urineLens, allWalls, scanCount, placementPhase) val guideResult = computePlacementGuide(detected, urineLens, allWalls, scanCount, placementPhase)
// 디바운스: 같은 phase가 2회 연속일 때만 힌트 변경
if (guideResult.nextPhase == lastGuidePhase) {
debounceCount++
} else {
debounceCount = 1
lastGuidePhase = guideResult.nextPhase
}
if (debounceCount >= 2 || guideResult.isPass) {
directionHint = guideResult.hint directionHint = guideResult.hint
directionIcon = guideResult.icon directionIcon = guideResult.icon
placementScore = guideResult.score placementScore = guideResult.score
placementPhase = guideResult.nextPhase placementPhase = guideResult.nextPhase
}
if (guideResult.isPass) { if (guideResult.isPass) {
isLocked = true isLocked = true
bleManager.sendLedMode(6) // ALIGN_COMPLETE bleManager.sendLedMode(6) // ALIGN_COMPLETE
bleManager.debugLogger.info("PLACEMENT_GREEN cv=${"%.3f".format(guideResult.cv)} lr_dev=${"%.3f".format(guideResult.lrDev)} score=${guideResult.score}") bleManager.debugLogger.info("PLACEMENT_GREEN cv=${"%.3f".format(guideResult.cv)} lr_dev=${"%.3f".format(guideResult.lrDev)} score=${guideResult.score}")
} else { } else {
isLocked = false // Green 벗어나면 다시 가이드 isLocked = false
bleManager.sendLedMode(5) // ALIGN_SEARCHING bleManager.sendLedMode(5) // ALIGN_SEARCHING
bleManager.debugLogger.info("PLACEMENT_${guideResult.phase.name} score=${guideResult.score} hint=${guideResult.hint}") 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 ch0 = detected.getOrElse(0) { false }
val ch3 = detected.getOrElse(3) { false } val ch3 = detected.getOrElse(3) { false }
val hint = when { val hint = when {
ch3 && !ch0 -> "센서를 위로 이동하세요 ↑" ch3 && !ch0 -> "센서를 아래로 이동하세요 ↓"
!ch3 && ch0 -> "센서를 아래로 이동하세요 ↓" !ch3 && ch0 -> "센서를 위로 이동하세요 ↑"
!ch3 && !ch0 -> "센서를 아래에서 위로 천천히 이동하세요" else -> "센서를 천천히 위아래로 이동하세요"
else -> "센서를 아래에서 위로 천천히 이동하세요"
} }
val icon = when { val icon = when {
ch3 && !ch0 -> "arrow.up" ch3 && !ch0 -> "arrow.down"
!ch3 && ch0 -> "arrow.down" !ch3 && ch0 -> "arrow.up"
else -> "arrow.up.arrow.down" else -> "arrow.up.arrow.down"
} }
return PlacementGuideResult(hint, icon, centerDetected * 10, return PlacementGuideResult(hint, icon, centerDetected * 10,
@@ -746,10 +758,10 @@ private fun computePlacementGuide(
} else { } else {
// 크기 편차 큼 → 상하 더 조절 // 크기 편차 큼 → 상하 더 조절
return if (len3 > len0 * 2) { return if (len3 > len0 * 2) {
PlacementGuideResult("센서를 위로 더 이동하세요 ↑ (CH0 약함)", "arrow.up", 25, PlacementGuideResult("센서를 아래로 더 이동하세요 ↓ (CH0 약함)", "arrow.down", 25,
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL)
} else { } else {
PlacementGuideResult("센서를 아래로 더 이동하세요 ↓ (CH3 약함)", "arrow.down", 25, PlacementGuideResult("센서를 위로 더 이동하세요 ↑ (CH3 약함)", "arrow.up", 25,
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL)
} }
} }