diff --git a/app/src/main/java/com/medithings/vesiscan/AppState.kt b/app/src/main/java/com/medithings/vesiscan/AppState.kt
index d98524a..cd29536 100644
--- a/app/src/main/java/com/medithings/vesiscan/AppState.kt
+++ b/app/src/main/java/com/medithings/vesiscan/AppState.kt
@@ -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)
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 0559c5d..edff81a 100644
--- a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt
+++ b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt
@@ -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()
diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/connection/DeviceScanView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/connection/DeviceScanView.kt
index 0ae8691..3a0482d 100644
--- a/app/src/main/java/com/medithings/vesiscan/ui/views/connection/DeviceScanView.kt
+++ b/app/src/main/java/com/medithings/vesiscan/ui/views/connection/DeviceScanView.kt
@@ -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))
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 83587c1..e0188e7 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -371,6 +371,8 @@
측정 모드 진입 (Dev)
저장된 기기
연결이 끝나지 않았습니다. 기기를 다시 눌러 주세요.
+ 연결이 끝까지 되지 않았습니다 — 기기가 응답했지만 준비를 마치지 못했습니다. 다시 연결합니다.
+ 페어링을 삭제할 수 없습니다: 기기에 명령이 닿지 않습니다. 기기 버튼을 15초 길게 눌러 초기화한 뒤, 저장된 기기를 삭제하고 다시 페어링하세요.
새 기기
권한 허용
검색 중지
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 803a2b1..ec414f9 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -371,6 +371,8 @@
Enter Measurement Mode (Dev)
Saved Devices
Connection did not finish. Tap the device again.
+ Connection was incomplete — the device answered but did not finish setup. Reconnecting.
+ Cannot delete pairing: the device is not reachable. Hold the device button for 15 seconds to reset it, then delete the saved device and pair again.
New Devices
Grant Permissions
Stop Scan