fix(ble): 반쪽 연결 — "연결됨"이라 말하고 아무 데이터도 안 오던 것

현장 재연(2026-09-10) 10단계를 받아 원인 사슬을 끝까지 따라갔다. 결론은 **반쪽 연결**
(링크는 붙었는데 서비스 탐색·CCCD 미완)이고, 마지막 단계가 제일 나쁘다 — **기기를 15초
길게 눌러 물리적으로 초기화해야만** 복구됐다.

    ③ 광고 직전/직후에 [저장된 기기] → 링크만 붙고 txCharacteristic = null
    ④ isServiceReady 가 안 와서 무한 스피너
    ⑤⑥ 화면은 isConnected 만 봐서 "연결됨"
    ⑦ txCharacteristic null → sendRawWrite 가 조용히 return → 배터리·IMU 전무
    ⑧-2 [페어링 삭제] → msr? 가 **안 나갔는데** removeBond() 는 실행
         폰: 본드 삭제 ✓   프로브: 본드 그대로 ✗   ← 비대칭
    ⑨ 재연결 시 프로브가 옛 LTK 를 요구 → "PIN/passkey 가 올바르지 않다"
    ⑩ 기기 15초 길게 눌러 초기화해야 복구

## 고친 것 넷

**① 보낼 수 없으면 본드를 지우지 않는다** (⑧-2 → ⑨⑩ 차단)

`canSendCommands`(= isServiceReady && tx != null && gatt != null)를 만들어
`disconnectAndUnbond()` 맨 앞에서 본다. false 면 **아무것도 지우지 않고** 끊고, 기기
초기화를 안내한다. 한쪽만 지운 상태보다 양쪽 다 남은 상태가 훨씬 낫다 — 후자는 그냥 다시
연결하면 된다.

설정탭·임상의 [페어링 삭제] 가 이 함수를 **직접** 부르고 있었다(unbondSmart 만
isServiceReady 를 확인했다). 그래서 방어를 함수 안에 뒀다.

**② "연결됨"의 뜻을 바꿨다** (⑤⑥)

`AppState.isDeviceConnected` 가 `isConnected` → **`isServiceReady`** 를 본다.
"붙었다"가 아니라 **"쓸 수 있다"** 가 사용자에게 의미 있는 상태다.

**③ 반쪽 연결 자가 복구** (④⑤)

링크가 붙은 시점부터 20초 상한을 건다(`armHalfConnectedGuard`). 못 넘기면 끊고, 사용자가
끊은 게 아니면 재연결을 잇는다. connect() 의 타임아웃과 중복이 아니다 — 그쪽은 사용자가
시작한 경로만 덮고, 이쪽은 자동 재연결·autoConnect 로 들어온 연결까지 덮는다. 실측 최악이
15초라 20초로 잡았다.

**④ 쓰기 실패가 보이게** `sendRawWrite` 가 Boolean 을 돌려주고, 실패하면
`TX_FAIL ...` 를 로그에 남긴다. 종전에는 logd 만 찍고 조용했다.

문구는 en/ko 둘 다. `HALF_CONNECTED` 는 "다시 연결합니다",
`UNBOND_UNREACHABLE` 은 15초 길게 누르기까지 구체적으로 안내한다.

