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)
|
piezoCollector.startMultiChannel(6)
|
||||||
imuCollector.reset()
|
imuCollector.reset()
|
||||||
sendRaw(CRC16.buildCommandASCII("mtb", " "))
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,9 +597,27 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
sendRaw(CRC16.buildCommandBE("mls", intArrayOf(state)))
|
sendRaw(CRC16.buildCommandBE("mls", intArrayOf(state)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendImuQuery() {
|
// 2026-07-08: sendImuQuery() (msp) 완전 제거. 신 firmware 는 mim (FIFO 15 sample) 만 사용.
|
||||||
sendRaw(CRC16.buildCommandASCII("msp", " "))
|
// 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?):
|
* IMU FIFO 폴링 (2026-07-03 펌웨어 신규 mim?):
|
||||||
@@ -592,6 +625,7 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
* - IMU 15 sample (300ms window) 를 rim: 프레임으로 반환
|
* - IMU 15 sample (300ms window) 를 rim: 프레임으로 반환
|
||||||
* - 응답 스키마는 mtb? 의 rim: 와 동일 → imuCollector.parseRim + onComplete 재사용
|
* - 응답 스키마는 mtb? 의 rim: 와 동일 → imuCollector.parseRim + onComplete 재사용
|
||||||
* idle 폴링에서 walking detector 의 sliding window 를 채우기 위해 사용.
|
* idle 폴링에서 walking detector 의 sliding window 를 채우기 위해 사용.
|
||||||
|
* mtb 진행 중에는 imuCollector.reset() 이 mtb 의 rim 을 파괴하므로 caller 가 gating 필수.
|
||||||
*/
|
*/
|
||||||
fun sendImuFifoQuery() {
|
fun sendImuFifoQuery() {
|
||||||
imuCollector.reset()
|
imuCollector.reset()
|
||||||
@@ -663,7 +697,8 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
if (batteryLevel.value == 0 && retries < BATTERY_RETRY_MAX) {
|
if (batteryLevel.value == 0 && retries < BATTERY_RETRY_MAX) {
|
||||||
retries++
|
retries++
|
||||||
logd { "battery query retry #$retries (no response yet)" }
|
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)
|
handler.postDelayed(this, BATTERY_RETRY_DELAY_MS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -675,7 +710,8 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
val pollRunnable = object : Runnable {
|
val pollRunnable = object : Runnable {
|
||||||
override fun run() {
|
override fun run() {
|
||||||
if (!isConnected.value) return
|
if (!isConnected.value) return
|
||||||
sendBatteryQuery()
|
// 2026-07-08: mtb 응답 진행 중이면 skip — 30초 주기라 다음 tick 에 잡힘.
|
||||||
|
if (!isMtbBusy) sendBatteryQuery()
|
||||||
handler.postDelayed(this, BATTERY_POLL_INTERVAL_MS)
|
handler.postDelayed(this, BATTERY_POLL_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -709,9 +745,12 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
|
|
||||||
val silenceMs = System.currentTimeMillis() - lastRxTimestamp
|
val silenceMs = System.currentTimeMillis() - lastRxTimestamp
|
||||||
|
|
||||||
// Idle 10초 이상이면 IMU query로 heartbeat
|
// Idle 10초 이상이면 IMU query로 heartbeat.
|
||||||
if (silenceMs > 10000 && silenceMs <= watchdogTimeoutMs) {
|
// 2026-07-08: sendImuQuery() (msp) → sendImuFifoQuery() (mim). msp 는 신
|
||||||
handler.post { sendImuQuery() }
|
// 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) {
|
if (silenceMs > watchdogTimeoutMs) {
|
||||||
@@ -731,6 +770,7 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
txCharacteristic = null
|
txCharacteristic = null
|
||||||
rxCharacteristic = null
|
rxCharacteristic = null
|
||||||
fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null }
|
fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null }
|
||||||
|
mtbTimeoutRunnable?.let { handler.removeCallbacks(it); mtbTimeoutRunnable = null }
|
||||||
cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null }
|
cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null }
|
||||||
stopBatteryPolling()
|
stopBatteryPolling()
|
||||||
isConnected.value = false
|
isConnected.value = false
|
||||||
@@ -751,7 +791,7 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
watchdogJob = null
|
watchdogJob = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun forceDisconnectAndReconnect() {
|
fun forceDisconnectAndReconnect() {
|
||||||
stopBatteryPolling()
|
stopBatteryPolling()
|
||||||
stopWatchdog()
|
stopWatchdog()
|
||||||
isConnected.value = false
|
isConnected.value = false
|
||||||
@@ -893,6 +933,7 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
stopWatchdog()
|
stopWatchdog()
|
||||||
// 2026-07-07 fix: 연결 실패 시 대기 중인 timer 취소.
|
// 2026-07-07 fix: 연결 실패 시 대기 중인 timer 취소.
|
||||||
fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null }
|
fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null }
|
||||||
|
mtbTimeoutRunnable?.let { handler.removeCallbacks(it); mtbTimeoutRunnable = null }
|
||||||
cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null }
|
cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null }
|
||||||
bluetoothGatt = null
|
bluetoothGatt = null
|
||||||
txCharacteristic = null
|
txCharacteristic = null
|
||||||
@@ -967,6 +1008,7 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
stopBatteryPolling()
|
stopBatteryPolling()
|
||||||
// 2026-07-07 fix: disconnect 시 대기 중인 timer 취소.
|
// 2026-07-07 fix: disconnect 시 대기 중인 timer 취소.
|
||||||
fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null }
|
fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null }
|
||||||
|
mtbTimeoutRunnable?.let { handler.removeCallbacks(it); mtbTimeoutRunnable = null }
|
||||||
cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null }
|
cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null }
|
||||||
bluetoothGatt = null
|
bluetoothGatt = null
|
||||||
txCharacteristic = null
|
txCharacteristic = null
|
||||||
@@ -1208,6 +1250,11 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
val tag = prefix.take(3)
|
val tag = prefix.take(3)
|
||||||
debugLogger.rx(tag, data.size, if (tag == "raa") "all-ch complete" else "single-ch end")
|
debugLogger.rx(tag, data.size, if (tag == "raa") "all-ch complete" else "single-ch end")
|
||||||
piezoCollector.addPacket(data)
|
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:" -> {
|
"rsn:" -> {
|
||||||
if (data.size >= 6) {
|
if (data.size >= 6) {
|
||||||
|
|||||||
+5
-2
@@ -847,8 +847,11 @@ fun PiezoMonitoringView(appState: AppState) {
|
|||||||
// Auto Scan 자체는 mtb 응답에 IMU 가 들어있어 자동으로 posture 갱신됨 (parseRim).
|
// Auto Scan 자체는 mtb 응답에 IMU 가 들어있어 자동으로 posture 갱신됨 (parseRim).
|
||||||
if (isConnected && !isMeasuring && !isSpotInProgress) {
|
if (isConnected && !isMeasuring && !isSpotInProgress) {
|
||||||
if (!isAutoMeasuring) {
|
if (!isAutoMeasuring) {
|
||||||
// idle — mim FIFO 폴링 (15샘플/burst, 무음)
|
// idle — mim FIFO 폴링 (15샘플/burst, 무음).
|
||||||
bleManager.sendImuFifoQuery()
|
// 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
|
// 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 → 팝업, 재부착 시 자동 해제)
|
// Detachment alert (rising edge → 팝업, 재부착 시 자동 해제)
|
||||||
var showDetachAlert by remember { mutableStateOf(false) }
|
var showDetachAlert by remember { mutableStateOf(false) }
|
||||||
var prevDetached by remember { mutableStateOf(false) }
|
var prevDetached by remember { mutableStateOf(false) }
|
||||||
|
|||||||
@@ -322,6 +322,8 @@
|
|||||||
<string name="placement_skip">건너뛰기</string>
|
<string name="placement_skip">건너뛰기</string>
|
||||||
<string name="placement_in_position">최적의 위치입니다!</string>
|
<string name="placement_in_position">최적의 위치입니다!</string>
|
||||||
<string name="placement_position_lost">위치를 놓쳤습니다. 치골 위에서 다시 시작해주세요.</string>
|
<string name="placement_position_lost">위치를 놓쳤습니다. 치골 위에서 다시 시작해주세요.</string>
|
||||||
|
<string name="mtb_response_delayed">기기 응답이 지연되고 있습니다. 잠시만 기다려주세요…</string>
|
||||||
|
<string name="mtb_response_reconnect">기기가 응답하지 않습니다. 연결을 재시도합니다.</string>
|
||||||
<string name="placement_detached_restart">센서가 분리되었습니다. 치골 위에서 다시 시작해주세요.</string>
|
<string name="placement_detached_restart">센서가 분리되었습니다. 치골 위에서 다시 시작해주세요.</string>
|
||||||
<string name="placement_signal_lost">신호를 놓쳤습니다 — 위로 올려주세요 ↑</string>
|
<string name="placement_signal_lost">신호를 놓쳤습니다 — 위로 올려주세요 ↑</string>
|
||||||
<string name="placement_sweep_start">배꼽 근처에서 시작해 천천히 아래로 내려주세요 ↓</string>
|
<string name="placement_sweep_start">배꼽 근처에서 시작해 천천히 아래로 내려주세요 ↓</string>
|
||||||
|
|||||||
@@ -322,6 +322,8 @@
|
|||||||
<string name="placement_skip">Skip</string>
|
<string name="placement_skip">Skip</string>
|
||||||
<string name="placement_in_position">Optimal position!</string>
|
<string name="placement_in_position">Optimal position!</string>
|
||||||
<string name="placement_position_lost">Position lost. Restart above pubic bone.</string>
|
<string name="placement_position_lost">Position lost. Restart above pubic bone.</string>
|
||||||
|
<string name="mtb_response_delayed">Device response delayed. Please wait…</string>
|
||||||
|
<string name="mtb_response_reconnect">Device not responding. Reconnecting.</string>
|
||||||
<string name="placement_detached_restart">Sensor detached. Restart above pubic bone.</string>
|
<string name="placement_detached_restart">Sensor detached. Restart above pubic bone.</string>
|
||||||
<string name="placement_signal_lost">Signal lost — slide up ↑</string>
|
<string name="placement_signal_lost">Signal lost — slide up ↑</string>
|
||||||
<string name="placement_sweep_start">Start near navel, slide down slowly ↓</string>
|
<string name="placement_sweep_start">Start near navel, slide down slowly ↓</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user