feat(ble): rec: / ric: chunk reassembly — 저 MTU 기기 지원
Firmware (2026-07-21+) 는 MTU 협상값이 낮아 reb: 210B / rim: 188B 를 못 보낼 때 다중 chunk (rec: / ric:) 로 쪼개 전송함. 앱은 각 chunk 의 offset/total/chunk_samples 헤더를 읽어 채널별로 재조립해 기존 reb: / rim: 완료 경로와 동일하게 downstream 콜백을 발생시킨다. - PiezoPacketCollector: rec: chunk → channelResults[chNum] 재조립, session/dup 규칙은 reb: 와 동일. raa: 도착 시 완료 검사. - ImuPacketCollector: ric: chunk → onComplete(15 samples), offset=0 재도착 시 이전 partial 파기. - BleManager.processReceivedData: rec: / ric: 케이스 dispatch + debug log. - PiezoRecReassemblyTest / ImuRicReassemblyTest: 실측 84+16 / 14+1 split 케이스 + 재전송/중복/세션 변경 회귀 방지. 정상 MTU 247 기기의 reb: / rim: 경로에는 변경 없음. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1240,6 +1240,18 @@ class BleManager private constructor(private val context: Context) {
|
||||
piezoCollector.addPacket(data)
|
||||
lastBleRxAt.value = System.currentTimeMillis() // 기기 alive 마크
|
||||
}
|
||||
"rec:" -> {
|
||||
// 저 MTU chunk (2026-07-21 FW+): tag 4 + ch_info 2 + off 2 + total 2 + chunk 2 + ADC + CRC 2
|
||||
// → ADC bytes = size − 14. 첫/마지막 chunk 마다 이 케이스로 진입.
|
||||
val chSession = if (data.size > 4) data[4].toInt() and 0xFF else -1
|
||||
val chNum = if (data.size > 5) data[5].toInt() and 0xFF else -1
|
||||
val off = if (data.size >= 8) ((data[6].toInt() and 0xFF) shl 8) or (data[7].toInt() and 0xFF) else -1
|
||||
val total = if (data.size >= 10) ((data[8].toInt() and 0xFF) shl 8) or (data[9].toInt() and 0xFF) else -1
|
||||
val chunk = if (data.size >= 12) ((data[10].toInt() and 0xFF) shl 8) or (data[11].toInt() and 0xFF) else -1
|
||||
debugLogger.rx("rec", data.size, "session=$chSession ch=$chNum off=$off chunk=$chunk/$total")
|
||||
piezoCollector.addPacket(data)
|
||||
lastBleRxAt.value = System.currentTimeMillis()
|
||||
}
|
||||
"red:" -> {
|
||||
debugLogger.rx("red", data.size, "continuation")
|
||||
piezoCollector.addPacket(data)
|
||||
@@ -1328,6 +1340,14 @@ class BleManager private constructor(private val context: Context) {
|
||||
debugLogger.rx("rim", data.size, "IMU ${num} samples")
|
||||
imuCollector.parseRim(data)
|
||||
}
|
||||
"ric:" -> {
|
||||
// 저 MTU IMU chunk (2026-07-21 FW+): tag 4 + off 2 + total 2 + chunk 2 + IMU 12·chunk + CRC 2
|
||||
val off = if (data.size >= 6) ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF) else -1
|
||||
val total = if (data.size >= 8) ((data[6].toInt() and 0xFF) shl 8) or (data[7].toInt() and 0xFF) else -1
|
||||
val chunk = if (data.size >= 10) ((data[8].toInt() and 0xFF) shl 8) or (data[9].toInt() and 0xFF) else -1
|
||||
debugLogger.rx("ric", data.size, "IMU off=$off chunk=$chunk/$total")
|
||||
imuCollector.parseRic(data)
|
||||
}
|
||||
else -> {
|
||||
val hex = data.take(8).joinToString(" ") { "%02X".format(it) }
|
||||
debugLogger.rx("???", data.size, "unknown prefix='$prefix' hex=$hex")
|
||||
|
||||
@@ -38,16 +38,26 @@ class ImuPacketCollector {
|
||||
var onComplete: ((List<ImuSample>) -> Unit)? = null
|
||||
var onLog: ((String) -> Unit)? = null
|
||||
|
||||
// ric: chunk 재조립 상태 (MTU 분할 전송, 2026-07-21 FW+).
|
||||
// firmware 는 rim: 188B 를 못 보낼 때 여러 ric: chunk 로 쪼개 전송.
|
||||
// 각 chunk 는 12B header + 12B·chunk sample + 2B CRC.
|
||||
// 예: MTU 185 → 첫 chunk (14 samples, 180B), 마지막 chunk (1 sample, 24B).
|
||||
private var ricTotal: Int = 0
|
||||
private var ricReceived: Int = 0
|
||||
private var ricBuffer: Array<ImuSample?>? = null
|
||||
|
||||
companion object {
|
||||
private const val ACCEL_LSB_PER_G = 8192f // ±4g FSR
|
||||
private const val GYRO_LSB_PER_DPS = 65.536f // ±500 dps FSR
|
||||
private const val SAMPLE_SIZE_BYTES = 12
|
||||
private const val HEADER_BYTES = 6 // tag(4) + num(2)
|
||||
private const val CRC_BYTES = 2
|
||||
private const val RIC_HEADER_BYTES = 10 // tag(4) + off(2) + total(2) + chunk(2)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
samples = emptyList()
|
||||
ricTotal = 0; ricReceived = 0; ricBuffer = null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,6 +182,84 @@ class ImuPacketCollector {
|
||||
onComplete?.invoke(parsed)
|
||||
}
|
||||
|
||||
/**
|
||||
* ric: chunk 패킷 파서 (MTU 분할, 2026-07-21 FW+).
|
||||
*
|
||||
* Packet 구조 (10B header + 12B·chunk sample + 2B CRC):
|
||||
* [tag 4B "ric:"] [offset 2B BE] [total_samples 2B BE] [chunk_samples 2B BE]
|
||||
* [(Ax 2B)(Ay 2B)(Az 2B)(Gx 2B)(Gy 2B)(Gz 2B)] × chunk_samples
|
||||
* [CRC 2B]
|
||||
*
|
||||
* 마지막 chunk (offset + chunk == total) 도착 시 rim: 완료와 동일하게 onComplete 호출.
|
||||
* 실측 (MTU 185): off=0 total=15 chunk=14 len=180 → off=14 total=15 chunk=1 len=24.
|
||||
*/
|
||||
fun parseRic(data: ByteArray) {
|
||||
if (data.size < RIC_HEADER_BYTES + CRC_BYTES) {
|
||||
onLog?.invoke("ric: too short (${data.size}B, min ${RIC_HEADER_BYTES + CRC_BYTES})")
|
||||
return
|
||||
}
|
||||
val offset = readBe16(data, 4)
|
||||
val total = readBe16(data, 6)
|
||||
val chunk = readBe16(data, 8)
|
||||
val payloadStart = RIC_HEADER_BYTES
|
||||
val payloadEnd = data.size - CRC_BYTES
|
||||
val payloadBytes = payloadEnd - payloadStart
|
||||
val expectedBytes = chunk * SAMPLE_SIZE_BYTES
|
||||
if (payloadBytes < expectedBytes) {
|
||||
onLog?.invoke("ric: payload short ($payloadBytes < $expectedBytes for chunk=$chunk)")
|
||||
return
|
||||
}
|
||||
if (offset < 0 || total <= 0 || chunk <= 0 || offset + chunk > total) {
|
||||
onLog?.invoke("ric: invalid header off=$offset total=$total chunk=$chunk — drop")
|
||||
return
|
||||
}
|
||||
|
||||
// 첫 chunk (offset==0) 이거나 total 이 바뀌면 buffer 재할당.
|
||||
val buf = ricBuffer
|
||||
if (buf == null || ricTotal != total || offset == 0) {
|
||||
if (buf != null && ricReceived != 0 && offset == 0) {
|
||||
onLog?.invoke("ric: restart mid-set (was received=$ricReceived/$ricTotal)")
|
||||
}
|
||||
ricTotal = total
|
||||
ricReceived = 0
|
||||
ricBuffer = arrayOfNulls(total)
|
||||
}
|
||||
val target = ricBuffer!!
|
||||
|
||||
for (i in 0 until chunk) {
|
||||
val off = payloadStart + i * SAMPLE_SIZE_BYTES
|
||||
val axRaw = readBe16Signed(data, off)
|
||||
val ayRaw = readBe16Signed(data, off + 2)
|
||||
val azRaw = readBe16Signed(data, off + 4)
|
||||
val gxRaw = readBe16Signed(data, off + 6)
|
||||
val gyRaw = readBe16Signed(data, off + 8)
|
||||
val gzRaw = readBe16Signed(data, off + 10)
|
||||
target[offset + i] = ImuSample(
|
||||
ax = axRaw / ACCEL_LSB_PER_G,
|
||||
ay = ayRaw / ACCEL_LSB_PER_G,
|
||||
az = azRaw / ACCEL_LSB_PER_G,
|
||||
gx = gxRaw / GYRO_LSB_PER_DPS,
|
||||
gy = gyRaw / GYRO_LSB_PER_DPS,
|
||||
gz = gzRaw / GYRO_LSB_PER_DPS,
|
||||
)
|
||||
}
|
||||
ricReceived += chunk
|
||||
onLog?.invoke("ric: off=$offset chunk=$chunk (accum=$ricReceived/$total)")
|
||||
|
||||
if (ricReceived >= ricTotal) {
|
||||
val parsed = target.filterNotNull()
|
||||
if (parsed.size < ricTotal) {
|
||||
onLog?.invoke("ric: incomplete after last chunk (${parsed.size}/$ricTotal) — drop")
|
||||
ricTotal = 0; ricReceived = 0; ricBuffer = null
|
||||
return
|
||||
}
|
||||
samples = parsed
|
||||
onLog?.invoke("ric: $ricTotal samples reassembled (last Az=${"%.3f".format(parsed.last().az)}g)")
|
||||
ricTotal = 0; ricReceived = 0; ricBuffer = null
|
||||
onComplete?.invoke(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readBe16(data: ByteArray, idx: Int): Int {
|
||||
val hi = data[idx].toInt() and 0xFF
|
||||
val lo = data[idx + 1].toInt() and 0xFF
|
||||
|
||||
@@ -59,6 +59,7 @@ class PiezoPacketCollector {
|
||||
channelResults.clear(); detectedBigEndian = false
|
||||
currentSetSession = null
|
||||
isSetDropped = false
|
||||
recAssembly.clear()
|
||||
}
|
||||
|
||||
fun startMultiChannel(channelCount: Int = 6) {
|
||||
@@ -82,6 +83,7 @@ class PiezoPacketCollector {
|
||||
onLog?.invoke("rbb: header skipped (${data.size}B) — waiting for reb:")
|
||||
}
|
||||
"reb:" -> processRebPacket(data)
|
||||
"rec:" -> processRecPacket(data)
|
||||
"red:" -> processRedPacket(data)
|
||||
"ree:", "raa:" -> processEndPacket(prefix, data)
|
||||
}
|
||||
@@ -203,6 +205,156 @@ class PiezoPacketCollector {
|
||||
return buffer
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// rec: chunk 재조립 (MTU 분할 전송, 2026-07-21 FW+)
|
||||
//
|
||||
// 저 MTU 기기 (BLE ATT 185 등) 에서 reb: 210B 가 안 들어가서 firmware 가
|
||||
// 채널 하나를 여러 chunk 로 쪼개 보냄. 각 chunk 헤더에 offset/total/chunk
|
||||
// sample 이 있어 순서 무관하게 재조립 가능.
|
||||
//
|
||||
// 실측 (FW log, MTU 185):
|
||||
// ch=0 off=0 total=100 chunk=84 len=182 (12B header + 168B ADC + 2B CRC)
|
||||
// ch=0 off=84 total=100 chunk=16 len=46 (12B header + 32B ADC + 2B CRC)
|
||||
// ch=1 off=0 ...
|
||||
//
|
||||
// Packet 구조:
|
||||
// [tag 4B "rec:"] [ch_session 1B] [ch_num 1B]
|
||||
// [offset 2B BE] [total_samples 2B BE] [chunk_samples 2B BE]
|
||||
// [ADC (2 · chunk_samples) B] [CRC 2B]
|
||||
//
|
||||
// 채널간 인터리브 없음 — ch=N 의 모든 chunk 가 순서대로 온 뒤 ch=N+1 시작.
|
||||
// 마지막 채널의 마지막 chunk 뒤에 raa: 가 오는 것은 reb: 와 동일.
|
||||
//
|
||||
// Endian: BE (VBT 표준 · reb: 와 동일 auto-detect 로직 적용).
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
/** 진행 중인 chunk 재조립 상태 (ch_num → UShort 버퍼). rec: 도착 시마다 update. */
|
||||
private val recAssembly: HashMap<Int, ChannelReassembly> = HashMap()
|
||||
private data class ChannelReassembly(
|
||||
val chSession: Int,
|
||||
val totalSamples: Int,
|
||||
var received: Int,
|
||||
val buffer: Array<UShort>,
|
||||
)
|
||||
|
||||
private fun processRecPacket(bytes: ByteArray) {
|
||||
// Header 12B + CRC 2B = 14B 최소. chunk_samples=0 는 무의미하나 방어적으로 처리.
|
||||
if (bytes.size < 14) { markError("rec: too short (${bytes.size}, expected ≥14)"); return }
|
||||
val chSession = bytes[4].toInt() and 0xFF
|
||||
val chNum = bytes[5].toInt() and 0xFF
|
||||
val offset = readBe16(bytes, 6)
|
||||
val total = readBe16(bytes, 8)
|
||||
val chunk = readBe16(bytes, 10)
|
||||
|
||||
val adcStart = 12
|
||||
val adcEnd = bytes.size - 2 // trailing CRC 2B
|
||||
val expectedAdcBytes = chunk * 2
|
||||
val actualAdcBytes = adcEnd - adcStart
|
||||
if (actualAdcBytes < expectedAdcBytes) {
|
||||
markError("rec: adc short (got ${actualAdcBytes}B, expected ${expectedAdcBytes}B for chunk=$chunk)")
|
||||
return
|
||||
}
|
||||
if (offset < 0 || total <= 0 || chunk <= 0 || offset + chunk > total) {
|
||||
onLog?.invoke("rec: invalid header off=$offset total=$total chunk=$chunk — drop chunk")
|
||||
return
|
||||
}
|
||||
|
||||
if (!isMultiChannel) {
|
||||
onLog?.invoke("rec: single-channel mode not supported — ignore")
|
||||
return
|
||||
}
|
||||
|
||||
// Session tracking — reb: 와 동일 규칙.
|
||||
when {
|
||||
currentSetSession == null -> {
|
||||
currentSetSession = chSession
|
||||
isSetDropped = false
|
||||
channelResults = MutableList(channelResults.size) { null }
|
||||
recAssembly.clear()
|
||||
}
|
||||
chSession != currentSetSession -> {
|
||||
onLog?.invoke("rec: session change (was=$currentSetSession, got=$chSession) — drop previous incomplete set")
|
||||
currentSetSession = chSession
|
||||
isSetDropped = false
|
||||
channelResults = MutableList(channelResults.size) { null }
|
||||
recAssembly.clear()
|
||||
}
|
||||
}
|
||||
|
||||
if (chNum !in channelResults.indices) {
|
||||
onLog?.invoke("rec: invalid ch_num=$chNum (range 0..${channelResults.size - 1}) — set marked dropped")
|
||||
isSetDropped = true
|
||||
return
|
||||
}
|
||||
if (channelResults[chNum] != null) {
|
||||
onLog?.invoke("rec: duplicate ch_num=$chNum (already finished) — set marked dropped")
|
||||
isSetDropped = true
|
||||
return
|
||||
}
|
||||
|
||||
// Endian detection — 첫 chunk 첫 sample 로 auto-detect. 이후 chunk 는 lock.
|
||||
val isFirstChunkOfChannel = (offset == 0)
|
||||
val isFirstChannelOfSet = (chNum == 0 && isFirstChunkOfChannel)
|
||||
if (isFirstChannelOfSet) {
|
||||
val b0 = bytes.getOrElse(adcStart) { 0 }.toInt() and 0xFF
|
||||
val b1 = bytes.getOrElse(adcStart + 1) { 0 }.toInt() and 0xFF
|
||||
val firstSampleLE = b0 or (b1 shl 8)
|
||||
val firstSampleBE = (b0 shl 8) or b1
|
||||
detectedBigEndian = when {
|
||||
firstSampleLE > 4095 && firstSampleBE <= 4095 -> true
|
||||
firstSampleBE > 4095 && firstSampleLE <= 4095 -> false
|
||||
else -> forceBigEndian
|
||||
}
|
||||
onLog?.invoke("rec: Endian ${if (detectedBigEndian) "BE" else "LE"} (LE=$firstSampleLE BE=$firstSampleBE force=$forceBigEndian)")
|
||||
}
|
||||
|
||||
// 채널 buffer 확보 (첫 chunk 에 total 기준으로 할당).
|
||||
val asm = recAssembly.getOrPut(chNum) {
|
||||
ChannelReassembly(chSession, total, 0, Array(total) { 0u })
|
||||
}
|
||||
if (asm.totalSamples != total) {
|
||||
onLog?.invoke("rec: ch=$chNum total mismatch (was=${asm.totalSamples}, got=$total) — set marked dropped")
|
||||
isSetDropped = true
|
||||
recAssembly.remove(chNum)
|
||||
return
|
||||
}
|
||||
|
||||
// Chunk ADC → buffer[offset..offset+chunk)
|
||||
var src = adcStart
|
||||
for (i in 0 until chunk) {
|
||||
val bA = bytes[src].toInt() and 0xFF
|
||||
val bB = bytes[src + 1].toInt() and 0xFF
|
||||
asm.buffer[offset + i] = if (detectedBigEndian) {
|
||||
((bA shl 8) or bB).toUShort()
|
||||
} else {
|
||||
(bA or (bB shl 8)).toUShort()
|
||||
}
|
||||
src += 2
|
||||
}
|
||||
asm.received += chunk
|
||||
onLog?.invoke("rec: session=$chSession CH$chNum off=$offset chunk=$chunk (accum=${asm.received}/$total)")
|
||||
|
||||
// 채널 완료 판정 — received == total.
|
||||
if (asm.received >= asm.totalSamples) {
|
||||
val list = asm.buffer.toList()
|
||||
channelResults[chNum] = PiezoChannelData(chNum, list)
|
||||
totalPackets++
|
||||
recAssembly.remove(chNum)
|
||||
peakRaw = list.maxOrNull() ?: 0u
|
||||
peakIndex = list.indexOf(peakRaw).coerceAtLeast(0).toUShort()
|
||||
baselineRaw = if (list.size >= 10) {
|
||||
(list.takeLast(10).sumOf { it.toInt() } / 10).toUShort()
|
||||
} else 0u
|
||||
numSamples = list.size.toUShort()
|
||||
onLog?.invoke("rec: CH$chNum reassembled (${list.size} samples, peak=$peakRaw @ $peakIndex)")
|
||||
}
|
||||
}
|
||||
|
||||
private fun readBe16(bytes: ByteArray, idx: Int): Int {
|
||||
val hi = bytes[idx].toInt() and 0xFF
|
||||
val lo = bytes[idx + 1].toInt() and 0xFF
|
||||
return (hi shl 8) or lo
|
||||
}
|
||||
|
||||
// red: continuation packet (legacy — 신구조에서는 210B reb 한 번에 들어와 사실상 미사용).
|
||||
// multi-channel 모드에서는 ch_num 기반 indexing이라 red: 의미 모호 — single-channel 모드에서만 처리.
|
||||
private fun processRedPacket(bytes: ByteArray) {
|
||||
@@ -243,6 +395,7 @@ class PiezoPacketCollector {
|
||||
currentSetSession = null
|
||||
isSetDropped = false
|
||||
channelResults = MutableList(channelResults.size) { null }
|
||||
recAssembly.clear()
|
||||
} else if (isMultiChannel) {
|
||||
// ree: in multi-channel — 신구조에서는 ch_num이 명시되므로 채널 구분자 불필요. 무시.
|
||||
onLog?.invoke("ree: ignored in multi-channel mode")
|
||||
|
||||
Reference in New Issue
Block a user