fix(ble): mtb 응답 중 다른 명령 끼어들어 firmware freeze — 3-layer 근본 fix

실측 로그 (2026-07-08 11:18~11:19) 분석: mtb 응답 stream (reb×6 + raa
+ rim, 총 8개 패킷) 중간에 msn (battery) / mim (IMU) 이 TX 로 끼어들면
firmware GATT queue 가 꼬여 응답 실종 → 이어지는 mls mode 0 명령에서
완전 freeze (BLE 광고까지 중단, 재연결 10회 모두 실패).

핵심 원인:
- BleManager 의 batteryTimer / mim polling 이 시간 간격 기반이라 mtb
  응답 진행 중에도 무조건 TX 발사.
- 특히 sendImuFifoQuery 는 imuCollector.reset() 을 먼저 호출 → mtb 의
  rim (IMU) 이 파괴됨.

Layer 1 — BleManager gating
  - isMtbBusy 프로퍼티 추가 (piezoCollector.isMultiChannel && !isComplete)
  - batteryTimer / batteryRetryTimer: sendBatteryQuery 앞에 isMtbBusy skip
  - Watchdog silence heartbeat: sendImuQuery (msp) → sendImuFifoQuery
    (mim) 로 교체 + isMtbBusy skip
  - sendImuQuery 함수 자체 삭제 — msp 명령 코드에서 완전 제거
    (rsp 파서는 legacy 응답용 유지)

Layer 2 — PiezoMonitoringView mim polling gating
  - LaunchedEffect while 루프의 sendImuFifoQuery 앞에 isMtbBusy skip

Layer 3 — mtb 3초 timeout + UI 안내
  - sendMtb: 3초 timeout runnable, raa 응답 오면 취소
  - lastMtbTimeoutAt / consecutiveMtbTimeouts state 노출
  - PlacementGuideView 가 관찰 → 1~2회: "기기 응답 지연" 안내
    3회 연속: "재연결" 안내 + forceDisconnectAndReconnect 자동 호출
  - forceDisconnectAndReconnect 를 public 으로 승격

부가:
- disconnect 3곳 (GATT_ERROR / STATE_DISCONNECTED / watchdog forced)
  에서 mtbTimeoutRunnable 도 함께 정리.

