BLE: reb 패킷 신구조(210B) 적용 — ch_info 2B + 명시적 채널 식별

펌웨어 업데이트로 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 17:58:38 +09:00
parent c0394f63b7
commit dadf80a778
2 changed files with 152 additions and 76 deletions
@@ -967,8 +967,11 @@ class BleManager private constructor(private val context: Context) {
debugLogger.rx("rer", data.size, "preliminary header") debugLogger.rx("rer", data.size, "preliminary header")
} }
"reb:" -> { "reb:" -> {
val samples = (data.size - 8) / 2 // 신구조 reb (210B): tag 4 + ch_info 2 + num_sample 2 + ADC + CRC 2 → ADC = size-10
debugLogger.rx("reb", data.size, "${samples}samples") 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) piezoCollector.addPacket(data)
} }
"red:" -> { "red:" -> {
@@ -1,15 +1,23 @@
package com.example.medilightv2android.ble package com.example.medilightv2android.ble
/** /**
* Collects multi-packet Piezo echo responses: reb: → red: → raa:/ree: * Collects multi-packet Piezo echo responses: reb: → (red:) → raa:
* 1:1 port of PiezoPacketCollector.swift
* *
* Supports both Big Endian (new devices) and Little Endian (old devices). * NEW packet layout (FW update — VBT post-2026-06):
* Auto-detects endianness from first channel's ADC data. * 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 { class PiezoPacketCollector {
// Header metadata // Header metadata (last received reb 기준 — 호환용)
var peakRaw: UShort = 0u; private set var peakRaw: UShort = 0u; private set
var peakIndex: UShort = 0u; private set var peakIndex: UShort = 0u; private set
var baselineRaw: UShort = 0u; private set var baselineRaw: UShort = 0u; private set
@@ -21,15 +29,16 @@ class PiezoPacketCollector {
var isComplete: Boolean = false; private set var isComplete: Boolean = false; private set
var hasError: Boolean = false; private set var hasError: Boolean = false; private set
var errorMessage: String = ""; private set var errorMessage: String = ""; private set
private var rawData = mutableListOf<Byte>() private var rawData = mutableListOf<Byte>() // 단일 채널 모드용
private var hasFirstPacket = false private var hasFirstPacket = false // 단일 채널 모드용
private var detectedBigEndian = false private var detectedBigEndian = false
var forceBigEndian: Boolean = true // VBT 기기 기본 BE, 기기 연결 시 설정 var forceBigEndian: Boolean = true // VBT 기기 기본 BE, 기기 연결 시 설정
// Multi-channel // Multi-channel + session tracking
var isMultiChannel: Boolean = false; private set var isMultiChannel: Boolean = false; private set
var currentChannel: Int = 0; private set
var channelResults: MutableList<PiezoChannelData?> = mutableListOf(); private set var channelResults: MutableList<PiezoChannelData?> = mutableListOf(); private set
private var currentSetSession: Int? = null
var isSetDropped: Boolean = false; private set
// Callbacks // Callbacks
var onComplete: ((PiezoEchoResult) -> Unit)? = null var onComplete: ((PiezoEchoResult) -> Unit)? = null
@@ -43,14 +52,15 @@ class PiezoPacketCollector {
totalPackets = 0; currentPacket = 0 totalPackets = 0; currentPacket = 0
isComplete = false; hasError = false; errorMessage = "" isComplete = false; hasError = false; errorMessage = ""
rawData.clear(); hasFirstPacket = false rawData.clear(); hasFirstPacket = false
isMultiChannel = false; currentChannel = 0 isMultiChannel = false
channelResults.clear(); detectedBigEndian = false channelResults.clear(); detectedBigEndian = false
currentSetSession = null
isSetDropped = false
} }
fun startMultiChannel(channelCount: Int = 6) { fun startMultiChannel(channelCount: Int = 6) {
reset() reset()
isMultiChannel = true isMultiChannel = true
currentChannel = 0
channelResults = MutableList(channelCount) { null } channelResults = MutableList(channelCount) { null }
} }
@@ -75,65 +85,128 @@ class PiezoPacketCollector {
recursionDepth-- recursionDepth--
} }
// reb: Data packet // reb: Data packet (NEW format, FW post-2026-06)
// Structure: reb:(4) + repeat_count(2, BE or LE) + 100 ADC(200) + CRC(2) // 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) { 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()) { val chSession = bytes[4].toInt() and 0xFF
saveCurrentChannel() 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 // Endian detection — first ADC sample at bytes[8..9]. Lock for the remaining channels.
hasFirstPacket = true // VBT = Big Endian, 2025MEDIP = Little Endian. Fallback: forceBigEndian flag.
rawData.clear() val isFirstChannelOfSet = if (isMultiChannel) (chNum == 0) else (!hasFirstPacket)
if (isFirstChannelOfSet) {
// Endian detection: first channel → lock for remaining val b8 = bytes.getOrElse(8) { 0 }.toInt() and 0xFF
// VBT 기기 = Big Endian, 2025MEDIP = Little Endian val b9 = bytes.getOrElse(9) { 0 }.toInt() and 0xFF
// 자동 감지 fallback: 첫 샘플 기반 val firstSampleLE = b8 or (b9 shl 8)
if (currentChannel == 0) { val firstSampleBE = (b8 shl 8) or b9
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
detectedBigEndian = when { detectedBigEndian = when {
firstSampleLE > 4095 && firstSampleBE <= 4095 -> true // 확실히 BE firstSampleLE > 4095 && firstSampleBE <= 4095 -> true
firstSampleBE > 4095 && firstSampleLE <= 4095 -> false // 확실히 LE firstSampleBE > 4095 && firstSampleLE <= 4095 -> false
else -> forceBigEndian // 둘 다 범위 내면 기기 기반 기본값 사용 else -> forceBigEndian
} }
onLog?.invoke("Endian: ${if (detectedBigEndian) "BE" else "LE"} (LE=$firstSampleLE BE=$firstSampleBE force=$forceBigEndian)") onLog?.invoke("Endian: ${if (detectedBigEndian) "BE" else "LE"} (LE=$firstSampleLE BE=$firstSampleBE force=$forceBigEndian)")
} }
// Parse repeat count // num_sample at bytes[6..7]
val b4 = bytes[4].toInt() and 0xFF val b6 = bytes[6].toInt() and 0xFF
val b5 = bytes[5].toInt() and 0xFF val b7 = bytes[7].toInt() and 0xFF
val repeatCount = if (detectedBigEndian) (b4 shl 8) or b5 else b4 or (b5 shl 8) 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) // ADC data at bytes[8..size-3] (exclude trailing CRC 2B)
val adcStart = 6 val adcStart = 8
val adcEnd = bytes.size - 2 val adcEnd = bytes.size - 2
if (adcEnd <= adcStart) { markError("reb: no ADC data"); return } if (adcEnd <= adcStart) { markError("reb: no ADC data"); return }
for (i in adcStart until adcEnd) rawData.add(bytes[i])
val sampleCount = (adcEnd - adcStart) / 2 val sampleCount = (adcEnd - adcStart) / 2
// Compute stats from buffer if (isMultiChannel) {
val buffer = parseADCBuffer() // Parse this channel's ADC directly into a buffer and store at ch_num position
peakRaw = buffer.maxOrNull() ?: 0u val buffer = parseADCBufferRange(bytes, adcStart, adcEnd)
peakIndex = buffer.indexOf(peakRaw).coerceAtLeast(0).toUShort() channelResults[chNum] = PiezoChannelData(chNum, buffer)
baselineRaw = if (buffer.size >= 10) { totalPackets++
(buffer.takeLast(10).sumOf { it.toInt() } / 10).toUShort()
} else 0u
numSamples = sampleCount.toUShort()
totalPackets = 1
val ch = if (isMultiChannel) " CH$currentChannel" else "" peakRaw = buffer.maxOrNull() ?: 0u
val endianStr = if (detectedBigEndian) "BE" else "LE" peakIndex = buffer.indexOf(peakRaw).coerceAtLeast(0).toUShort()
onLog?.invoke("reb:$ch [$endianStr] repeat=$repeatCount, $sampleCount samples, peak=$peakRaw @ idx $peakIndex, base=$baselineRaw") 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<UShort> {
val buffer = mutableListOf<UShort>()
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) { 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 (!hasFirstPacket) { markError("red: before reb:"); return }
if (bytes.size < 6) return if (bytes.size < 6) return
for (i in 6 until bytes.size) rawData.add(bytes[i]) for (i in 6 until bytes.size) rawData.add(bytes[i])
@@ -142,19 +215,31 @@ class PiezoPacketCollector {
private fun processEndPacket(prefix: String, bytes: ByteArray) { private fun processEndPacket(prefix: String, bytes: ByteArray) {
if (isMultiChannel && prefix == "raa:") { if (isMultiChannel && prefix == "raa:") {
// Final completion marker — save last channel and finish // Set 종료 마커 — 완전성 검사 후 콜백 결정
if (hasFirstPacket && rawData.isNotEmpty()) saveCurrentChannel()
isComplete = true isComplete = true
val validChannels = channelResults.filterNotNull() when {
onLog?.invoke("raa: multi-channel complete — ${validChannels.size} channels") isSetDropped -> {
onMultiChannelComplete?.invoke(validChannels) onLog?.invoke("raa: set dropped (session=$currentSetSession) — no callback")
} else if (isMultiChannel) { }
// ree: in multi-channel = channel separator, save and continue channelResults.any { it == null } -> {
if (hasFirstPacket && rawData.isNotEmpty()) { val missing = channelResults.mapIndexedNotNull { i, c -> if (c == null) i else null }
saveCurrentChannel() 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 { } else {
// Single-channel complete (ree:) // Single-channel mode 완료 (ree: 또는 raa:)
finishSingleChannel() finishSingleChannel()
} }
} }
@@ -172,18 +257,6 @@ class PiezoPacketCollector {
onComplete?.invoke(result) 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<UShort> { private fun parseADCBuffer(): List<UShort> {
val buffer = mutableListOf<UShort>() val buffer = mutableListOf<UShort>()
var i = 0 var i = 0