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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user