feat(ble): CommandQueue · 응답 대기 후 다음 명령 전송 (FW 팀 지시)
배경 (2026-08-04):
FW 팀 지시 — 이전 명령의 응답 도착 전에 다음 명령 보내지 말 것.
FW GATT queue 가 얕아 백투백 write 시 silent drop / freeze 유발.
기존 isMtbBusy gate 는 일부만 방어 (battery/IMU polling ↔ mtb 중간)
→ 모든 명령에 통일된 sequential 큐 도입.
CommandQueue.kt (신규 · 155 line):
- QueuedCommand(data, label, isDone(tag3), timeoutMs=3000)
- enqueue → pending==null 이면 즉시 dispatch, 아니면 대기
- 응답 tag 마다 onResponseTag() → pending.isDone 체크
- 공통 에러 응답 (rxx/rxd/rxn/rxc/rxs) 은 무조건 완료 처리
- 3초 timeout → onDrop 콜백 (UI observer 갱신 포함)
- clear(reason) — disconnect 경로에서 pending + 큐 전체 drop
- Thread-safe (synchronized on internal lock)
BleManager.kt:
- sendRaw() → sendRawWrite() private (실제 GATT write)
- 새 sendRaw() = 단일 응답 (m→r 자동 치환) 명령용 wrapper · enqueue 위임
- 스트리밍 명령은 자체 send fn 에서 explicit enqueue:
· maa/mbb → 종료 = raa && piezoCollector.isComplete
· mtb → 종료 = (rim|ric|raa) && piezo done && imu samples 채워짐
· mim → 종료 = (rim|ric) && imu samples 채워짐
· mec → 종료 = raa
- processReceivedData 말미에 commandQueue.onResponseTag(tag3) hook
- 공통 에러 응답 5종 (rxx/rxd/rxn/rxc/rxs) case 신설 · 로그
- disconnect 5 경로에 commandQueue.clear("disconnect") 추가
- onDrop 콜백에서 mtb timeout 시 UI observer (lastMtbTimeoutAt +
consecutiveMtbTimeouts) 갱신 · 기존 UX 유지
BLE 스펙 참조: ppt/ble.xlsx (25 명령 · 응답 태그 표).
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user