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) piezoCollector.addPacket(data)
lastBleRxAt.value = System.currentTimeMillis() // 기기 alive 마크 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:" -> { "red:" -> {
debugLogger.rx("red", data.size, "continuation") debugLogger.rx("red", data.size, "continuation")
piezoCollector.addPacket(data) piezoCollector.addPacket(data)
@@ -1328,6 +1340,14 @@ class BleManager private constructor(private val context: Context) {
debugLogger.rx("rim", data.size, "IMU ${num} samples") debugLogger.rx("rim", data.size, "IMU ${num} samples")
imuCollector.parseRim(data) 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 -> { else -> {
val hex = data.take(8).joinToString(" ") { "%02X".format(it) } val hex = data.take(8).joinToString(" ") { "%02X".format(it) }
debugLogger.rx("???", data.size, "unknown prefix='$prefix' hex=$hex") debugLogger.rx("???", data.size, "unknown prefix='$prefix' hex=$hex")
@@ -38,16 +38,26 @@ class ImuPacketCollector {
var onComplete: ((List<ImuSample>) -> Unit)? = null var onComplete: ((List<ImuSample>) -> Unit)? = null
var onLog: ((String) -> 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 { companion object {
private const val ACCEL_LSB_PER_G = 8192f // ±4g FSR 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 GYRO_LSB_PER_DPS = 65.536f // ±500 dps FSR
private const val SAMPLE_SIZE_BYTES = 12 private const val SAMPLE_SIZE_BYTES = 12
private const val HEADER_BYTES = 6 // tag(4) + num(2) private const val HEADER_BYTES = 6 // tag(4) + num(2)
private const val CRC_BYTES = 2 private const val CRC_BYTES = 2
private const val RIC_HEADER_BYTES = 10 // tag(4) + off(2) + total(2) + chunk(2)
} }
fun reset() { fun reset() {
samples = emptyList() samples = emptyList()
ricTotal = 0; ricReceived = 0; ricBuffer = null
} }
/** /**
@@ -172,6 +182,84 @@ class ImuPacketCollector {
onComplete?.invoke(parsed) 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 { private fun readBe16(data: ByteArray, idx: Int): Int {
val hi = data[idx].toInt() and 0xFF val hi = data[idx].toInt() and 0xFF
val lo = data[idx + 1].toInt() and 0xFF val lo = data[idx + 1].toInt() and 0xFF
@@ -59,6 +59,7 @@ class PiezoPacketCollector {
channelResults.clear(); detectedBigEndian = false channelResults.clear(); detectedBigEndian = false
currentSetSession = null currentSetSession = null
isSetDropped = false isSetDropped = false
recAssembly.clear()
} }
fun startMultiChannel(channelCount: Int = 6) { fun startMultiChannel(channelCount: Int = 6) {
@@ -82,6 +83,7 @@ class PiezoPacketCollector {
onLog?.invoke("rbb: header skipped (${data.size}B) — waiting for reb:") onLog?.invoke("rbb: header skipped (${data.size}B) — waiting for reb:")
} }
"reb:" -> processRebPacket(data) "reb:" -> processRebPacket(data)
"rec:" -> processRecPacket(data)
"red:" -> processRedPacket(data) "red:" -> processRedPacket(data)
"ree:", "raa:" -> processEndPacket(prefix, data) "ree:", "raa:" -> processEndPacket(prefix, data)
} }
@@ -203,6 +205,156 @@ class PiezoPacketCollector {
return buffer 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 한 번에 들어와 사실상 미사용). // red: continuation packet (legacy — 신구조에서는 210B reb 한 번에 들어와 사실상 미사용).
// multi-channel 모드에서는 ch_num 기반 indexing이라 red: 의미 모호 — single-channel 모드에서만 처리. // multi-channel 모드에서는 ch_num 기반 indexing이라 red: 의미 모호 — single-channel 모드에서만 처리.
private fun processRedPacket(bytes: ByteArray) { private fun processRedPacket(bytes: ByteArray) {
@@ -243,6 +395,7 @@ class PiezoPacketCollector {
currentSetSession = null currentSetSession = null
isSetDropped = false isSetDropped = false
channelResults = MutableList(channelResults.size) { null } channelResults = MutableList(channelResults.size) { null }
recAssembly.clear()
} else if (isMultiChannel) { } else if (isMultiChannel) {
// ree: in multi-channel — 신구조에서는 ch_num이 명시되므로 채널 구분자 불필요. 무시. // ree: in multi-channel — 신구조에서는 ch_num이 명시되므로 채널 구분자 불필요. 무시.
onLog?.invoke("ree: ignored in multi-channel mode") onLog?.invoke("ree: ignored in multi-channel mode")
@@ -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>, // 각 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<IntArray> = 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<ImuSample>? = 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<ImuSample>? = 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<ImuSample>? = 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)
}
}
@@ -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<PiezoChannelData>? = 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<PiezoChannelData>? = 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<PiezoChannelData>? = 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<PiezoChannelData>? = 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<PiezoChannelData>? = 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)
}
}