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:
@@ -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) {
|
||||
|
||||
+5
-2
@@ -847,8 +847,11 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
// Auto Scan 자체는 mtb 응답에 IMU 가 들어있어 자동으로 posture 갱신됨 (parseRim).
|
||||
if (isConnected && !isMeasuring && !isSpotInProgress) {
|
||||
if (!isAutoMeasuring) {
|
||||
// idle — mim FIFO 폴링 (15샘플/burst, 무음)
|
||||
bleManager.sendImuFifoQuery()
|
||||
// idle — mim FIFO 폴링 (15샘플/burst, 무음).
|
||||
// 2026-07-08: isMtbBusy 시 skip. 다른 화면 (Alignment) 이 mtb 폴링 중이면
|
||||
// mim 의 imuCollector.reset() 이 mtb 의 rim 파괴 + GATT queue 혼잡 →
|
||||
// firmware freeze 실측 확인. 다음 tick 에 재시도.
|
||||
if (!bleManager.isMtbBusy) bleManager.sendImuFifoQuery()
|
||||
}
|
||||
// isAutoMeasuring=true 면 mtb 의 rim: 가 알아서 posture 갱신, mim skip
|
||||
}
|
||||
|
||||
@@ -173,6 +173,21 @@ fun PlacementGuideView(appState: AppState) {
|
||||
}
|
||||
}
|
||||
|
||||
// 2026-07-08: mtb 응답 3초 timeout 감지 → UI 안내 + 3회 연속 시 강제 재연결.
|
||||
// BleManager.sendMtb 는 raa 응답 대기 3초 후 consecutiveMtbTimeouts 를 증가시킴.
|
||||
val mtbTimeoutCount by bleManager.consecutiveMtbTimeouts
|
||||
LaunchedEffect(mtbTimeoutCount) {
|
||||
if (mtbTimeoutCount in 1..2) {
|
||||
directionHint = context.getString(R.string.mtb_response_delayed)
|
||||
directionIcon = ""
|
||||
} else if (mtbTimeoutCount >= 3) {
|
||||
directionHint = context.getString(R.string.mtb_response_reconnect)
|
||||
directionIcon = ""
|
||||
bleManager.debugLogger.warn("MTB_FORCE_RECONNECT after $mtbTimeoutCount consecutive timeouts")
|
||||
bleManager.forceDisconnectAndReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
// Detachment alert (rising edge → 팝업, 재부착 시 자동 해제)
|
||||
var showDetachAlert by remember { mutableStateOf(false) }
|
||||
var prevDetached by remember { mutableStateOf(false) }
|
||||
|
||||
Reference in New Issue
Block a user