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:
@@ -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 {
|
||||||
batteryLevel.value = pct
|
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}%)")
|
debugLogger.rx("rsn", data.size, "battery=${millivolts}mV (${pct}%)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-7
@@ -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
|
||||||
|
|||||||
+25
-13
@@ -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)
|
||||||
directionHint = guideResult.hint
|
|
||||||
directionIcon = guideResult.icon
|
// 디바운스: 같은 phase가 2회 연속일 때만 힌트 변경
|
||||||
placementScore = guideResult.score
|
if (guideResult.nextPhase == lastGuidePhase) {
|
||||||
placementPhase = guideResult.nextPhase
|
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) {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user