테스트 143개 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 14:45:22 +09:00
parent b0351342de
commit f676bd7c8a
5 changed files with 132 additions and 5 deletions
@@ -96,8 +96,18 @@ class AppState(private val context: Context) {
* 현재: getter 가 BleManager.isConnected 를 읽음 → Compose 가 자동 재구성.
* 기존 코드의 setter 는 no-op 로 유지 (호환).
*/
/**
* 화면이 "연결됨"으로 쓰는 값 — **[BleManager.isServiceReady] 를 본다.**
*
* 종전에는 `isConnected` 였다. 그런데 링크만 붙고 서비스 탐색·CCCD 가 끝나지 않은
* **반쪽 연결**이 있다. 그 상태에서 `isConnected` 는 true 이고 화면은 전부 "연결됨"으로
* 표시했는데 배터리·IMU·측정 어느 것도 오지 않았다 — 사용자는 앱이 멈춘 줄 안다
* (2026-09-10 현장 재연 ⑤~⑦).
*
* **"붙었다"가 아니라 "쓸 수 있다"가 사용자에게 의미 있는 상태다.**
*/
var isDeviceConnected: Boolean
get() = isDemoMode || bleManager.isConnected.value
get() = isDemoMode || bleManager.isServiceReady.value
set(_) { /* no-op · 실제 상태는 BleManager 가 관리 */ }
var isDemoMode by mutableStateOf(false)
var isDevMode by mutableStateOf(false)
@@ -464,6 +464,18 @@ class BleManager private constructor(private val context: Context) {
/** 마지막으로 연결을 끊은 시각. 쿨다운 계산에만 쓴다. */
@Volatile private var lastDisconnectAtMs = 0L
/**
* **명령을 실제로 보낼 수 있는가.** [isConnected] 와 다르다.
*
* 링크만 붙고 서비스 탐색·CCCD 가 끝나지 않은 **반쪽 연결**이 있다. 그 상태에서는
* `txCharacteristic` 이 null 이라 `sendRawWrite` 가 조용히 빠져나가고, 배터리·IMU·측정
* 어느 것도 오지 않는다. 화면은 [isConnected] 만 보고 "연결됨"이라고 했다.
*
* 광고 직전/직후에 [저장된 기기] 를 누르면 재연된다(2026-09-10 현장).
*/
val canSendCommands: Boolean
get() = isServiceReady.value && txCharacteristic != null && bluetoothGatt != null
/**
* 쿨다운이 끝나면 연결할 예약. 두 번 예약되면 GATT 가 두 개 열린다.
*
@@ -472,6 +484,54 @@ class BleManager private constructor(private val context: Context) {
*/
private var pendingConnect: Runnable? = null
private var halfConnGuard: Runnable? = null
/**
* 링크가 붙은 뒤 [isServiceReady] 까지의 상한. 못 넘기면 **끊는다.**
*
* 반쪽 연결을 살려 두면 화면은 "연결됨"인데 아무 데이터도 오지 않고, 그 상태에서
* [페어링 삭제] 를 누르면 본드가 비대칭이 되어 기기를 물리적으로 초기화해야 한다.
* 그러니 **빨리 끊고 다시 붙는 편이 낫다.**
*
* [connect] 의 타임아웃과 중복이 아니다 — 그쪽은 사용자가 시작한 경로만 덮고, 이쪽은
* 자동 재연결·autoConnect 로 들어온 연결까지 덮는다.
*/
private fun armHalfConnectedGuard(gatt: BluetoothGatt) {
halfConnGuard?.let { handler.removeCallbacks(it) }
val r = Runnable {
halfConnGuard = null
if (gatt !== bluetoothGatt) return@Runnable
if (isServiceReady.value) return@Runnable
debugLogger.error(
"HALF_CONNECTED 링크는 붙었는데 서비스가 준비되지 않았습니다 " +
"(${SERVICE_READY_GUARD_MS}ms) — 끊습니다"
)
commandQueue.clear("half-connected")
try { gatt.disconnect(); gatt.close() } catch (_: Exception) {}
if (gatt === bluetoothGatt) {
bluetoothGatt = null
txCharacteristic = null
rxCharacteristic = null
}
isConnected.value = false
isServiceReady.value = false
isConnecting.value = false
connectionError.value = "HALF_CONNECTED"
// 사용자가 끊은 게 아니면 계속 되살린다.
if (!isUserDisconnect && lastConnectedAddress != null) scheduleAutoReconnect()
}
halfConnGuard = r
handler.postDelayed(r, SERVICE_READY_GUARD_MS)
}
private fun clearHalfConnectedGuard() {
halfConnGuard?.let { handler.removeCallbacks(it) }
halfConnGuard = null
}
/** 링크 수립 후 서비스 준비까지 허용 시간. 실측 최악이 15초였다(2026-09-10). */
private val SERVICE_READY_GUARD_MS = 20_000L
private fun isStaleGatt(gatt: BluetoothGatt, where: String): Boolean {
val current = bluetoothGatt ?: return false
if (gatt === current) return false
@@ -733,6 +793,7 @@ class BleManager private constructor(private val context: Context) {
fun disconnect() {
lastDisconnectAtMs = System.currentTimeMillis()
clearHalfConnectedGuard()
// 쿨다운 예약을 버린다. 안 버리면 끊은 뒤 600ms 만에 스스로 다시 연결된다.
pendingConnect?.let { handler.removeCallbacks(it); pendingConnect = null }
isUserDisconnect = true
@@ -750,7 +811,34 @@ class BleManager private constructor(private val context: Context) {
com.medithings.vesiscan.services.BleForegroundService.stop(context)
}
/**
* 양쪽 본드를 지우고 끊는다 — 프로브에 `msr?`(본드 삭제 + 재부팅), 폰에 `removeBond()`.
*
* ## ⚠ 보낼 수 없으면 **폰 쪽도 지우지 않는다**
* 반쪽 연결에서는 `msr?` 가 한 바이트도 안 나간다([canSendCommands]). 그런데 종전에는
* 그걸 모르고 `removeBond()` 를 실행해 **본드가 비대칭**이 됐다:
*
* 폰: 본드 삭제 ✓ 프로브: 본드 그대로 ✗
*
* 다음 연결에서 프로브가 옛 LTK 로 암호화를 요구하는데 폰엔 키가 없어 "PIN 또는
* passkey 가 올바르지 않습니다"로 거부된다. **앱으로는 복구가 안 된다** — 프로브를
* 15초 길게 눌러 본드를 초기화해야 한다(2026-09-10 현장에서 그렇게 복구했다).
*
* 그래서 보낼 수 없으면 **아무것도 지우지 않고** 연결만 끊고, 사용자에게 기기 초기화를
* 안내한다. 한쪽만 지운 상태보다 양쪽 다 남은 상태가 훨씬 낫다 — 후자는 그냥 다시
* 연결하면 된다.
*/
fun disconnectAndUnbond() {
if (!canSendCommands) {
debugLogger.error(
"UNBOND_REFUSED canSend=false serviceReady=${isServiceReady.value} " +
"tx=${txCharacteristic != null} gatt=${bluetoothGatt != null} — " +
"msr? 를 보낼 수 없어 폰 본드도 지우지 않습니다(비대칭 방지)"
)
connectionError.value = "UNBOND_UNREACHABLE"
disconnect()
return
}
lastDisconnectAtMs = System.currentTimeMillis()
pendingConnect?.let { handler.removeCallbacks(it); pendingConnect = null }
isUserDisconnect = true
@@ -877,16 +965,26 @@ class BleManager private constructor(private val context: Context) {
)
/** 실제 GATT write — CommandQueue 내부에서만 호출. 외부는 sendRaw() 사용. */
private fun sendRawWrite(data: ByteArray) {
/**
* @return 쓰기 요청이 **실제로 스택에 들어갔는가.** false = 한 바이트도 안 나갔다.
*
* 반환값을 만든 이유는 [disconnectAndUnbond] 다. 반쪽 연결(링크는 붙었는데 서비스
* 미완)에서는 `txCharacteristic` 이 null 이라 여기서 조용히 빠져나갔고, 그걸 모르고
* 폰 쪽 본드만 지워 **프로브와 본드가 비대칭**이 됐다. 그 상태는 프로브를 물리적으로
* 초기화(15초 길게 누르기)해야만 복구된다 — 2026-09-10 현장 재연으로 확인.
*/
private fun sendRawWrite(data: ByteArray): Boolean {
val characteristic = txCharacteristic
val gatt = bluetoothGatt
if (characteristic == null) {
loge { "sendRawWrite: txCharacteristic is null!" }
return
debugLogger.error("TX_FAIL txCharacteristic=null — 명령이 나가지 않았습니다")
return false
}
if (gatt == null) {
loge { "sendRawWrite: bluetoothGatt is null!" }
return
debugLogger.error("TX_FAIL bluetoothGatt=null — 명령이 나가지 않았습니다")
return false
}
val cmdPreview = if (data.size >= 4) String(data, 0, 3, Charsets.US_ASCII) else "?"
val cmdDetail = when (cmdPreview) {
@@ -908,9 +1006,11 @@ class BleManager private constructor(private val context: Context) {
}
logd { "sendRawWrite: $cmdPreview (${data.size} bytes)" }
debugLogger.tx("$cmdPreview $cmdDetail", data.size)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val result = gatt.writeCharacteristic(characteristic, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE)
logd { "sendRawWrite result: $result" }
// API 33+ 는 상태 코드다 — 0(SUCCESS) 만 실제로 들어간 것이다.
result == BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
characteristic.value = data
@@ -918,6 +1018,7 @@ class BleManager private constructor(private val context: Context) {
@Suppress("DEPRECATION")
val ok = gatt.writeCharacteristic(characteristic)
logd { "sendRawWrite success: $ok" }
ok
}
}
@@ -1618,6 +1719,10 @@ class BleManager private constructor(private val context: Context) {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
logd { "Connected to ${gatt.device.address}" }
// 반쪽 연결 감시. 링크가 붙은 시점부터 재는 상한이다 —
// connect() 의 타임아웃은 사용자가 시작한 경로에만 걸리므로, 재연결·
// 자동연결로 들어온 반쪽 연결은 아무도 보지 않았다.
armHalfConnectedGuard(gatt)
// **이 연결을 현재 GATT 로 못박는다.**
//
// 재연결 경로는 `bluetoothGatt = connectGatt(...)` 로 대입하지만,
@@ -1759,6 +1864,7 @@ class BleManager private constructor(private val context: Context) {
handler.post {
if (txCharacteristic != null && rxCharacteristic != null) {
isServiceReady.value = true
clearHalfConnectedGuard()
onConnectionStateChanged?.invoke(true)
startBatteryPolling()
startRssiPolling()
@@ -1771,6 +1877,7 @@ class BleManager private constructor(private val context: Context) {
handler.post {
if (txCharacteristic != null) {
isServiceReady.value = true
clearHalfConnectedGuard()
onConnectionStateChanged?.invoke(true)
startBatteryPolling()
startRssiPolling()
@@ -263,6 +263,10 @@ fun DeviceScanView(appState: AppState) {
val isBondConflict = curError.startsWith("BOND_CONFLICT:") && curBondAddr != null
val isDeviceUnresponsive = curError.startsWith("DEVICE_UNRESPONSIVE:")
val isReconnectGaveUp = curError == "RECONNECT_GAVE_UP"
// 반쪽 연결 — 링크는 붙었는데 서비스가 준비되지 않아 앱이 끊은 경우.
val isHalfConnected = curError == "HALF_CONNECTED"
// 본드 비대칭을 막으려고 페어링 삭제를 거부한 경우. 기기 초기화가 필요하다.
val isUnbondUnreachable = curError == "UNBOND_UNREACHABLE"
Column(
modifier = Modifier
.fillMaxWidth()
@@ -278,6 +282,8 @@ fun DeviceScanView(appState: AppState) {
isBondConflict -> stringResource(R.string.ble_error_bond_conflict)
isDeviceUnresponsive -> stringResource(R.string.ble_error_device_unresponsive)
isReconnectGaveUp -> stringResource(R.string.ble_error_reconnect_gave_up)
isHalfConnected -> stringResource(R.string.ble_error_half_connected)
isUnbondUnreachable -> stringResource(R.string.ble_error_unbond_unreachable)
else -> curError
}
Text(displayText, color = Color.White, fontSize = 13.sp, modifier = Modifier.weight(1f))