From f4f54078941c73b79c62dc5ae22d8d6ded43d9d5 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Tue, 7 Jul 2026 10:10:57 +0900 Subject: [PATCH] =?UTF-8?q?fix(ble):=20=ED=99=94=EB=A9=B4=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=ED=9B=84=20=EC=A2=80=EB=B9=84=20BLE=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EB=B0=A9=EC=A7=80=20=E2=80=94=20=EC=BD=9C=EB=B0=B1?= =?UTF-8?q?=20=EC=88=9C=EC=84=9C=20+=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 증상: 앱 실행 후 alignment → 도넛차트 → 개발자모드 → alignment 이동 시 BLE 가 끊긴 것처럼 응답 실종 → 15초 후 watchdog forced reconnect. 심하면 재연결도 좀비 상태로 나와 3번째 재연결에서야 정상 동작. 원인 1: BleManager.onConnectionStateChange 에서 STATE_CONNECTED 직후 onConnectionStateChanged?.invoke(true) 호출 → 이 시점에는 아직 MTU/ service discovery/CCCD write 이 완료되지 않아 txCharacteristic 은 null. 상위 (PiezoMonitoringView) 가 startBatteryPolling 등 TX 를 시도하면 sendRaw 내부 null-check 에 걸려 silent drop → peripheral 이 응답을 보내지 않음 → watchdog timeout. 원인 2: PiezoMonitoringView / PlacementGuideView 의 DisposableEffect 에서 BleManager 콜백 (onConnectionStateChanged, onUnexpectedDisconnect, onReconnectionFailed, piezoCollector.onMultiChannelComplete 등) 을 정리하지 않음. 화면 전환 시 이전 화면의 stale 콜백이 남아 새 화면 진입 후에도 발동 → 상태 충돌. 수정: - BleManager: onConnectionStateChanged(true) 호출을 CCCD write 완료 (isServiceReady=true) 시점으로 이동. CCCD 없음 / RX 없음 edge case 도 동일하게 처리. - PiezoMonitoringView / PlacementGuideView: DisposableEffect 에서 등록한 BleManager 콜백 전부 null 로 정리. Co-Authored-By: Claude Opus 4.7 --- .../java/com/medithings/vesiscan/ble/BleManager.kt | 13 ++++++++++++- .../ui/views/monitoring/PiezoMonitoringView.kt | 8 +++++++- .../ui/views/monitoring/PlacementGuideView.kt | 11 +++++++++-- 3 files changed, 28 insertions(+), 4 deletions(-) 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 fdb793a..54cc755 100644 --- a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt +++ b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt @@ -937,7 +937,12 @@ class BleManager private constructor(private val context: Context) { if (!gatt.requestMtu(247)) { gatt.discoverServices() // MTU 요청 실패 시 바로 진행 } - onConnectionStateChanged?.invoke(true) + // 2026-07-07 fix: onConnectionStateChanged(true) 를 여기서 호출하면 + // TX/RX characteristic 이 아직 null (onServicesDiscovered 미실행) 이라 + // 상위 (PiezoMonitoringView) 가 startBatteryPolling 등 TX 를 시작해도 + // sendRaw 에서 silent drop → RX 실종 → watchdog timeout → forced reconnect + // 좀비 세션의 원인. 콜백은 isServiceReady=true 시점 (onDescriptorWrite) + // 으로 이동. } BluetoothProfile.STATE_DISCONNECTED -> { logd { "Disconnected from ${gatt.device.address}" } @@ -1011,6 +1016,7 @@ class BleManager private constructor(private val context: Context) { handler.post { if (txCharacteristic != null && rxCharacteristic != null) { isServiceReady.value = true + onConnectionStateChanged?.invoke(true) startBatteryPolling() startWatchdog() logd { "Service ready (no CCCD)" } @@ -1021,6 +1027,7 @@ class BleManager private constructor(private val context: Context) { handler.post { if (txCharacteristic != null) { isServiceReady.value = true + onConnectionStateChanged?.invoke(true) startBatteryPolling() startWatchdog() logd { "Service ready (no RX)" } @@ -1036,6 +1043,10 @@ class BleManager private constructor(private val context: Context) { if (status == BluetoothGatt.GATT_SUCCESS) { if (txCharacteristic != null && rxCharacteristic != null) { isServiceReady.value = true + // 2026-07-07 fix: 여기서 상위에 알림 — TX/RX 준비 완료 시점. 이전에는 + // STATE_CONNECTED 즉시 호출해서 아직 characteristic null 인 상태로 + // 상위가 TX 시도 → silent drop → 좀비 세션. + onConnectionStateChanged?.invoke(true) // Connection priority HIGH 요청: peripheral이 수락하면 interval ~15ms로 협상되어 // Central Link Layer ACK 속도 ↑ → FW SoftDevice TX queue 포화 빈도 ↓ val priorityOk = try { diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt index 41dbdc7..eb6aa01 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt @@ -846,11 +846,17 @@ fun PiezoMonitoringView(appState: AppState) { } } - // PiezoMonitoring 이탈 시 imuCollector callback cleanup — 다른 화면 (PlacementGuide 등) 이 + // PiezoMonitoring 이탈 시 callback cleanup — 다른 화면 (PlacementGuide, ClinicalHome 등) 이 // 자기 콜백 등록하기 전에 우리 콜백 해제. 다시 진입하면 위 LaunchedEffect 가 재등록. + // 2026-07-07 fix: BleManager 콜백 (onConnectionStateChanged 등) 3개도 정리. 이전에는 + // 화면 전환 시 stale 콜백이 남아 새 화면 진입 후에도 이전 화면 로직 (startBatteryPolling + // 등) 이 발동 → GATT operation queue 겹침 → 좀비 세션의 간접 원인. androidx.compose.runtime.DisposableEffect(Unit) { onDispose { bleManager.imuCollector.onComplete = null + bleManager.onConnectionStateChanged = null + bleManager.onUnexpectedDisconnect = null + bleManager.onReconnectionFailed = null } } diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PlacementGuideView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PlacementGuideView.kt index 0268ca5..72d4b5f 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PlacementGuideView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PlacementGuideView.kt @@ -162,9 +162,16 @@ fun PlacementGuideView(appState: AppState) { sweepLastDetected = 0; sweepEverSawAll = false } - // 화면 벗어날 때 LED OFF + // 화면 벗어날 때 LED OFF + 콜백 정리. + // 2026-07-07 fix: piezoCollector.onMultiChannelComplete / imuCollector.onComplete 는 + // PlacementGuide 진입 시 자기 콜백으로 덮어쓰는데, 이탈 시 정리하지 않으면 다음 + // 화면 (PiezoMonitoring 등) 의 콜백과 stale reference 가 뒤엉킴. androidx.compose.runtime.DisposableEffect(Unit) { - onDispose { bleManager.sendLedMode(0) } + onDispose { + bleManager.sendLedMode(0) + bleManager.piezoCollector.onMultiChannelComplete = null + bleManager.imuCollector.onComplete = null + } } // Detachment alert (rising edge → 팝업, 재부착 시 자동 해제)