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 8068866..9ee8bd5 100644 --- a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt +++ b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt @@ -544,6 +544,44 @@ class BleManager private constructor(private val context: Context) { * [connect] 의 타임아웃과 중복이 아니다 — 그쪽은 사용자가 시작한 경로만 덮고, 이쪽은 * 자동 재연결·autoConnect 로 들어온 연결까지 덮는다. */ + /** + * 이 연결에서 서비스 탐색을 이미 시작했는가 — [discoverServicesOnce] 참조. + * 연결될 때마다 false 로 되돌린다. + */ + private var serviceDiscoveryStarted = false + + /** + * 서비스 탐색을 **이 연결에서 한 번만** 시작한다. + * + * ## 왜 필요한가 (2026-09-15 실측으로 원인 확정) + * 프로브가 연결 직후 **자기 쪽에서 MTU 협상을 한 번 더 걸어온다** — 폰의 GATT + * 서버 로그에 `gatts_process_mtu_req: MTU 247 request from remote` 로 찍히고, + * 첫 협상 뒤 **일관되게 약 343ms** 시점이다. 그러면 안드로이드가 `onMtuChanged` + * 를 두 번 전달하고, 탐색을 그 콜백에서 시작하던 이 코드가 `discoverServices()` + * 를 두 번 불렀다. 앞의 탐색이 진행 중인데 다시 부르면 탐색이 끝나지 않고, + * 그 결과가 반쪽 연결이다 — 링크는 붙었는데 `txCharacteristic == null`. + * + * 실측 상관관계가 깔끔하게 갈린다: + * · `MTU=` 1회 → `WATCHDOG_STARTED` (정상) + * · `MTU=` 2회 → 20초 뒤 `HALF_CONNECTED` + * 464건 전수에서도 같다 — 1회 97.0%(420/433) vs 2회 22.6%(7/31) 성공 + * (docs/FIRMWARE_BLE_FINDINGS.md). 2026-09-15 Xiaomi 23021RAA2Y 에서 한 세션 안에 + * 성공 6 / 반쪽 3 으로 재현됐고, 반쪽 3건 전부 `MTU=` 2회였다. + * + * 프로브가 규격을 어기는 것은 아니다(MTU 재협상은 BLE 에서 허용된다). 콜백이 몇 + * 번 오든 탐색을 한 번만 시작하는 것은 **중앙 쪽 책임**이라, 펌웨어 배포를 기다리지 + * 않고 여기서 막는다. + */ + private fun discoverServicesOnce(gatt: BluetoothGatt, where: String) { + if (serviceDiscoveryStarted) { + logw { "discoverServices skipped at $where — already started" } + handler.post { debugLogger.info("DISCOVER_SKIPPED at=$where (중복 MTU 콜백)") } + return + } + serviceDiscoveryStarted = true + gatt.discoverServices() + } + private fun armHalfConnectedGuard(gatt: BluetoothGatt) { halfConnGuard?.let { handler.removeCallbacks(it) } val r = Runnable { @@ -1911,6 +1949,8 @@ class BleManager private constructor(private val context: Context) { when (newState) { BluetoothProfile.STATE_CONNECTED -> { logd { "Connected to ${gatt.device.address}" } + // 새 연결이므로 서비스 탐색을 아직 시작하지 않았다 ([discoverServicesOnce]). + serviceDiscoveryStarted = false // 반쪽 연결 감시. 링크가 붙은 시점부터 재는 상한이다 — // connect() 의 타임아웃은 사용자가 시작한 경로에만 걸리므로, 재연결· // 자동연결로 들어온 반쪽 연결은 아무도 보지 않았다. @@ -1954,7 +1994,8 @@ class BleManager private constructor(private val context: Context) { // MTU 247 요청 (reb: 208B 패킷 드롭 방지) // onMtuChanged 콜백에서 discoverServices 호출 if (!gatt.requestMtu(247)) { - gatt.discoverServices() // MTU 요청 실패 시 바로 진행 + // MTU 요청 실패 시 바로 진행 + discoverServicesOnce(gatt, "requestMtu-failed") } // 2026-07-07 fix: onConnectionStateChanged(true) 를 여기서 호출하면 // TX/RX characteristic 이 아직 null (onServicesDiscovered 미실행) 이라 @@ -2018,7 +2059,9 @@ class BleManager private constructor(private val context: Context) { if (isStaleGatt(gatt, "onMtuChanged")) return logd { "MTU changed: $mtu (status=$status)" } handler.post { debugLogger.info("MTU=$mtu") } - gatt.discoverServices() + // 프로브가 MTU 를 한 번 더 걸어오면 이 콜백이 두 번 온다 — 그때 탐색을 두 번 + // 부르면 탐색이 끝나지 않고 반쪽 연결이 된다 ([discoverServicesOnce] 참조). + discoverServicesOnce(gatt, "onMtuChanged") } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {