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 c3e88ee..acb84f6 100644 --- a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt +++ b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt @@ -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") diff --git a/app/src/main/java/com/medithings/vesiscan/ble/ImuPacketCollector.kt b/app/src/main/java/com/medithings/vesiscan/ble/ImuPacketCollector.kt index 0ca2870..b3a6b1c 100644 --- a/app/src/main/java/com/medithings/vesiscan/ble/ImuPacketCollector.kt +++ b/app/src/main/java/com/medithings/vesiscan/ble/ImuPacketCollector.kt @@ -38,16 +38,26 @@ class ImuPacketCollector { var onComplete: ((List) -> 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? = 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 diff --git a/app/src/main/java/com/medithings/vesiscan/ble/PiezoPacketCollector.kt b/app/src/main/java/com/medithings/vesiscan/ble/PiezoPacketCollector.kt index 9feaaf7..d3b6fb1 100644 --- a/app/src/main/java/com/medithings/vesiscan/ble/PiezoPacketCollector.kt +++ b/app/src/main/java/com/medithings/vesiscan/ble/PiezoPacketCollector.kt @@ -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 = HashMap() + private data class ChannelReassembly( + val chSession: Int, + val totalSamples: Int, + var received: Int, + val buffer: Array, + ) + + 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") diff --git a/app/src/test/java/com/medithings/vesiscan/ble/ImuRicReassemblyTest.kt b/app/src/test/java/com/medithings/vesiscan/ble/ImuRicReassemblyTest.kt new file mode 100644 index 0000000..fbe0cc2 --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/ble/ImuRicReassemblyTest.kt @@ -0,0 +1,124 @@ +package com.medithings.vesiscan.ble + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +/** + * ric: (MTU 분할 IMU) 재조립 파서 검증. + * + * Firmware 실측 (MTU=185, 2026-07-21 log): + * ric off=0 total=15 chunk=14 len=180 + * ric off=14 total=15 chunk=1 len=24 + * + * IMU sample 은 12B (Ax Ay Az Gx Gy Gz, 각 2B BE int16, unsigned overflow signed). + * Test 는 15 sample 을 14+1 로 쪼개 주입하고 onComplete 가 정확한 순서/값을 복원하는지 확인. + */ +class ImuRicReassemblyTest { + + /** + * ric: 헤더 10B + IMU 12·chunk B + CRC 2B. + * [tag "ric:"] [off BE] [total BE] [chunk BE] [12B·chunk] [CRC 00 00] + */ + private fun makeRicChunk( + offset: Int, + total: Int, + chunkSamples: Int, + rawSamples: Array, // 각 IntArray = [ax, ay, az, gx, gy, gz] (int16 raw) + ): ByteArray { + require(rawSamples.size == chunkSamples) + val size = 10 + chunkSamples * 12 + 2 + val out = ByteArray(size) + out[0] = 'r'.code.toByte() + out[1] = 'i'.code.toByte() + out[2] = 'c'.code.toByte() + out[3] = ':'.code.toByte() + out[4] = ((offset ushr 8) and 0xFF).toByte() + out[5] = (offset and 0xFF).toByte() + out[6] = ((total ushr 8) and 0xFF).toByte() + out[7] = (total and 0xFF).toByte() + out[8] = ((chunkSamples ushr 8) and 0xFF).toByte() + out[9] = (chunkSamples and 0xFF).toByte() + var p = 10 + for (s in rawSamples) { + require(s.size == 6) + for (v in s) { + val vi = v and 0xFFFF + out[p++] = ((vi ushr 8) and 0xFF).toByte() + out[p++] = (vi and 0xFF).toByte() + } + } + out[p] = 0; out[p + 1] = 0 + return out + } + + /** 예측 가능한 raw 15 sample: Az = 8192 (=1g), 그 외는 sample index 로 구분. */ + private fun makeRawSamples(count: Int): Array = Array(count) { i -> + intArrayOf( + /* ax */ i * 10, + /* ay */ i * 10 + 1, + /* az */ 8192, // 정확히 +1g + /* gx */ i, + /* gy */ i * 2, + /* gz */ i * 3, + ) + } + + @Test + fun `ric 14+1 split reassembles 15 samples in order`() { + val collector = ImuPacketCollector() + var completed: List? = null + collector.onComplete = { completed = it } + collector.onLog = { /* println(it) */ } + + val all = makeRawSamples(15) + val first14 = all.copyOfRange(0, 14) + val last1 = all.copyOfRange(14, 15) + collector.parseRic(makeRicChunk(offset = 0, total = 15, chunkSamples = 14, rawSamples = first14)) + collector.parseRic(makeRicChunk(offset = 14, total = 15, chunkSamples = 1, rawSamples = last1)) + + val result = completed + assertNotNull("onComplete 가 호출되어야 함", result) + assertEquals(15, result!!.size) + + // Az = 8192 / 8192 = 1.0 g + for (i in 0 until 15) { + assertEquals("sample[$i].az", 1.0f, result[i].az, 1e-6f) + assertEquals("sample[$i].ax", (i * 10) / 8192f, result[i].ax, 1e-6f) + assertEquals("sample[$i].ay", (i * 10 + 1) / 8192f, result[i].ay, 1e-6f) + assertEquals("sample[$i].gx", i / 65.536f, result[i].gx, 1e-6f) + } + } + + @Test + fun `ric single chunk (chunk == total) also completes`() { + val collector = ImuPacketCollector() + var completed: List? = null + collector.onComplete = { completed = it } + + val all = makeRawSamples(15) + collector.parseRic(makeRicChunk(0, 15, 15, all)) + + assertNotNull(completed) + assertEquals(15, completed!!.size) + } + + @Test + fun `ric restart on new offset=0 discards partial`() { + val collector = ImuPacketCollector() + var completed: List? = null + collector.onComplete = { completed = it } + + // 부분 도착 + val partial = makeRawSamples(15).copyOfRange(0, 14) + collector.parseRic(makeRicChunk(0, 15, 14, partial)) + // 완성 전 새 set 시작 (off=0) → 부분 버림 + val newSet = Array(15) { i -> intArrayOf(999 + i, 0, 8192, 0, 0, 0) } + collector.parseRic(makeRicChunk(0, 15, 15, newSet)) + + assertNotNull(completed) + assertEquals(15, completed!!.size) + // 새 set 값이 반영 (ax=999 시작) + assertEquals(999f / 8192f, completed!![0].ax, 1e-6f) + } +} diff --git a/app/src/test/java/com/medithings/vesiscan/ble/PiezoRecReassemblyTest.kt b/app/src/test/java/com/medithings/vesiscan/ble/PiezoRecReassemblyTest.kt new file mode 100644 index 0000000..bbf4f16 --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/ble/PiezoRecReassemblyTest.kt @@ -0,0 +1,209 @@ +package com.medithings.vesiscan.ble + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * rec: (MTU 분할 ADC) 재조립 파서 검증. + * + * Firmware 실측 (MTU=185, 2026-07-21 log): + * ch=0 off=0 total=100 chunk=84 len=182 + * ch=0 off=84 total=100 chunk=16 len=46 + * → 채널별 순차, 각 채널 내 offset 오름차순, 6채널 후 raa:. + * + * Test 는 각 채널 100 sample 을 84+16 로 쪼개 6채널 → raa: 순으로 주입하고 + * onMultiChannelComplete callback 이 원본 sample 순서를 그대로 복원하는지 확인. + */ +class PiezoRecReassemblyTest { + + /** + * rec: 헤더 12B + ADC 2·chunk B + CRC 2B. + * [tag "rec:"] [ch_session] [ch_num] [off BE] [total BE] [chunk BE] [ADC BE...] [CRC 00 00] + */ + private fun makeRecChunk( + chSession: Int, + chNum: Int, + offset: Int, + total: Int, + chunkSamples: Int, + adcValues: IntArray, + ): ByteArray { + require(adcValues.size == chunkSamples) + val size = 12 + chunkSamples * 2 + 2 + val out = ByteArray(size) + out[0] = 'r'.code.toByte() + out[1] = 'e'.code.toByte() + out[2] = 'c'.code.toByte() + out[3] = ':'.code.toByte() + out[4] = chSession.toByte() + out[5] = chNum.toByte() + out[6] = ((offset ushr 8) and 0xFF).toByte() + out[7] = (offset and 0xFF).toByte() + out[8] = ((total ushr 8) and 0xFF).toByte() + out[9] = (total and 0xFF).toByte() + out[10] = ((chunkSamples ushr 8) and 0xFF).toByte() + out[11] = (chunkSamples and 0xFF).toByte() + var p = 12 + for (v in adcValues) { + out[p++] = ((v ushr 8) and 0xFF).toByte() // BE hi + out[p++] = (v and 0xFF).toByte() // BE lo + } + // CRC 는 검증하지 않으므로 0 채움 + out[p] = 0; out[p + 1] = 0 + return out + } + + private fun makeRaa(chSession: Int): ByteArray { + // raa: 8B (tag 4 + state 2 + crc 2). state=0x0000, crc=0x0000. + return byteArrayOf('r'.code.toByte(), 'a'.code.toByte(), 'a'.code.toByte(), ':'.code.toByte(), + chSession.toByte(), 0, 0, 0) + } + + /** 채널당 100 sample: ch=N 은 (N+1)·1000 + i 순차값 → 원본 그대로 복원되는지 확인. */ + private fun makeChannelSamples(ch: Int): IntArray = IntArray(100) { i -> (ch + 1) * 1000 + i } + + @Test + fun `rec 84+16 split reassembles 100 samples per channel × 6`() { + val collector = PiezoPacketCollector() + collector.forceBigEndian = true + var completed: List? = null + collector.startMultiChannel(6) + collector.onMultiChannelComplete = { completed = it } + collector.onLog = { /* println(it) */ } + + val session = 0x42 + for (ch in 0..5) { + val full = makeChannelSamples(ch) + val first = full.copyOfRange(0, 84) + val last = full.copyOfRange(84, 100) + collector.addPacket(makeRecChunk(session, ch, offset = 0, total = 100, chunkSamples = 84, adcValues = first)) + collector.addPacket(makeRecChunk(session, ch, offset = 84, total = 100, chunkSamples = 16, adcValues = last)) + } + collector.addPacket(makeRaa(session)) + + val result = completed + assertNotNull("onMultiChannelComplete 가 호출되어야 함", result) + assertEquals(6, result!!.size) + for (ch in 0..5) { + val ci = result.first { it.channel == ch } + val expected = makeChannelSamples(ch) + assertEquals(100, ci.buffer.size) + for (i in 0 until 100) { + assertEquals("ch=$ch idx=$i", expected[i].toUShort(), ci.buffer[i]) + } + } + } + + @Test + fun `rec single chunk (chunk == total) also completes channel`() { + // 어떤 firmware 는 chunk=total 인 케이스도 rec: 로 보낼 수 있음 (하위호환). + val collector = PiezoPacketCollector() + collector.forceBigEndian = true + var completed: List? = null + collector.startMultiChannel(6) + collector.onMultiChannelComplete = { completed = it } + + val session = 0x01 + for (ch in 0..5) { + val full = makeChannelSamples(ch) + collector.addPacket(makeRecChunk(session, ch, 0, 100, 100, full)) + } + collector.addPacket(makeRaa(session)) + + assertNotNull(completed) + assertEquals(6, completed!!.size) + for (ch in 0..5) { + val ci = completed!!.first { it.channel == ch } + assertEquals(100, ci.buffer.size) + } + } + + @Test + fun `rec session change mid-set drops previous partial`() { + val collector = PiezoPacketCollector() + collector.forceBigEndian = true + var completed: List? = null + collector.startMultiChannel(6) + collector.onMultiChannelComplete = { completed = it } + + // Session A: ch=0 부분만 (chunk 1) 도착 + collector.addPacket(makeRecChunk(chSession = 0xAA, chNum = 0, offset = 0, total = 100, + chunkSamples = 84, adcValues = makeChannelSamples(0).copyOfRange(0, 84))) + + // Session B: 6채널 완전한 새 set + val sessionB = 0xBB + for (ch in 0..5) { + val full = makeChannelSamples(ch) + collector.addPacket(makeRecChunk(sessionB, ch, 0, 100, 84, full.copyOfRange(0, 84))) + collector.addPacket(makeRecChunk(sessionB, ch, 84, 100, 16, full.copyOfRange(84, 100))) + } + collector.addPacket(makeRaa(sessionB)) + + assertNotNull(completed) + assertEquals(6, completed!!.size) + // Session A 데이터가 섞이지 않음을 확인 — ch=0 앞 84 sample 이 정확히 B 의 값 + val ch0 = completed!!.first { it.channel == 0 } + assertEquals((1 * 1000 + 0).toUShort(), ch0.buffer[0]) + assertEquals((1 * 1000 + 83).toUShort(), ch0.buffer[83]) + } + + @Test + fun `rec duplicate chunk after channel complete drops set`() { + val collector = PiezoPacketCollector() + collector.forceBigEndian = true + var completed: List? = null + collector.startMultiChannel(6) + collector.onMultiChannelComplete = { completed = it } + + val session = 0x77 + // ch=0 정상 완료 + val ch0 = makeChannelSamples(0) + collector.addPacket(makeRecChunk(session, 0, 0, 100, 84, ch0.copyOfRange(0, 84))) + collector.addPacket(makeRecChunk(session, 0, 84, 100, 16, ch0.copyOfRange(84, 100))) + // ch=0 재전송 (duplicate) → set 폐기 + collector.addPacket(makeRecChunk(session, 0, 0, 100, 100, ch0)) + assertTrue("duplicate 후 set 이 dropped 로 표시되어야 함", collector.isSetDropped) + + // 나머지 채널 완결해도 raa: 시 completion 안 됨 + for (ch in 1..5) { + val full = makeChannelSamples(ch) + collector.addPacket(makeRecChunk(session, ch, 0, 100, 100, full)) + } + collector.addPacket(makeRaa(session)) + assertNull("dropped set 은 callback 안 옴", completed) + } + + @Test + fun `mixed reb and rec in same set is not supported — reb only path unchanged`() { + // 정상 MTU 기기에서 reb: 만 오는 케이스는 회귀 없어야 함. + val collector = PiezoPacketCollector() + collector.forceBigEndian = true + var completed: List? = null + collector.startMultiChannel(6) + collector.onMultiChannelComplete = { completed = it } + + val session = 0x10 + for (ch in 0..5) { + val full = makeChannelSamples(ch) + val pkt = ByteArray(210) + pkt[0] = 'r'.code.toByte(); pkt[1] = 'e'.code.toByte(); pkt[2] = 'b'.code.toByte(); pkt[3] = ':'.code.toByte() + pkt[4] = session.toByte(); pkt[5] = ch.toByte() + pkt[6] = 0; pkt[7] = 1 // num_sample=1 (repeat), BE + var p = 8 + for (v in full) { + pkt[p++] = ((v ushr 8) and 0xFF).toByte() + pkt[p++] = (v and 0xFF).toByte() + } + pkt[208] = 0; pkt[209] = 0 // CRC + collector.addPacket(pkt) + } + collector.addPacket(makeRaa(session)) + + assertNotNull(completed) + assertEquals(6, completed!!.size) + } +}