fix: BLE watchdog — 좀비 연결 감지 + 강제 reconnect

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 12:27:13 +09:00
parent 506248655d
commit d4e2627efe
@@ -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<BleDevice>()
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)" }