fix(ble): 배터리 폴링을 **조용할 때만** — 측정 스트림에 끼어들지 않게
`msn?` 을 30초마다 보내고 있었다. 원래 목적은 **링크를 살려 두는 것**이었는데, 자동 측정(500ms 주기 `mtb`)이나 배터리 소모 시험(1Hz `mbb`)처럼 계속 통신하는 동안에는 링크가 그 트래픽으로 이미 살아 있다 — 보낼 이유가 없는데 측정 스트림 사이에 끼어든다. ## 기존 가드로는 부족했다 `isMtbBusy` 는 **한 스트림이 흐르는 동안**만 막는다(`startMultiChannel` ~ `isComplete`). 자동 측정은 cycle 사이에 완료 구간이 생기므로 그 틈에 `msn?` 이 나가고, 그 응답(`rsn`)이 다음 `mtb` 스트림과 겹친다. 끼어들면 펌웨어 GATT 큐가 꼬여 응답이 실종되고 freeze 로 이어진다 — 2026-07-08 주석이 그 증상을 적고 있다. ## TX 시각을 보고 판단한다 전송 관문(demo-final `sendRawWrite` · user `sendRaw`)에서 `lastTxAtMs` 를 남기고, 폴링은 직전 TX 로부터 5초가 지났을 때만 보낸다. 5초는 자동 측정 주기(500ms)와 배터리 시험 주기(1초)보다 충분히 길어 **측정 중에는 한 번도 나가지 않는다.** 쉬고 있으면 직전 TX 가 30초 전(지난 폴링)이라 정상적으로 나간다. 첫 응답 재시도(3초 간격 2회)에도 같은 기준을 걸었다 — 연결 직후 바로 측정을 시작하는 흐름이 있어(배터리 시험) 그 재시도가 스트림에 끼어들 수 있다. ## 부작용을 막았다 — rbb 에서 배터리를 읽는다 폴링을 멈추면 화면의 배터리 표시가 멈춘다. 그런데 **`mbb` 응답 헤더(`rbb`)에는 배터리가 이미 실려 온다** — 앱이 그걸 안 읽고 있었다. 읽게 했더니 배터리 소모 시험(1Hz `mbb`, 50시간) 내내 표시가 살아 있고 **추가 통신은 0**이다. 원래는 배터리가 그 시험의 측정값인데 화면이 멈추는 모양새였다. 변환식과 단조 감소 가드를 `applyBatteryMv()` 한 곳으로 모았다. 따로 구현하면 같은 전압이 경로에 따라 다른 %로 보인다. 단조 가드를 그대로 둔 이유는 부하가 걸리면 전압이 일시적으로 떨어졌다 회복하는데, 그때마다 %가 오르내리면 사용자가 배터리가 늘었다고 읽기 때문이다. ## 남는 한 가지 `mtb`(자동 측정) 응답에는 배터리가 없어 **자동 측정 중에는 표시가 멈춘다.** 피할 수 없고, 원래 의도(측정 중에는 끼어들지 않는다)의 대가다. 끝나면 다음 tick 에 갱신된다. 보호자 앱에는 BLE 가 없어(아이콘 import 뿐) 대상이 아니다. demo-final 143 · user 79 · caregiver 33 테스트 통과. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user