Firmware VBTFW0121 의 mls mode 0 handler 취약점은 별도로 펌웨어 팀에
리포트 필요 (본 fix 는 큐 혼잡 방지로 근본적으로 회피).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 15:03:48 +09:00
parent deac2f50eb
commit 990793170e
5 changed files with 80 additions and 11 deletions
@@ -536,6 +536,21 @@ class BleManager private constructor(private val context: Context) {
piezoCollector.startMultiChannel(6)
imuCollector.reset()
sendRaw(CRC16.buildCommandASCII("mtb", " "))
// 2026-07-08: 3초 timeout. raa 응답이 오면 취소 (아래 processReceivedData 참조).
// Timeout 시 lastMtbTimeoutAt 갱신 + consecutiveMtbTimeouts 증가 → 상위 UI 안내.
mtbTimeoutRunnable?.let { handler.removeCallbacks(it) }
val to = Runnable {
if (isMtbBusy) {
lastMtbTimeoutAt.value = System.currentTimeMillis()
consecutiveMtbTimeouts.value = consecutiveMtbTimeouts.value + 1
debugLogger.warn("MTB_TIMEOUT after ${MTB_TIMEOUT_MS}ms (consecutive=${consecutiveMtbTimeouts.value})")
// collector 상태 해제 — 다음 mtb 는 정상적으로 시작 가능
piezoCollector.reset()
imuCollector.reset()
}
}
mtbTimeoutRunnable = to
handler.postDelayed(to, MTB_TIMEOUT_MS)
return true
}
@@ -582,9 +597,27 @@ class BleManager private constructor(private val context: Context) {
sendRaw(CRC16.buildCommandBE("mls", intArrayOf(state)))
}
fun sendImuQuery() {
sendRaw(CRC16.buildCommandASCII("msp", " "))
}
// 2026-07-08: sendImuQuery() (msp) 완전 제거. 신 firmware 는 mim (FIFO 15 sample) 만 사용.
// Watchdog heartbeat 도 sendImuFifoQuery (mim) 로 통일.
// ※ debugLogger 명령 매핑 (msp → "IMU query") 은 legacy 응답 (rsp:) 파싱용으로 유지.
/**
* mtb 응답 진행 중 여부 — Battery/IMU 폴링이 mtb 응답 stream (reb×6 + raa + rim) 중간에
* 끼어들면 firmware GATT queue 가 꼬여 응답 실종 → freeze 유발.
* 이 property 를 batteryTimer / mim polling / watchdog heartbeat 앞에서 확인해 skip.
*/
val isMtbBusy: Boolean
get() = piezoCollector.isMultiChannel && !piezoCollector.isComplete
/**
* mtb 응답 3초 timeout 후 갱신되는 마지막 timeout 시각. 상위 UI 가 관찰해서
* "기기 응답 지연" 배너 표시. 0 = 아직 timeout 없음.
*/
val lastMtbTimeoutAt = mutableStateOf(0L)
/** mtb 연속 timeout 카운터. 3회 이상 시 재연결 유도. */
val consecutiveMtbTimeouts = mutableStateOf(0)
private var mtbTimeoutRunnable: Runnable? = null
private val MTB_TIMEOUT_MS = 3000L
/**
* IMU FIFO 폴링 (2026-07-03 펌웨어 신규 mim?):
@@ -592,6 +625,7 @@ class BleManager private constructor(private val context: Context) {
* - IMU 15 sample (300ms window) 를 rim: 프레임으로 반환
* - 응답 스키마는 mtb? 의 rim: 와 동일 → imuCollector.parseRim + onComplete 재사용
* idle 폴링에서 walking detector 의 sliding window 를 채우기 위해 사용.
* mtb 진행 중에는 imuCollector.reset() 이 mtb 의 rim 을 파괴하므로 caller 가 gating 필수.
*/
fun sendImuFifoQuery() {
imuCollector.reset()
@@ -663,7 +697,8 @@ class BleManager private constructor(private val context: Context) {
if (batteryLevel.value == 0 && retries < BATTERY_RETRY_MAX) {
retries++
logd { "battery query retry #$retries (no response yet)" }
sendBatteryQuery()
// 2026-07-08: mtb 응답 진행 중이면 skip — 다음 주기까지 대기 (queue 충돌 방지).
if (!isMtbBusy) sendBatteryQuery()
handler.postDelayed(this, BATTERY_RETRY_DELAY_MS)
}
}
@@ -675,7 +710,8 @@ class BleManager private constructor(private val context: Context) {
val pollRunnable = object : Runnable {
override fun run() {
if (!isConnected.value) return
sendBatteryQuery()
// 2026-07-08: mtb 응답 진행 중이면 skip — 30초 주기라 다음 tick 에 잡힘.
if (!isMtbBusy) sendBatteryQuery()
handler.postDelayed(this, BATTERY_POLL_INTERVAL_MS)
}
}
@@ -709,9 +745,12 @@ class BleManager private constructor(private val context: Context) {
val silenceMs = System.currentTimeMillis() - lastRxTimestamp
// Idle 10초 이상이면 IMU query로 heartbeat
if (silenceMs > 10000 && silenceMs <= watchdogTimeoutMs) {
handler.post { sendImuQuery() }
// Idle 10초 이상이면 IMU query로 heartbeat.
// 2026-07-08: sendImuQuery() (msp) → sendImuFifoQuery() (mim). msp 는 신
// firmware 에서 미지원. isMtbBusy 시 skip — mtb 응답 중간에 mim 이 끼어들면
// imuCollector.reset() 이 mtb 의 rim 을 파괴 + GATT queue 혼잡 유발.
if (silenceMs > 10000 && silenceMs <= watchdogTimeoutMs && !isMtbBusy) {
handler.post { if (!isMtbBusy) sendImuFifoQuery() }
}
if (silenceMs > watchdogTimeoutMs) {
@@ -731,6 +770,7 @@ class BleManager private constructor(private val context: Context) {
txCharacteristic = null
rxCharacteristic = null
fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null }
mtbTimeoutRunnable?.let { handler.removeCallbacks(it); mtbTimeoutRunnable = null }
cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null }
stopBatteryPolling()
isConnected.value = false
@@ -751,7 +791,7 @@ class BleManager private constructor(private val context: Context) {
watchdogJob = null
}
private fun forceDisconnectAndReconnect() {
fun forceDisconnectAndReconnect() {
stopBatteryPolling()
stopWatchdog()
isConnected.value = false
@@ -893,6 +933,7 @@ class BleManager private constructor(private val context: Context) {
stopWatchdog()
// 2026-07-07 fix: 연결 실패 시 대기 중인 timer 취소.
fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null }
mtbTimeoutRunnable?.let { handler.removeCallbacks(it); mtbTimeoutRunnable = null }
cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null }
bluetoothGatt = null
txCharacteristic = null
@@ -967,6 +1008,7 @@ class BleManager private constructor(private val context: Context) {
stopBatteryPolling()
// 2026-07-07 fix: disconnect 시 대기 중인 timer 취소.
fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null }
mtbTimeoutRunnable?.let { handler.removeCallbacks(it); mtbTimeoutRunnable = null }
cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null }
bluetoothGatt = null
txCharacteristic = null
@@ -1208,6 +1250,11 @@ class BleManager private constructor(private val context: Context) {
val tag = prefix.take(3)
debugLogger.rx(tag, data.size, if (tag == "raa") "all-ch complete" else "single-ch end")
piezoCollector.addPacket(data)
// 2026-07-08: mtb 응답 완료 → timeout runnable 취소 + 연속 실패 카운터 리셋.
if (tag == "raa") {
mtbTimeoutRunnable?.let { handler.removeCallbacks(it); mtbTimeoutRunnable = null }
if (consecutiveMtbTimeouts.value != 0) consecutiveMtbTimeouts.value = 0
}
}
"rsn:" -> {
if (data.size >= 6) {