From d4e2627efe8f3840ce2d8e3e968b8a3c0796a893 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Thu, 23 Apr 2026 12:27:13 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20BLE=20watchdog=20=E2=80=94=20=EC=A2=80?= =?UTF-8?q?=EB=B9=84=20=EC=97=B0=EA=B2=B0=20=EA=B0=90=EC=A7=80=20+=20?= =?UTF-8?q?=EA=B0=95=EC=A0=9C=20reconnect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android BLE가 disconnect 콜백을 안 보내는 경우(좀비 연결) 대응: - 마지막 RX로부터 15초간 응답 없으면 강제 disconnect → auto reconnect - processReceivedData()에서 lastRxTimestamp 갱신 - 5초마다 watchdog 체크 - WATCHDOG_TIMEOUT 이벤트 BleDebugLogger에 기록 - 수동 disconnect 시 watchdog 해제 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../medilightv2android/ble/BleManager.kt | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) 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 507486e..b4b7115 100644 --- a/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt +++ b/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt @@ -63,6 +63,9 @@ class BleManager private constructor(private val context: Context) { private val maxReconnectAttempts = 5 private val reconnectDelays = longArrayOf(3000, 5000, 10000, 10000, 10000) private var reconnectTimer: Runnable? = null + private var lastRxTimestamp: Long = 0 + private var watchdogTimer: Runnable? = null + private val watchdogTimeoutMs: Long = 15000 val discoveredDevices = mutableStateListOf() val isScanning = mutableStateOf(false) @@ -220,6 +223,7 @@ class BleManager private constructor(private val context: Context) { fun disconnect() { isUserDisconnect = true cancelAutoReconnect() + stopWatchdog() connectionTimer?.let { handler.removeCallbacks(it) } connectionTimer = null bluetoothGatt?.disconnect() @@ -348,6 +352,50 @@ class BleManager private constructor(private val context: Context) { batteryTimer = null } + // ── Watchdog (zombie connection detection) ── + + private fun startWatchdog() { + stopWatchdog() + lastRxTimestamp = System.currentTimeMillis() + val check = object : Runnable { + override fun run() { + if (!isConnected.value) return + val silenceMs = System.currentTimeMillis() - lastRxTimestamp + if (silenceMs > watchdogTimeoutMs) { + logw { "Watchdog: no RX for ${silenceMs}ms — forcing disconnect + reconnect" } + debugLogger.error("WATCHDOG_TIMEOUT silence=${silenceMs}ms — forcing reconnect") + forceDisconnectAndReconnect() + return + } + handler.postDelayed(this, 5000) + } + } + watchdogTimer = check + handler.postDelayed(check, 5000) + } + + private fun stopWatchdog() { + watchdogTimer?.let { handler.removeCallbacks(it) } + watchdogTimer = null + } + + private fun forceDisconnectAndReconnect() { + stopBatteryPolling() + stopWatchdog() + isConnected.value = false + isServiceReady.value = false + + bluetoothGatt?.let { gatt -> + try { gatt.disconnect(); gatt.close() } catch (_: Exception) {} + } + bluetoothGatt = null + txCharacteristic = null + rxCharacteristic = null + + onUnexpectedDisconnect?.invoke() + scheduleAutoReconnect() + } + // ── Auto Reconnect ── private fun scheduleAutoReconnect() { @@ -544,6 +592,7 @@ class BleManager private constructor(private val context: Context) { if (txCharacteristic != null && rxCharacteristic != null) { isServiceReady.value = true startBatteryPolling() + startWatchdog() logd { "Service ready! (CCCD written)" } } } else { @@ -579,6 +628,7 @@ class BleManager private constructor(private val context: Context) { } private fun processReceivedData(data: ByteArray) { + lastRxTimestamp = System.currentTimeMillis() val prefix = String(data, 0, minOf(4, data.size), Charsets.US_ASCII) debugLogger.rx(prefix.trimEnd(':', '!'), data.size) logd { "Received: $prefix (${data.size} bytes)" }