From 6f5801b4b694fcb440c068e07b85e492590243b2 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Wed, 22 Apr 2026 17:40:00 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20BLE=20=EC=9E=90=EB=8F=99=20reconnect=20?= =?UTF-8?q?+=20=EB=B0=B0=ED=84=B0=EB=A6=AC=20=ED=8F=B4=EB=A7=81=205?= =?UTF-8?q?=EC=B4=88=20(keep-alive)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BleManager: 배터리 폴링 30초→5초 (펌웨어 15초 타임아웃 방지, iOS 동일) - BleManager: 자동 reconnect with 백오프 (3s→5s→10s, 최대 5회) - scheduleAutoReconnect() + attemptAutoReconnect() - GATT 에러 시에도 백오프 재시도 - 수동 disconnect 시 자동 reconnect 취소 - PiezoMonitoringView: 끊김 시 즉시 파란 배너(RECONNECTING) - 성공 시 배너 자동 숨김 + 배터리 폴링 재시작 - 5회 실패 시 빨간 배너 + 수동 Reconnect 버튼 - 측정 중 끊김 → isMeasuring 리셋 + 자동측정 중지 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../medilightv2android/ble/BleManager.kt | 81 +++++++++++++++++-- .../views/monitoring/PiezoMonitoringView.kt | 21 +++-- 2 files changed, 89 insertions(+), 13 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 e6ec96d..afd6a2d 100644 --- a/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt +++ b/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt @@ -56,6 +56,12 @@ class BleManager private constructor(private val context: Context) { private var batteryTimer: Runnable? = null private var connectionTimer: Runnable? = null private var isUserDisconnect = false + var lastConnectedAddress: String? = null + private set + private var reconnectAttempt = 0 + private val maxReconnectAttempts = 5 + private val reconnectDelays = longArrayOf(3000, 5000, 10000, 10000, 10000) + private var reconnectTimer: Runnable? = null val discoveredDevices = mutableStateListOf() val isScanning = mutableStateOf(false) @@ -212,6 +218,7 @@ class BleManager private constructor(private val context: Context) { fun disconnect() { isUserDisconnect = true + cancelAutoReconnect() connectionTimer?.let { handler.removeCallbacks(it) } connectionTimer = null bluetoothGatt?.disconnect() @@ -321,16 +328,15 @@ class BleManager private constructor(private val context: Context) { fun startBatteryPolling() { batteryTimer?.let { handler.removeCallbacks(it) } - // Query immediately, then every 30s sendBatteryQuery() val poll = object : Runnable { override fun run() { sendBatteryQuery() - handler.postDelayed(this, 30000) + handler.postDelayed(this, 5000) } } batteryTimer = poll - handler.postDelayed(poll, 30000) + handler.postDelayed(poll, 5000) } fun stopBatteryPolling() { @@ -338,6 +344,57 @@ class BleManager private constructor(private val context: Context) { batteryTimer = null } + // ── Auto Reconnect ── + + private fun scheduleAutoReconnect() { + if (reconnectAttempt >= maxReconnectAttempts) { + logw { "Max reconnect attempts ($maxReconnectAttempts) reached" } + isReconnecting.value = false + onReconnectionFailed?.invoke() + return + } + + val delay = reconnectDelays[reconnectAttempt.coerceAtMost(reconnectDelays.size - 1)] + logd { "Auto-reconnect attempt ${reconnectAttempt + 1}/$maxReconnectAttempts in ${delay}ms" } + isReconnecting.value = true + + cancelReconnectTimer() + val timer = Runnable { attemptAutoReconnect() } + reconnectTimer = timer + handler.postDelayed(timer, delay) + } + + private fun attemptAutoReconnect() { + val address = lastConnectedAddress ?: return + if (bluetoothAdapter?.isEnabled != true) { + onReconnectionFailed?.invoke() + isReconnecting.value = false + return + } + + reconnectAttempt++ + logd { "Attempting auto-reconnect #$reconnectAttempt to $address" } + + bluetoothGatt?.let { gatt -> + try { gatt.close() } catch (_: Exception) {} + bluetoothGatt = null + } + + val device = bluetoothAdapter.getRemoteDevice(address) + bluetoothGatt = device.connectGatt(context, true, gattCallback, BluetoothDevice.TRANSPORT_LE) + } + + private fun cancelReconnectTimer() { + reconnectTimer?.let { handler.removeCallbacks(it) } + reconnectTimer = null + } + + fun cancelAutoReconnect() { + cancelReconnectTimer() + reconnectAttempt = 0 + isReconnecting.value = false + } + private val gattCallback = object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { logd { "onConnectionStateChange: status=$status newState=$newState" } @@ -347,20 +404,24 @@ class BleManager private constructor(private val context: Context) { connectionTimer = null if (status != BluetoothGatt.GATT_SUCCESS && newState != BluetoothProfile.STATE_CONNECTED) { - // Connection failed with error loge { "GATT connection failed with status: $status" } bluetoothGatt = null txCharacteristic = null rxCharacteristic = null isConnected.value = false isServiceReady.value = false - isReconnecting.value = false - connectionError.value = "Connection failed (status $status). Try again." val idx = discoveredDevices.indexOfFirst { it.address == gatt.device.address } if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnected = false, isConnecting = false) gatt.close() + + if (reconnectAttempt > 0 && lastConnectedAddress != null) { + scheduleAutoReconnect() + } else { + isReconnecting.value = false + connectionError.value = "Connection failed (status $status). Try again." + } return@post } @@ -378,7 +439,10 @@ class BleManager private constructor(private val context: Context) { com.example.medilightv2android.managers.PiezoHW.autoDetectPreset(connectedDeviceName.value) logd { "Device preset: ${com.example.medilightv2android.managers.PiezoHW.presetName} (from '${connectedDeviceName.value}')" } - // Save last connected device address + lastConnectedAddress = gatt.device.address + reconnectAttempt = 0 + cancelReconnectTimer() + try { val prefs = context.getSharedPreferences("medilight_prefs", Context.MODE_PRIVATE) prefs.edit().putString("last_device_address", gatt.device.address).apply() @@ -403,8 +467,9 @@ class BleManager private constructor(private val context: Context) { onConnectionStateChanged?.invoke(false) - if (!isUserDisconnect) { + if (!isUserDisconnect && lastConnectedAddress != null) { onUnexpectedDisconnect?.invoke() + scheduleAutoReconnect() } isUserDisconnect = false gatt.close() 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 b96e43c..128e40d 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 @@ -204,15 +204,27 @@ fun PiezoMonitoringView(appState: AppState) { } } - // Setup disconnect handling + // Setup disconnect handling — auto reconnect integrated LaunchedEffect(Unit) { bleManager.onUnexpectedDisconnect = { - reconnectionState = BleReconnectionState.DISCONNECTED + reconnectionState = BleReconnectionState.RECONNECTING showDisconnectionBanner = true + if (isMeasuring) { + isMeasuring = false + measureRetryCount = 0 + } + if (isAutoMeasuring) stopAutoMeasure() } bleManager.onReconnectionFailed = { reconnectionState = BleReconnectionState.FAILED } + bleManager.onConnectionStateChanged = { connected -> + if (connected && showDisconnectionBanner) { + showDisconnectionBanner = false + reconnectionState = BleReconnectionState.DISCONNECTED + bleManager.startBatteryPolling() + } + } } // Add catheter dialog @@ -544,9 +556,8 @@ fun PiezoMonitoringView(appState: AppState) { state = reconnectionState, onReconnect = { reconnectionState = BleReconnectionState.RECONNECTING - bleManager.reconnectToLastDevice( - com.example.medilightv2android.services.UserStorage.getInstance(context).lastDeviceAddress - ) + bleManager.cancelAutoReconnect() + bleManager.reconnectToLastDevice(bleManager.lastConnectedAddress) } ) Spacer(modifier = Modifier.height(8.dp))