From dadf80a778ef0bb47f27745b5c2f5b5424525142 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Mon, 1 Jun 2026 17:58:38 +0900 Subject: [PATCH] =?UTF-8?q?BLE:=20reb=20=ED=8C=A8=ED=82=B7=20=EC=8B=A0?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0(210B)=20=EC=A0=81=EC=9A=A9=20=E2=80=94=20ch?= =?UTF-8?q?=5Finfo=202B=20+=20=EB=AA=85=EC=8B=9C=EC=A0=81=20=EC=B1=84?= =?UTF-8?q?=EB=84=90=20=EC=8B=9D=EB=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 펌웨어 업데이트로 reb 응답 패킷이 208B → 210B로 확장됨. 새 payload 앞에 ch_info 2B (= ch_session 1B + ch_num 1B) 필드 추가. 변경 내용: reb (old): [tag 4B][num_sample 2B][adc 200B][crc 2B] = 208B reb (new): [tag 4B][ch_session 1B][ch_num 1B][num_sample 2B][adc 200B][crc 2B] = 210B 수정 위치: - BleManager.kt:970 — 디버그 로그용 sample count 신구조 기반 계산, session/ch 정보를 ble.log에 함께 노출 - PiezoPacketCollector.processRebPacket — 전면 재작성 * 모든 offset +2 (num_sample → bytes[6..7], ADC → bytes[8..]) * Endian 감지 첫 ADC 샘플 위치 bytes[8..9]로 이동 * 채널 저장 방식: 도착 순서(currentChannel++) → ch_num 직접 indexing * ch_session 추적 추가 — 같은 session의 6개 reb를 하나의 set으로 묶음 - processEndPacket(raa:) — 완전성 검사 강화 * isSetDropped == true → 콜백 안 부르고 폐기 * channelResults 중 null 존재 (불완전 set) → 폐기 * 6채널 모두 채워진 경우만 onMultiChannelComplete 호출 * 폐기/완료 후 currentSetSession=null로 reset → 다음 set 진입 대비 - red:, ree: — multi-channel 모드에서는 ch_num indexing이라 무시 (single-channel만 유지) - currentChannel 필드 제거 (도착 순서 의존 폐기) 호환성: - 신버전 펌웨어 전용. 구버전(208B) 단말 미지원. - rbb/raa/rim 구조 변동 없음 — 손대지 않음. - 온도 센서(TMP235 → IMU register) 변경은 FW 내부일 뿐 클라이언트 포맷 그대로. 무효 패킷 정책: - session 불일치 (set 진행 중 다른 session 도착): 이전 set 폐기, 새 set 시작 - 같은 set 내 duplicate ch_num: isSetDropped=true, raa에서 무콜백 처리 - invalid ch_num (out of 0..5): set 폐기 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../medilightv2android/ble/BleManager.kt | 7 +- .../ble/PiezoPacketCollector.kt | 221 ++++++++++++------ 2 files changed, 152 insertions(+), 76 deletions(-) diff --git a/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt b/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt index 6e1321b..a006fac 100644 --- a/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt +++ b/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt @@ -967,8 +967,11 @@ class BleManager private constructor(private val context: Context) { debugLogger.rx("rer", data.size, "preliminary header") } "reb:" -> { - val samples = (data.size - 8) / 2 - debugLogger.rx("reb", data.size, "${samples}samples") + // 신구조 reb (210B): tag 4 + ch_info 2 + num_sample 2 + ADC + CRC 2 → ADC = size-10 + val samples = ((data.size - 10) / 2).coerceAtLeast(0) + 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 + debugLogger.rx("reb", data.size, "session=$chSession ch=$chNum ${samples}samples") piezoCollector.addPacket(data) } "red:" -> { diff --git a/app/src/main/java/com/example/medilightv2android/ble/PiezoPacketCollector.kt b/app/src/main/java/com/example/medilightv2android/ble/PiezoPacketCollector.kt index 1678206..d713b32 100644 --- a/app/src/main/java/com/example/medilightv2android/ble/PiezoPacketCollector.kt +++ b/app/src/main/java/com/example/medilightv2android/ble/PiezoPacketCollector.kt @@ -1,15 +1,23 @@ package com.example.medilightv2android.ble /** - * Collects multi-packet Piezo echo responses: reb: → red: → raa:/ree: - * 1:1 port of PiezoPacketCollector.swift + * Collects multi-packet Piezo echo responses: reb: → (red:) → raa: * - * Supports both Big Endian (new devices) and Little Endian (old devices). - * Auto-detects endianness from first channel's ADC data. + * NEW packet layout (FW update — VBT post-2026-06): + * reb: [tag 4B][ch_info 2B = ch_session 1B + ch_num 1B][num_sample 2B][adc 200B][crc 2B] = 210B + * raa: [tag 4B][state 2B][crc 2B] = 8B (set 종료 마커, 변동 없음) + * + * Channel order is determined by the ch_num field (not arrival order). All 6 reb packets + * within the same ch_session form one measurement set. If ch_session changes mid-set or + * a duplicate ch_num arrives, the entire set is dropped and only the next valid set + * triggers onMultiChannelComplete. + * + * Big Endian / Little Endian: VBT FW = BE. Endian is detected from the first ADC sample of + * ch_num=0 and locked for the remaining channels in that set. */ class PiezoPacketCollector { - // Header metadata + // Header metadata (last received reb 기준 — 호환용) var peakRaw: UShort = 0u; private set var peakIndex: UShort = 0u; private set var baselineRaw: UShort = 0u; private set @@ -21,15 +29,16 @@ class PiezoPacketCollector { var isComplete: Boolean = false; private set var hasError: Boolean = false; private set var errorMessage: String = ""; private set - private var rawData = mutableListOf() - private var hasFirstPacket = false + private var rawData = mutableListOf() // 단일 채널 모드용 + private var hasFirstPacket = false // 단일 채널 모드용 private var detectedBigEndian = false var forceBigEndian: Boolean = true // VBT 기기 기본 BE, 기기 연결 시 설정 - // Multi-channel + // Multi-channel + session tracking var isMultiChannel: Boolean = false; private set - var currentChannel: Int = 0; private set var channelResults: MutableList = mutableListOf(); private set + private var currentSetSession: Int? = null + var isSetDropped: Boolean = false; private set // Callbacks var onComplete: ((PiezoEchoResult) -> Unit)? = null @@ -43,14 +52,15 @@ class PiezoPacketCollector { totalPackets = 0; currentPacket = 0 isComplete = false; hasError = false; errorMessage = "" rawData.clear(); hasFirstPacket = false - isMultiChannel = false; currentChannel = 0 + isMultiChannel = false channelResults.clear(); detectedBigEndian = false + currentSetSession = null + isSetDropped = false } fun startMultiChannel(channelCount: Int = 6) { reset() isMultiChannel = true - currentChannel = 0 channelResults = MutableList(channelCount) { null } } @@ -75,65 +85,128 @@ class PiezoPacketCollector { recursionDepth-- } - // reb: Data packet - // Structure: reb:(4) + repeat_count(2, BE or LE) + 100 ADC(200) + CRC(2) + // reb: Data packet (NEW format, FW post-2026-06) + // Structure: reb:(4) + ch_session(1) + ch_num(1) + num_sample(2, BE/LE) + 100 ADC(200) + CRC(2) = 210B private fun processRebPacket(bytes: ByteArray) { - if (bytes.size < 8) { markError("reb: too short (${bytes.size})"); return } + if (bytes.size < 10) { markError("reb: too short (${bytes.size}, expected ≥10)"); return } - if (isMultiChannel && hasFirstPacket && rawData.isNotEmpty()) { - saveCurrentChannel() + val chSession = bytes[4].toInt() and 0xFF + val chNum = bytes[5].toInt() and 0xFF + + if (isMultiChannel) { + // Session tracking — drop previous incomplete set when session changes + when { + currentSetSession == null -> { + currentSetSession = chSession + isSetDropped = false + channelResults = MutableList(channelResults.size) { null } + } + chSession != currentSetSession -> { + onLog?.invoke("reb: session change (was=$currentSetSession, got=$chSession) — drop previous incomplete set") + currentSetSession = chSession + isSetDropped = false + channelResults = MutableList(channelResults.size) { null } + } + } + + if (chNum !in channelResults.indices) { + onLog?.invoke("reb: invalid ch_num=$chNum (range 0..${channelResults.size - 1}) — set marked dropped") + isSetDropped = true + return + } + if (channelResults[chNum] != null) { + onLog?.invoke("reb: duplicate ch_num=$chNum in session=$chSession — set marked dropped") + isSetDropped = true + return + } } - currentPacket = 0 - hasFirstPacket = true - rawData.clear() - - // Endian detection: first channel → lock for remaining - // VBT 기기 = Big Endian, 2025MEDIP = Little Endian - // 자동 감지 fallback: 첫 샘플 기반 - if (currentChannel == 0) { - val b6 = bytes.getOrElse(6) { 0 }.toInt() and 0xFF - val b7 = bytes.getOrElse(7) { 0 }.toInt() and 0xFF - val firstSampleLE = b6 or (b7 shl 8) - val firstSampleBE = (b6 shl 8) or b7 - + // Endian detection — first ADC sample at bytes[8..9]. Lock for the remaining channels. + // VBT = Big Endian, 2025MEDIP = Little Endian. Fallback: forceBigEndian flag. + val isFirstChannelOfSet = if (isMultiChannel) (chNum == 0) else (!hasFirstPacket) + if (isFirstChannelOfSet) { + val b8 = bytes.getOrElse(8) { 0 }.toInt() and 0xFF + val b9 = bytes.getOrElse(9) { 0 }.toInt() and 0xFF + val firstSampleLE = b8 or (b9 shl 8) + val firstSampleBE = (b8 shl 8) or b9 detectedBigEndian = when { - firstSampleLE > 4095 && firstSampleBE <= 4095 -> true // 확실히 BE - firstSampleBE > 4095 && firstSampleLE <= 4095 -> false // 확실히 LE - else -> forceBigEndian // 둘 다 범위 내면 기기 기반 기본값 사용 + firstSampleLE > 4095 && firstSampleBE <= 4095 -> true + firstSampleBE > 4095 && firstSampleLE <= 4095 -> false + else -> forceBigEndian } onLog?.invoke("Endian: ${if (detectedBigEndian) "BE" else "LE"} (LE=$firstSampleLE BE=$firstSampleBE force=$forceBigEndian)") } - // Parse repeat count - val b4 = bytes[4].toInt() and 0xFF - val b5 = bytes[5].toInt() and 0xFF - val repeatCount = if (detectedBigEndian) (b4 shl 8) or b5 else b4 or (b5 shl 8) + // num_sample at bytes[6..7] + val b6 = bytes[6].toInt() and 0xFF + val b7 = bytes[7].toInt() and 0xFF + val repeatCount = if (detectedBigEndian) (b6 shl 8) or b7 else b6 or (b7 shl 8) - // ADC data: bytes[6] to bytes[size-3] (skip prefix+count, exclude CRC) - val adcStart = 6 + // ADC data at bytes[8..size-3] (exclude trailing CRC 2B) + val adcStart = 8 val adcEnd = bytes.size - 2 if (adcEnd <= adcStart) { markError("reb: no ADC data"); return } - - for (i in adcStart until adcEnd) rawData.add(bytes[i]) val sampleCount = (adcEnd - adcStart) / 2 - // Compute stats from buffer - val buffer = parseADCBuffer() - peakRaw = buffer.maxOrNull() ?: 0u - peakIndex = buffer.indexOf(peakRaw).coerceAtLeast(0).toUShort() - baselineRaw = if (buffer.size >= 10) { - (buffer.takeLast(10).sumOf { it.toInt() } / 10).toUShort() - } else 0u - numSamples = sampleCount.toUShort() - totalPackets = 1 + if (isMultiChannel) { + // Parse this channel's ADC directly into a buffer and store at ch_num position + val buffer = parseADCBufferRange(bytes, adcStart, adcEnd) + channelResults[chNum] = PiezoChannelData(chNum, buffer) + totalPackets++ - val ch = if (isMultiChannel) " CH$currentChannel" else "" - val endianStr = if (detectedBigEndian) "BE" else "LE" - onLog?.invoke("reb:$ch [$endianStr] repeat=$repeatCount, $sampleCount samples, peak=$peakRaw @ idx $peakIndex, base=$baselineRaw") + peakRaw = buffer.maxOrNull() ?: 0u + peakIndex = buffer.indexOf(peakRaw).coerceAtLeast(0).toUShort() + baselineRaw = if (buffer.size >= 10) { + (buffer.takeLast(10).sumOf { it.toInt() } / 10).toUShort() + } else 0u + numSamples = sampleCount.toUShort() + + val endianStr = if (detectedBigEndian) "BE" else "LE" + onLog?.invoke("reb: session=$chSession CH$chNum [$endianStr] repeat=$repeatCount $sampleCount samples, peak=$peakRaw @ $peakIndex") + } else { + // Single-channel mode — buffer to rawData, finalize on ree:/raa: + currentPacket = 0 + hasFirstPacket = true + rawData.clear() + for (i in adcStart until adcEnd) rawData.add(bytes[i]) + val buffer = parseADCBuffer() + peakRaw = buffer.maxOrNull() ?: 0u + peakIndex = buffer.indexOf(peakRaw).coerceAtLeast(0).toUShort() + baselineRaw = if (buffer.size >= 10) { + (buffer.takeLast(10).sumOf { it.toInt() } / 10).toUShort() + } else 0u + numSamples = sampleCount.toUShort() + totalPackets = 1 + + val endianStr = if (detectedBigEndian) "BE" else "LE" + onLog?.invoke("reb: single [$endianStr] repeat=$repeatCount $sampleCount samples, peak=$peakRaw @ $peakIndex") + } } + private fun parseADCBufferRange(bytes: ByteArray, start: Int, end: Int): List { + val buffer = mutableListOf() + var i = start + while (i + 1 < end) { + val b0 = bytes[i].toInt() and 0xFF + val b1 = bytes[i + 1].toInt() and 0xFF + val value: UShort = if (detectedBigEndian) { + ((b0 shl 8) or b1).toUShort() + } else { + (b0 or (b1 shl 8)).toUShort() + } + buffer.add(value) + i += 2 + } + return buffer + } + + // red: continuation packet (legacy — 신구조에서는 210B reb 한 번에 들어와 사실상 미사용). + // multi-channel 모드에서는 ch_num 기반 indexing이라 red: 의미 모호 — single-channel 모드에서만 처리. private fun processRedPacket(bytes: ByteArray) { + if (isMultiChannel) { + onLog?.invoke("red: ignored in multi-channel mode (ch_num-indexed set)") + return + } if (!hasFirstPacket) { markError("red: before reb:"); return } if (bytes.size < 6) return for (i in 6 until bytes.size) rawData.add(bytes[i]) @@ -142,19 +215,31 @@ class PiezoPacketCollector { private fun processEndPacket(prefix: String, bytes: ByteArray) { if (isMultiChannel && prefix == "raa:") { - // Final completion marker — save last channel and finish - if (hasFirstPacket && rawData.isNotEmpty()) saveCurrentChannel() + // Set 종료 마커 — 완전성 검사 후 콜백 결정 isComplete = true - val validChannels = channelResults.filterNotNull() - onLog?.invoke("raa: multi-channel complete — ${validChannels.size} channels") - onMultiChannelComplete?.invoke(validChannels) - } else if (isMultiChannel) { - // ree: in multi-channel = channel separator, save and continue - if (hasFirstPacket && rawData.isNotEmpty()) { - saveCurrentChannel() + when { + isSetDropped -> { + onLog?.invoke("raa: set dropped (session=$currentSetSession) — no callback") + } + channelResults.any { it == null } -> { + val missing = channelResults.mapIndexedNotNull { i, c -> if (c == null) i else null } + onLog?.invoke("raa: incomplete set (session=$currentSetSession, missing CH=$missing) — drop") + } + else -> { + val validChannels = channelResults.filterNotNull() + onLog?.invoke("raa: session=$currentSetSession complete — ${validChannels.size} channels") + onMultiChannelComplete?.invoke(validChannels) + } } + // 다음 set을 위해 reset (isMultiChannel/onComplete 등 유지) + currentSetSession = null + isSetDropped = false + channelResults = MutableList(channelResults.size) { null } + } else if (isMultiChannel) { + // ree: in multi-channel — 신구조에서는 ch_num이 명시되므로 채널 구분자 불필요. 무시. + onLog?.invoke("ree: ignored in multi-channel mode") } else { - // Single-channel complete (ree:) + // Single-channel mode 완료 (ree: 또는 raa:) finishSingleChannel() } } @@ -172,18 +257,6 @@ class PiezoPacketCollector { onComplete?.invoke(result) } - private fun saveCurrentChannel() { - if (currentChannel >= channelResults.size) return - val buffer = parseADCBuffer() - channelResults[currentChannel] = PiezoChannelData(currentChannel, buffer) - onLog?.invoke("CH$currentChannel saved: ${buffer.size} samples, peak=${buffer.maxOrNull() ?: 0u}") - - rawData.clear() - peakRaw = 0u; peakIndex = 0u; baselineRaw = 0u; numSamples = 0u - totalPackets = 0; currentPacket = 0; hasFirstPacket = false - currentChannel++ - } - private fun parseADCBuffer(): List { val buffer = mutableListOf() var i = 0