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:
2026-07-21 18:01:28 +09:00
parent 10809af7b5
commit 739d52e472
5 changed files with 594 additions and 0 deletions
@@ -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")