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 a52cffa..64e767c 100644 --- a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt +++ b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt @@ -87,7 +87,33 @@ class BleManager private constructor(private val context: Context) { private var scanTimer: Runnable? = null private var batteryTimer: Runnable? = null private var batteryRetryTimer: Runnable? = null - private val BATTERY_POLL_INTERVAL_MS = 30_000L // 정상 polling 간격 + private val BATTERY_POLL_INTERVAL_MS = 30_000L + /** + * 직전 TX 로부터 이만큼은 조용해야 배터리를 묻는다. + * + * ## 왜 필요한가 + * `msn?` 은 **링크를 살려 두려고** 30초마다 보내던 것이다. 그런데 자동 측정(500ms 주기 + * `mtb`)이나 배터리 소모 시험(1Hz `mbb`)처럼 계속 통신하는 동안에는 링크가 그 트래픽으로 + * 이미 살아 있다 — 보낼 이유가 없는데 **측정 스트림 사이에 끼어든다.** + * + * 끼어들면 펌웨어 GATT 큐가 꼬여 응답이 실종되고 freeze 로 이어진다(2026-07-08 주석). + * 종전 가드는 [isMtbBusy] 뿐이었는데 그건 **한 스트림이 흐르는 동안**만 막는다. 자동 + * 측정은 cycle 사이에 완료 구간이 생기므로 그 틈에 `msn?` 이 나가고, 그 응답(`rsn`)이 + * 다음 `mtb` 스트림과 겹친다. + * + * 5초로 잡은 이유: 자동 측정 주기(500ms)와 배터리 시험 주기(1초)보다 충분히 길어 + * **측정 중에는 한 번도 나가지 않는다.** 반대로 쉬고 있으면 직전 TX 가 30초 전(지난 + * 폴링)이라 정상적으로 나간다. + */ + private val BATTERY_TX_QUIET_MS = 5_000L + + /** 마지막으로 무언가 전송한 시각. 전송 관문이 갱신한다. */ + @Volatile private var lastTxAtMs = 0L + + /** 직전 TX 로부터 [BATTERY_TX_QUIET_MS] 가 지났는가 — 즉 지금 조용한가. */ + private val isTxQuiet: Boolean + get() = System.currentTimeMillis() - lastTxAtMs >= BATTERY_TX_QUIET_MS + // 정상 polling 간격 private val BATTERY_RETRY_DELAY_MS = 3_000L // 첫 응답 재시도 지연 private val BATTERY_RETRY_MAX = 2 // 최대 재시도 횟수 private var rssiTimer: Runnable? = null @@ -1124,6 +1150,9 @@ class BleManager private constructor(private val context: Context) { } logd { "sendRawWrite: $cmdPreview (${data.size} bytes)" } debugLogger.tx("$cmdPreview $cmdDetail", data.size) + // 배터리 폴링이 "지금 통신 중인가"를 판단하는 근거. 전송 경로가 여럿(sendRaw 큐· + // 스트리밍 명령·직접 write)이지만 전부 이 함수로 모이므로 여기 한 곳에서 남긴다. + lastTxAtMs = System.currentTimeMillis() 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" } @@ -1464,7 +1493,9 @@ class BleManager private constructor(private val context: Context) { retries++ logd { "battery query retry #$retries (no response yet)" } // 2026-07-08: mtb 응답 진행 중이면 skip — 다음 주기까지 대기 (queue 충돌 방지). - if (!isMtbBusy) sendBatteryQuery() + // 2026-09-10: TX 중에도 건너뛴다. 연결 직후 바로 측정을 시작하는 + // 흐름이 있어(배터리 시험) 이 재시도가 그 스트림에 끼어들 수 있다. + if (!isMtbBusy && isTxQuiet) sendBatteryQuery() handler.postDelayed(this, BATTERY_RETRY_DELAY_MS) } } @@ -1472,12 +1503,23 @@ class BleManager private constructor(private val context: Context) { batteryRetryTimer = retryRunnable handler.postDelayed(retryRunnable, BATTERY_RETRY_DELAY_MS) - // 3단계: 30초 주기 polling + // 3단계: 30초 주기 polling — **조용할 때만 보낸다** + // + // 2026-09-10: `isMtbBusy` 만으로는 부족했다. 그건 한 스트림이 흐르는 동안만 막으므로, + // 자동 측정처럼 cycle 이 반복되면 그 사이 완료 구간에서 `msn?` 이 나간다. 측정 중에는 + // 링크가 그 트래픽으로 이미 살아 있으니 보낼 이유가 없다. val pollRunnable = object : Runnable { override fun run() { if (!isConnected.value) return - // 2026-07-08: mtb 응답 진행 중이면 skip — 30초 주기라 다음 tick 에 잡힘. - if (!isMtbBusy) sendBatteryQuery() + when { + isMtbBusy -> logd { "battery poll skip: mtb stream in progress" } + // 측정 중이다. 건너뛰고 다음 tick 에 다시 본다 — 끝나면 잡힌다. + !isTxQuiet -> logd { + "battery poll skip: TX active " + + "(${System.currentTimeMillis() - lastTxAtMs}ms ago)" + } + else -> sendBatteryQuery() + } handler.postDelayed(this, BATTERY_POLL_INTERVAL_MS) } } @@ -1485,6 +1527,34 @@ class BleManager private constructor(private val context: Context) { handler.postDelayed(pollRunnable, BATTERY_POLL_INTERVAL_MS) } + + /** + * 배터리 mV → % 변환 + 적용. `rsn`(질의 응답)과 `rbb`(전체 측정 헤더)가 같이 쓴다. + * + * ## 왜 한 곳으로 모았나 + * 배터리 폴링(`msn?`)을 측정 중에는 보내지 않게 바꿨다(2026-09-10). 그러면 측정하는 + * 동안 표시가 멈추는데, **`mbb` 응답 헤더에는 배터리가 이미 실려 온다** — 공짜로 있는 + * 값을 읽으면 추가 통신 없이 표시가 살아 있다. 배터리 소모 시험(1Hz `mbb`)이 바로 그 + * 경우다. + * + * 변환식과 **단조 감소 가드**를 양쪽이 똑같이 써야 한다. 따로 구현하면 같은 전압이 + * 경로에 따라 다른 %로 보인다. + * + * 단조 가드(`pct <= 현재값`)를 그대로 둔 이유: 부하가 걸리면 전압이 일시적으로 떨어졌다 + * 회복하는데, 그때마다 %가 오르내리면 사용자는 배터리가 늘었다고 읽는다. + */ + private fun applyBatteryMv(millivolts: Int): Int { + val pct = when { + millivolts <= 3500 -> 0 + millivolts <= 3700 -> ((millivolts - 3500) * 5 / 200).coerceIn(0, 5) + else -> (5 + (millivolts - 3700) * 95 / 400).coerceIn(5, 100) + } + if (pct <= batteryLevel.value || batteryLevel.value == 0) { + batteryLevel.value = pct + } + return pct + } + fun stopBatteryPolling() { batteryTimer?.let { handler.removeCallbacks(it) } batteryTimer = null @@ -2211,14 +2281,7 @@ class BleManager private constructor(private val context: Context) { "rsn:" -> { if (data.size >= 6) { val millivolts = ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF) - val pct = when { - millivolts <= 3500 -> 0 - millivolts <= 3700 -> ((millivolts - 3500) * 5 / 200).coerceIn(0, 5) - else -> (5 + (millivolts - 3700) * 95 / 400).coerceIn(5, 100) - } - if (pct <= batteryLevel.value || batteryLevel.value == 0) { - batteryLevel.value = pct - } + val pct = applyBatteryMv(millivolts) debugLogger.rx("rsn", data.size, "battery=${millivolts}mV (${pct}%)") emitBatteryEvent(pct) } @@ -2268,7 +2331,12 @@ class BleManager private constructor(private val context: Context) { val mv = ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF) val t = (((data[data.size - 4].toInt() and 0xFF) shl 8) or (data[data.size - 3].toInt() and 0xFF)) / 100f - "battery=${mv}mV" + if (t in 0f..80f) " temp=%.2fC".format(t) else "" + // **표시에도 반영한다.** msn? 폴링을 측정 중에는 안 보내므로, 이 값을 + // 안 읽으면 배터리 소모 시험(1Hz mbb) 내내 화면이 멈춘다. 추가 통신 0. + val pct = applyBatteryMv(mv) + emitBatteryEvent(pct) + "battery=${mv}mV (${pct}%)" + + if (t in 0f..80f) " temp=%.2fC".format(t) else "" } else "full measurement header (battery+IMU+temp)" debugLogger.rx("rbb", data.size, detail) onMbbHeaderReceived?.invoke(data)