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")
}
"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:" -> {
@@ -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<Byte>()
private var hasFirstPacket = false
private var rawData = mutableListOf<Byte>() // 단일 채널 모드용
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<PiezoChannelData?> = 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<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) {
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<UShort> {
val buffer = mutableListOf<UShort>()
var i = 0