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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user