feat(ble): FW query 자동 재시도 · "기기 응답 없음" 배너 false-positive 감소

배경 (2026-08-04 · 사용자 · Alignment idle 시 배너 flap):
  연결 직후 mid? (t=0) → mfv? fallback (t=4s) 만으로도 응답 없으면
  firmwareVersion 이 empty · 8초 후 FirmwareWarningBanner 의 timedOut=true
  → Alignment idle 처럼 트래픽 적은 화면에서 배너 뜸.

신규 로직 scheduleFirmwareRetry():
  - 5초 간격으로 mid? / mfv? 교차 발사 (최대 3회)
  - 첫 응답 오면 즉시 중단 (firmwareVersion.isNotEmpty())
  - 3회 모두 실패 → 재시도 종료 · 배너는 그때 노출 (진짜 문제)

Timing:
  t=0    mid?              (기존)
  t=4s   mfv? fallback     (기존) · 여기서 scheduleFirmwareRetry() 시작
  t=9s   mid? retry #1     (신규)
  t=14s  mfv? retry #2     (신규)
  t=19s  mid? retry #3     (신규)
  t=24s+ 재시도 종료 · 응답 없으면 배너

Disconnect / GATT error / 좀비 경로에도 stopFirmwareRetry() cleanup.

검증: BUILD SUCCESSFUL 2s
This commit is contained in:
2026-08-04 12:25:12 +09:00
parent 0184c6d694
commit 68577139d4
@@ -84,8 +84,13 @@ class BleManager private constructor(private val context: Context) {
private val watchdogTimeoutMs: Long = 25000
// 2026-07-07 fix: disconnect 시 취소 위해 Runnable 참조 유지.
// fwFallback: onDescriptorWrite 성공 후 4초 mfv? fallback.
// fwRetry: 2026-08-04 신규 · mid?/mfv? 초기 응답 실패 시 5초 간격 재시도 최대 3회.
// cccdRetry: onDescriptorWrite 실패 후 500ms retry.
private var fwFallbackTimer: Runnable? = null
private var fwRetryTimer: Runnable? = null
@Volatile private var fwRetryCount: Int = 0
private val FW_RETRY_MAX = 3
private val FW_RETRY_INTERVAL_MS = 5_000L
private var cccdRetryTimer: Runnable? = null
val discoveredDevices = mutableStateListOf<BleDevice>()
@@ -731,6 +736,60 @@ class BleManager private constructor(private val context: Context) {
sendRaw(CRC16.buildCommandASCII("mfv", " "))
}
/**
* FW query 자동 재시도 (2026-08-04 · GUARDIAN §Rev2 후속).
*
* 배경: 연결 직후 mid? (t=0) → mfv? fallback (t=4s) 만으로도 응답 없으면
* firmwareVersion 이 empty 로 남고, 8초 후 FirmwareWarningBanner 의
* `timedOut = true` 로 배너 노출. Alignment idle 처럼 트래픽이 적은
* 화면에서 특히 눈에 띔.
*
* 신규 로직: `scheduleFirmwareRetry()` 호출 시점부터 5초 간격으로 mid?/mfv?
* 를 교차 발사. 최대 3회. 응답 오면 (firmwareVersion 채워짐) 즉시 중단.
*
* 실패 시: 3회 모두 응답 없음 → 재시도 종료. 배너는 그때 뜸 (진짜 문제 상황).
*/
fun scheduleFirmwareRetry() {
fwRetryTimer?.let { handler.removeCallbacks(it) }
val runnable = object : Runnable {
override fun run() {
// 성공 · 연결 끊김 · 최대 시도 도달 → 중단
if (firmwareVersion.value.isNotEmpty()) {
debugLogger.info("FW retry: success (fw=${firmwareVersion.value})")
fwRetryTimer = null
return
}
if (!isConnected.value) {
fwRetryTimer = null
return
}
if (fwRetryCount >= FW_RETRY_MAX) {
debugLogger.warn("FW retry: gave up after $FW_RETRY_MAX attempts")
fwRetryTimer = null
return
}
fwRetryCount++
// mid? / mfv? 교차 (홀수=mid, 짝수=mfv). 다른 명령이 서로 다른 FW 지원.
if (fwRetryCount % 2 == 1) {
debugLogger.info("FW retry #$fwRetryCount: mid?")
sendDeviceInfoQuery()
} else {
debugLogger.info("FW retry #$fwRetryCount: mfv?")
sendFirmwareVersionQuery()
}
handler.postDelayed(this, FW_RETRY_INTERVAL_MS)
}
}
fwRetryTimer = runnable
handler.postDelayed(runnable, FW_RETRY_INTERVAL_MS)
}
private fun stopFirmwareRetry() {
fwRetryTimer?.let { handler.removeCallbacks(it) }
fwRetryTimer = null
fwRetryCount = 0
}
fun sendLedMode(state: Int) {
sendRaw(CRC16.buildCommandBE("mls", intArrayOf(state)))
}
@@ -948,6 +1007,7 @@ class BleManager private constructor(private val context: Context) {
txCharacteristic = null
rxCharacteristic = null
fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null }
stopFirmwareRetry()
mtbTimeoutRunnable?.let { handler.removeCallbacks(it); mtbTimeoutRunnable = null }
cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null }
stopBatteryPolling()
@@ -1128,6 +1188,7 @@ class BleManager private constructor(private val context: Context) {
stopWatchdog()
// 2026-07-07 fix: 연결 실패 시 대기 중인 timer 취소.
fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null }
stopFirmwareRetry()
mtbTimeoutRunnable?.let { handler.removeCallbacks(it); mtbTimeoutRunnable = null }
cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null }
bluetoothGatt = null
@@ -1220,6 +1281,7 @@ class BleManager private constructor(private val context: Context) {
commandQueue.clear("state-disconnected")
// 2026-07-07 fix: disconnect 시 대기 중인 timer 취소.
fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null }
stopFirmwareRetry()
mtbTimeoutRunnable?.let { handler.removeCallbacks(it); mtbTimeoutRunnable = null }
cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null }
bluetoothGatt = null
@@ -1335,6 +1397,7 @@ class BleManager private constructor(private val context: Context) {
startBatteryPolling()
startRssiPolling()
startWatchdog()
fwRetryCount = 0
sendDeviceInfoQuery()
// 4초 후에도 펌웨어 미수신이면 mfv? fallback (구 펌웨어는 mid? 미지원 가능성).
// 2026-07-07 fix: disconnect 시 취소되도록 Runnable 참조 유지.
@@ -1343,6 +1406,10 @@ class BleManager private constructor(private val context: Context) {
if (firmwareVersion.value.isEmpty() && isConnected.value) {
debugLogger.info("FW fallback: sending mfv?")
sendFirmwareVersionQuery()
// 2026-08-04: 첫 mfv? fallback 실패 대비 재시도 스케줄러 시작.
// 5초 간격으로 mid?/mfv? 교차 최대 3회 · "기기 응답 없음"
// 배너 false positive 대폭 감소 (알림 창 노출 조건 §Rev1 참고).
scheduleFirmwareRetry()
}
}
fwFallbackTimer = fwFallback