feat: BLE 자동 reconnect + 배터리 폴링 5초 (keep-alive)

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 17:40:00 +09:00
parent d8a725b21b
commit 6f5801b4b6
2 changed files with 89 additions and 13 deletions
@@ -56,6 +56,12 @@ class BleManager private constructor(private val context: Context) {
private var batteryTimer: Runnable? = null private var batteryTimer: Runnable? = null
private var connectionTimer: Runnable? = null private var connectionTimer: Runnable? = null
private var isUserDisconnect = false 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<BleDevice>() val discoveredDevices = mutableStateListOf<BleDevice>()
val isScanning = mutableStateOf(false) val isScanning = mutableStateOf(false)
@@ -212,6 +218,7 @@ class BleManager private constructor(private val context: Context) {
fun disconnect() { fun disconnect() {
isUserDisconnect = true isUserDisconnect = true
cancelAutoReconnect()
connectionTimer?.let { handler.removeCallbacks(it) } connectionTimer?.let { handler.removeCallbacks(it) }
connectionTimer = null connectionTimer = null
bluetoothGatt?.disconnect() bluetoothGatt?.disconnect()
@@ -321,16 +328,15 @@ class BleManager private constructor(private val context: Context) {
fun startBatteryPolling() { fun startBatteryPolling() {
batteryTimer?.let { handler.removeCallbacks(it) } batteryTimer?.let { handler.removeCallbacks(it) }
// Query immediately, then every 30s
sendBatteryQuery() sendBatteryQuery()
val poll = object : Runnable { val poll = object : Runnable {
override fun run() { override fun run() {
sendBatteryQuery() sendBatteryQuery()
handler.postDelayed(this, 30000) handler.postDelayed(this, 5000)
} }
} }
batteryTimer = poll batteryTimer = poll
handler.postDelayed(poll, 30000) handler.postDelayed(poll, 5000)
} }
fun stopBatteryPolling() { fun stopBatteryPolling() {
@@ -338,6 +344,57 @@ class BleManager private constructor(private val context: Context) {
batteryTimer = null 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() { private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
logd { "onConnectionStateChange: status=$status newState=$newState" } logd { "onConnectionStateChange: status=$status newState=$newState" }
@@ -347,20 +404,24 @@ class BleManager private constructor(private val context: Context) {
connectionTimer = null connectionTimer = null
if (status != BluetoothGatt.GATT_SUCCESS && newState != BluetoothProfile.STATE_CONNECTED) { if (status != BluetoothGatt.GATT_SUCCESS && newState != BluetoothProfile.STATE_CONNECTED) {
// Connection failed with error
loge { "GATT connection failed with status: $status" } loge { "GATT connection failed with status: $status" }
bluetoothGatt = null bluetoothGatt = null
txCharacteristic = null txCharacteristic = null
rxCharacteristic = null rxCharacteristic = null
isConnected.value = false isConnected.value = false
isServiceReady.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 } val idx = discoveredDevices.indexOfFirst { it.address == gatt.device.address }
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnected = false, isConnecting = false) if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnected = false, isConnecting = false)
gatt.close() gatt.close()
if (reconnectAttempt > 0 && lastConnectedAddress != null) {
scheduleAutoReconnect()
} else {
isReconnecting.value = false
connectionError.value = "Connection failed (status $status). Try again."
}
return@post return@post
} }
@@ -378,7 +439,10 @@ class BleManager private constructor(private val context: Context) {
com.example.medilightv2android.managers.PiezoHW.autoDetectPreset(connectedDeviceName.value) com.example.medilightv2android.managers.PiezoHW.autoDetectPreset(connectedDeviceName.value)
logd { "Device preset: ${com.example.medilightv2android.managers.PiezoHW.presetName} (from '${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 { try {
val prefs = context.getSharedPreferences("medilight_prefs", Context.MODE_PRIVATE) val prefs = context.getSharedPreferences("medilight_prefs", Context.MODE_PRIVATE)
prefs.edit().putString("last_device_address", gatt.device.address).apply() 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) onConnectionStateChanged?.invoke(false)
if (!isUserDisconnect) { if (!isUserDisconnect && lastConnectedAddress != null) {
onUnexpectedDisconnect?.invoke() onUnexpectedDisconnect?.invoke()
scheduleAutoReconnect()
} }
isUserDisconnect = false isUserDisconnect = false
gatt.close() gatt.close()
@@ -204,15 +204,27 @@ fun PiezoMonitoringView(appState: AppState) {
} }
} }
// Setup disconnect handling // Setup disconnect handling — auto reconnect integrated
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
bleManager.onUnexpectedDisconnect = { bleManager.onUnexpectedDisconnect = {
reconnectionState = BleReconnectionState.DISCONNECTED reconnectionState = BleReconnectionState.RECONNECTING
showDisconnectionBanner = true showDisconnectionBanner = true
if (isMeasuring) {
isMeasuring = false
measureRetryCount = 0
}
if (isAutoMeasuring) stopAutoMeasure()
} }
bleManager.onReconnectionFailed = { bleManager.onReconnectionFailed = {
reconnectionState = BleReconnectionState.FAILED reconnectionState = BleReconnectionState.FAILED
} }
bleManager.onConnectionStateChanged = { connected ->
if (connected && showDisconnectionBanner) {
showDisconnectionBanner = false
reconnectionState = BleReconnectionState.DISCONNECTED
bleManager.startBatteryPolling()
}
}
} }
// Add catheter dialog // Add catheter dialog
@@ -544,9 +556,8 @@ fun PiezoMonitoringView(appState: AppState) {
state = reconnectionState, state = reconnectionState,
onReconnect = { onReconnect = {
reconnectionState = BleReconnectionState.RECONNECTING reconnectionState = BleReconnectionState.RECONNECTING
bleManager.reconnectToLastDevice( bleManager.cancelAutoReconnect()
com.example.medilightv2android.services.UserStorage.getInstance(context).lastDeviceAddress bleManager.reconnectToLastDevice(bleManager.lastConnectedAddress)
)
} }
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))