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 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<BleDevice>()
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()