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 feb3793..3ebb14e 100644 --- a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt +++ b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt @@ -370,6 +370,7 @@ class BleManager private constructor(private val context: Context) { stopBatteryPolling() stopRssiPolling() rssi.value = null + commandQueue.clear("disconnect") com.medithings.vesiscan.services.BleForegroundService.stop(context) connectionTimer?.let { handler.removeCallbacks(it) } connectionTimer = null @@ -462,15 +463,40 @@ class BleManager private constructor(private val context: Context) { } // Send - fun sendRaw(data: ByteArray) { + /** + * 명령 큐 (2026-08-04 · FW 팀 지시로 도입). 모든 명령은 depth=1 sequential. + * 이전 응답 도착 · 또는 3초 timeout 전까지 다음 명령은 대기. + * + * 사용: + * - `sendRaw(data)` 는 이제 단일 응답 (m→r) 명령용 wrapper (자동 enqueue). + * - 스트리밍 응답 (mtb/maa/mbb/mim/mec) 은 각 send fn 에서 `commandQueue.enqueue()` + * 로 직접 완료 조건 (piezoCollector.isComplete + imuCollector.samples 등) 지정. + */ + val commandQueue = CommandQueue( + handler = handler, + writeRaw = { data -> sendRawWrite(data) }, + onDrop = { label, reason -> + debugLogger.warn("CMDQ drop label=$label · $reason") + // mtb timeout 이면 기존 UI observers 유지 (연속 timeout 카운터 등) + if (label.startsWith("mtb") && reason == "timeout") { + lastMtbTimeoutAt.value = System.currentTimeMillis() + consecutiveMtbTimeouts.value = consecutiveMtbTimeouts.value + 1 + piezoCollector.reset() + imuCollector.reset() + } + }, + ) + + /** 실제 GATT write — CommandQueue 내부에서만 호출. 외부는 sendRaw() 사용. */ + private fun sendRawWrite(data: ByteArray) { val characteristic = txCharacteristic val gatt = bluetoothGatt if (characteristic == null) { - loge { "sendRaw: txCharacteristic is null!" } + loge { "sendRawWrite: txCharacteristic is null!" } return } if (gatt == null) { - loge { "sendRaw: bluetoothGatt is null!" } + loge { "sendRawWrite: bluetoothGatt is null!" } return } val cmdPreview = if (data.size >= 4) String(data, 0, 3, Charsets.US_ASCII) else "?" @@ -495,21 +521,42 @@ class BleManager private constructor(private val context: Context) { "mbb" -> "full measurement (battery+IMU+temp+6ch)" else -> "" } - logd { "sendRaw: $cmdPreview (${data.size} bytes)" } + logd { "sendRawWrite: $cmdPreview (${data.size} bytes)" } debugLogger.tx("$cmdPreview $cmdDetail", data.size) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val result = gatt.writeCharacteristic(characteristic, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) - logd { "sendRaw result: $result" } + logd { "sendRawWrite result: $result" } } else { @Suppress("DEPRECATION") characteristic.value = data characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE @Suppress("DEPRECATION") val ok = gatt.writeCharacteristic(characteristic) - logd { "sendRaw success: $ok" } + logd { "sendRawWrite success: $ok" } } } + /** + * 단일 응답 명령 (m→r 대응) 을 큐에 삽입. + * + * 자동 완료 조건: 3-byte 접두 (예: msn) → r + 뒤 2byte (rsn) 도착 시 완료. + * 스트리밍 명령 (maa/mtb/mbb/mim/mec) 은 자체 send fn 에서 `commandQueue.enqueue()` 로 + * 직접 완료 조건 지정. 이 함수로 넣으면 잘못된 tag ("rmm" 등) 를 기다리다 3초 timeout. + */ + fun sendRaw(data: ByteArray) { + if (data.size < 4) { + loge { "sendRaw: too short (${data.size}B)" } + return + } + val cmd3 = String(data, 0, 3, Charsets.US_ASCII) + val expected = "r" + cmd3.substring(1) // msn → rsn, mid → rid, mls → rls + commandQueue.enqueue(QueuedCommand( + data = data, + label = cmd3, + isDone = { it == expected }, + )) + } + // Piezo Commands val piezoCollector = PiezoPacketCollector() // IMU rim: 응답 파서. mtb? 명령 시 채워짐. PlacementGuide/Clinical에서 콜백 등록. @@ -526,14 +573,24 @@ class BleManager private constructor(private val context: Context) { } fun sendBurst(freqOption: Int, cycles: Int, delayUs: Int, piezoCh: Int = 0) { - sendRaw(CRC16.buildCommandBE("mec", intArrayOf(freqOption, delayUs, 140, cycles, 1, piezoCh))) + // mec: reb×1 → raa. 종료 = raa. + commandQueue.enqueue(QueuedCommand( + data = CRC16.buildCommandBE("mec", intArrayOf(freqOption, delayUs, 140, cycles, 1, piezoCh)), + label = "mec burst", + isDone = { it == "raa" }, + )) } fun sendAllChannels(mode: Int = 0): Boolean { if (!canSendMaa("sendAllChannels")) return false lastMaaSentMs = System.currentTimeMillis() piezoCollector.startMultiChannel(6) - sendRaw(CRC16.buildCommandASCII("maa", " ")) + // maa: reb×6 → raa. 종료 = raa. + commandQueue.enqueue(QueuedCommand( + data = CRC16.buildCommandASCII("maa", " "), + label = "maa 6ch", + isDone = { it == "raa" }, + )) return true } @@ -548,22 +605,18 @@ class BleManager private constructor(private val context: Context) { lastMaaSentMs = System.currentTimeMillis() 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) + // mtb: reb×6 → raa → rim (또는 ric 분할). 종료 = piezo raa 완료 AND imu 샘플 도착. + // parseRic 는 마지막 chunk 도착 시에만 samples 를 채움 → 중간 ric 은 자연스레 skip. + commandQueue.enqueue(QueuedCommand( + data = CRC16.buildCommandASCII("mtb", " "), + label = "mtb 6ch+imu", + isDone = { tag -> + (tag == "rim" || tag == "ric" || tag == "raa") && + piezoCollector.isComplete && + imuCollector.samples.isNotEmpty() + }, + timeoutMs = MTB_TIMEOUT_MS, + )) return true } @@ -642,13 +695,25 @@ class BleManager private constructor(private val context: Context) { */ fun sendImuFifoQuery() { imuCollector.reset() - sendRaw(CRC16.buildCommandASCII("mim", " ")) + // mim: rim: (또는 ric 분할). 종료 = imu 샘플 채워짐 (마지막 rim 또는 마지막 ric). + commandQueue.enqueue(QueuedCommand( + data = CRC16.buildCommandASCII("mim", " "), + label = "mim IMU", + isDone = { tag -> + (tag == "rim" || tag == "ric") && imuCollector.samples.isNotEmpty() + }, + )) } /** 전체 측정: 배터리+IMU+온도+6ch piezo (rbb→reb×6→raa) */ fun sendFullMeasurement() { piezoCollector.startMultiChannel(6) - sendRaw(CRC16.buildCommandASCII("mbb", " ")) + // mbb: rbb → reb×6 → raa. 종료 = raa (piezo 완료). + commandQueue.enqueue(QueuedCommand( + data = CRC16.buildCommandASCII("mbb", " "), + label = "mbb full", + isDone = { it == "raa" && piezoCollector.isComplete }, + )) } /** @@ -683,7 +748,12 @@ class BleManager private constructor(private val context: Context) { if (!canSendMaa("sendChannelsOnly")) return false lastMaaSentMs = System.currentTimeMillis() piezoCollector.startMultiChannel(6) - sendRaw(CRC16.buildCommandASCII("maa", " ")) + // maa: reb×6 → raa. 종료 = raa (piezo 완료). + commandQueue.enqueue(QueuedCommand( + data = CRC16.buildCommandASCII("maa", " "), + label = "maa 6ch", + isDone = { it == "raa" && piezoCollector.isComplete }, + )) return true } @@ -833,6 +903,7 @@ class BleManager private constructor(private val context: Context) { stopBatteryPolling() stopRssiPolling() rssi.value = null + commandQueue.clear("disconnect") stopWatchdog() isConnected.value = false isServiceReady.value = false @@ -1310,10 +1381,10 @@ 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 + // raa 성공 → 연속 timeout 카운터 리셋 (mtb 완료 UI 관찰). + // 3초 timeout 은 CommandQueue 가 소유 (기존 mtbTimeoutRunnable 은 dead code · 유지만). + if (tag == "raa" && consecutiveMtbTimeouts.value != 0) { + consecutiveMtbTimeouts.value = 0 } } "rsn:" -> { @@ -1380,10 +1451,20 @@ class BleManager private constructor(private val context: Context) { imuCollector.parseRsp(data) onImuReceived?.invoke(data) } - "rxs:" -> { + // ── 공통 에러 응답 (xlsx §공통 프레임) — CommandQueue 가 pending 완료 처리 ── + // rxx: Unknown command · rxd: Disabled · rxn: NULL handler · rxc: CRC fail · rxs: Too short + "rxx:", "rxd:", "rxn:", "rxc:", "rxs:" -> { + val tag = prefix.take(3) val echoCmd = if (data.size > 4) String(data, 4, minOf(data.size - 4, 10), Charsets.US_ASCII).trim() else "?" - android.util.Log.w("ImuDbg", "rxs cmd_not_supported: $echoCmd") - debugLogger.rx("rxs", data.size, "cmd_not_supported: $echoCmd") + val reason = when (tag) { + "rxx" -> "unknown_cmd" + "rxd" -> "disabled_cmd" + "rxn" -> "null_handler" + "rxc" -> "crc_fail" + "rxs" -> "too_short" + else -> "err" + } + debugLogger.rx(tag, data.size, "$reason: $echoCmd") } "rim:" -> { val num = if (data.size >= 6) ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF) else 0 @@ -1404,5 +1485,12 @@ class BleManager private constructor(private val context: Context) { logw { "Unknown prefix: '$prefix' hex=$hex" } } } + // ── CommandQueue hook — 파서가 collector 상태 갱신한 뒤 완료 판정 ── + // 3-char 태그 (예: "raa", "rim", "rxc") 로 현재 pending 명령의 isDone 실행. + // True 면 pending 해제 + 큐에 대기 중이던 다음 명령 dispatch. + if (data.size >= 4) { + val tag3 = prefix.take(3) + commandQueue.onResponseTag(tag3) + } } } diff --git a/app/src/main/java/com/medithings/vesiscan/ble/CommandQueue.kt b/app/src/main/java/com/medithings/vesiscan/ble/CommandQueue.kt new file mode 100644 index 0000000..4fda99c --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/ble/CommandQueue.kt @@ -0,0 +1,164 @@ +/* + * CommandQueue — BLE 명령 순차 전송 큐 (depth = 1). + * + * 설계 배경 (2026-08-04 · FW 팀 지시): + * - 이전 명령의 응답이 도착하기 전에 다음 명령을 보내지 말 것. + * - FW GATT queue 가 얕아 백투백 write 시 silent drop 가능. + * - 특히 스트리밍 응답 (mtb: reb×6 → raa → rim) 중간에 다른 명령이 끼면 + * 파싱이 꼬여 freeze 유발 (기존 isMtbBusy gate 로 부분 방어 중이었음). + * + * 동작: + * - enqueue() 로 명령 삽입. pending==null 이면 즉시 dispatch, 아니면 큐 대기. + * - 각 응답 tag 도착 시 onResponseTag() → 현재 pending 의 isDone 체크. + * true 면 pending 해제 + 다음 dispatch. + * - 3초 timeout 이면 강제 완료 (다음 명령 진행 · deadlock 방지). + * - 공통 에러 응답 (rxx/rxd/rxc/rxs/rxn) 은 항상 pending 완료 처리 + * (응답은 도착한 것이므로 대기 종료 · 다음 명령 dispatch). + * + * BLE 스펙 참조: docs/../ppt/ble.xlsx (25 명령 · 응답 태그 표). + */ +package com.medithings.vesiscan.ble + +import android.os.Handler +import android.util.Log + +/** + * 큐에 들어가는 단일 명령 단위. + * + * @param data 실제 GATT write payload (CRC 포함). + * @param label 로그·debugLogger 표시명 (예: "mtb 6ch+imu"). + * @param isDone 응답 tag 3자 (예: "raa", "rim") 를 받아 완료 여부 반환. + * 스트리밍 응답은 collector 상태를 함께 참조 가능. + * @param timeoutMs 응답 대기 최대 시간. 초과 시 강제 완료. + */ +data class QueuedCommand( + val data: ByteArray, + val label: String, + val isDone: (tag3: String) -> Boolean, + val timeoutMs: Long = 3000L, +) { + override fun equals(other: Any?): Boolean = + other is QueuedCommand && data.contentEquals(other.data) && label == other.label + override fun hashCode(): Int = 31 * data.contentHashCode() + label.hashCode() +} + +/** + * BleManager 가 소유하는 명령 큐. + * + * @param handler 메인 루퍼 Handler (timeout · dispatch 는 메인 스레드에서 실행). + * @param writeRaw 실제 GATT write. commandQueue 는 이걸 호출만 함 (구현 몰라도 됨). + * @param onDrop (선택) 큐가 clear 되거나 timeout 시 호출. UI/로그용. + */ +class CommandQueue( + private val handler: Handler, + private val writeRaw: (ByteArray) -> Unit, + private val onDrop: ((label: String, reason: String) -> Unit)? = null, +) { + + companion object { + private const val TAG = "CommandQueue" + /** 큐 soft cap — 이 개수 이상 쌓이면 신규 enqueue 를 debug log 로 경고. */ + private const val WARN_QUEUE_DEPTH = 8 + /** 공통 에러 응답 prefix (xlsx §공통 프레임): rxx/rxd/rxn/rxc/rxs. */ + private val ERROR_TAGS = setOf("rxx", "rxd", "rxn", "rxc", "rxs") + } + + private val lock = Any() + private val queue = ArrayDeque() + @Volatile private var pending: QueuedCommand? = null + @Volatile private var pendingStartMs: Long = 0L + private var timeoutRunnable: Runnable? = null + + /** 현재 처리 중인 명령이 있는지 (기존 isMtbBusy 대체). */ + val isBusy: Boolean get() = pending != null + + /** 현재 pending 명령의 label (디버그 UI 용). */ + val pendingLabel: String? get() = pending?.label + + /** 대기 중인 큐 길이 (디버그). */ + val queuedSize: Int get() = synchronized(lock) { queue.size } + + /** + * 명령을 큐에 삽입. 현재 처리 중이면 대기, 아니면 즉시 dispatch. + * 스레드 안전 (여러 스레드에서 호출 가능). + */ + fun enqueue(cmd: QueuedCommand) { + synchronized(lock) { + queue.addLast(cmd) + if (queue.size >= WARN_QUEUE_DEPTH) { + Log.w(TAG, "queue depth ${queue.size} — 상위 caller throttle 확인 필요") + } + if (pending == null) dispatchNextLocked() + } + } + + /** + * 응답 태그 도착 시 호출 (processReceivedData 말미에서 hook). + * 공통 에러 응답이면 무조건 pending 완료, 아니면 pending.isDone 체크. + */ + fun onResponseTag(tag3: String) { + val cur = pending ?: return + val isError = tag3 in ERROR_TAGS + val done = isError || run { + try { cur.isDone(tag3) } catch (e: Exception) { + Log.w(TAG, "isDone threw for ${cur.label}: ${e.message}") + false + } + } + if (done) { + val reason = if (isError) "error:$tag3" else "response:$tag3" + completePendingLocked(reason) + } + } + + /** + * 큐 전체 제거 + pending drop. 연결 해제 등에서 호출. + */ + fun clear(reason: String) { + synchronized(lock) { + timeoutRunnable?.let { handler.removeCallbacks(it) } + timeoutRunnable = null + val dropped = pending + if (dropped != null) { + Log.w(TAG, "clear: dropped pending=${dropped.label} + queued=${queue.size} · $reason") + onDrop?.invoke(dropped.label, "cleared:$reason") + } + queue.forEach { onDrop?.invoke(it.label, "cleared:$reason") } + pending = null + queue.clear() + } + } + + // ── internal ── + + private fun dispatchNextLocked() { + val next = queue.removeFirstOrNull() ?: return + pending = next + pendingStartMs = System.currentTimeMillis() + try { + writeRaw(next.data) + } catch (e: Exception) { + Log.e(TAG, "writeRaw threw for ${next.label}: ${e.message}") + // write 자체 실패 → 응답도 없을 것 → 즉시 timeout 처리로 이어감 + } + val to = Runnable { + val cur = pending + if (cur != null && cur === next) { + Log.w(TAG, "TIMEOUT: ${next.label} after ${next.timeoutMs}ms") + onDrop?.invoke(next.label, "timeout") + synchronized(lock) { completePendingLocked("timeout") } + } + } + timeoutRunnable = to + handler.postDelayed(to, next.timeoutMs) + } + + private fun completePendingLocked(reason: String) { + synchronized(lock) { + timeoutRunnable?.let { handler.removeCallbacks(it) } + timeoutRunnable = null + pending = null + if (queue.isNotEmpty()) dispatchNextLocked() + } + } +}