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 bb74285..8ece26a 100644 --- a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt +++ b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt @@ -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) { diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt index 3791d57..791264e 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt @@ -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 } diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PlacementGuideView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PlacementGuideView.kt index 47a88dd..3e43aa1 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PlacementGuideView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PlacementGuideView.kt @@ -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) } diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 0d48486..5a105e0 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -322,6 +322,8 @@ 건너뛰기 최적의 위치입니다! 위치를 놓쳤습니다. 치골 위에서 다시 시작해주세요. + 기기 응답이 지연되고 있습니다. 잠시만 기다려주세요… + 기기가 응답하지 않습니다. 연결을 재시도합니다. 센서가 분리되었습니다. 치골 위에서 다시 시작해주세요. 신호를 놓쳤습니다 — 위로 올려주세요 ↑ 배꼽 근처에서 시작해 천천히 아래로 내려주세요 ↓ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c676676..fe32a1e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -322,6 +322,8 @@ Skip Optimal position! Position lost. Restart above pubic bone. + Device response delayed. Please wait… + Device not responding. Reconnecting. Sensor detached. Restart above pubic bone. Signal lost — slide up ↑ Start near navel, slide down slowly ↓