From 68577139d4e5b234d83518a89b503caabcb80e2e Mon Sep 17 00:00:00 2001 From: jjangddu Date: Tue, 4 Aug 2026 12:25:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(ble):=20FW=20query=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84=20=C2=B7=20"=EA=B8=B0=EA=B8=B0=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=97=86=EC=9D=8C"=20=EB=B0=B0=EB=84=88?= =?UTF-8?q?=20false-positive=20=EA=B0=90=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배경 (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 --- .../com/medithings/vesiscan/ble/BleManager.kt | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt index b76fc8d..083d055 100644 --- a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt +++ b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt @@ -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() @@ -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