feat: add Piezo BLE data pipeline (1:1 port from iOS)

PiezoPacketCollector.kt:
- Multi-packet reb:/red:/raa: assembly
- Auto-detect Big/Little Endian
- First channel locks endianness for all channels
- 6-channel support (maa command)

BleManager.kt:
- sendAllChannels(mode) → maa command
- reb:/rer:/raa: response routing to piezoCollector
- piezoCollector instance added

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dwjang
2026-04-21 16:06:42 +09:00
parent dc61e1d52e
commit b83cb16f35
3 changed files with 231 additions and 1 deletions
@@ -274,7 +274,9 @@ class BleManager private constructor(private val context: Context) {
}
// Piezo Commands
fun sendPiezoPowerOn(freqOption: Int, cycles: Int) {
val piezoCollector = PiezoPacketCollector()
fun sendPiezoPowerOn(freqOption: Int = 2, cycles: Int = 5) {
sendRaw(CRC16.buildCommand("mpa", intArrayOf(freqOption, cycles)))
}
@@ -286,6 +288,12 @@ class BleManager private constructor(private val context: Context) {
sendRaw(CRC16.buildCommand("mec", intArrayOf(freqOption, delayUs, 140, cycles, 1, piezoCh)))
}
/** 6-channel measurement (maa) */
fun sendAllChannels(mode: Int = 0) {
piezoCollector.startMultiChannel(6)
sendRaw(CRC16.buildCommand("maa", intArrayOf(mode)))
}
// NIRS Commands
fun sendNirsPowerOn() {
sendRaw(CRC16.buildCommand("mta", intArrayOf(1)))
@@ -505,6 +513,14 @@ class BleManager private constructor(private val context: Context) {
"rag:", "rag!" -> onNirsMagReceived?.invoke(data)
"rcj:", "rcj!" -> onNirsMcjReceived?.invoke(data)
"rpa:" -> onPiezoDataReceived?.invoke(data)
// Piezo echo responses → route to packet collector
"rer:" -> {
// VesiScan-Basic preliminary header — just acknowledge, reb: follows
logd { "rer: preliminary header (${data.size} bytes)" }
}
"reb:" -> piezoCollector.addPacket(data)
"red:" -> piezoCollector.addPacket(data)
"ree:", "raa:" -> piezoCollector.addPacket(data)
"rsn:" -> {
if (data.size >= 6) {
val millivolts = ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF)
@@ -0,0 +1,214 @@
package com.example.medilightv2android.ble
/**
* Collects multi-packet Piezo echo responses: reb: → red: → raa:/ree:
* 1:1 port of PiezoPacketCollector.swift
*
* Supports both Big Endian (new devices) and Little Endian (old devices).
* Auto-detects endianness from first channel's ADC data.
*/
class PiezoPacketCollector {
// Header metadata
var peakRaw: UShort = 0u; private set
var peakIndex: UShort = 0u; private set
var baselineRaw: UShort = 0u; private set
var numSamples: UShort = 0u; private set
var totalPackets: Int = 0; private set
// Collection state
var currentPacket: Int = 0; private set
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 detectedBigEndian = false
// Multi-channel
var isMultiChannel: Boolean = false; private set
var currentChannel: Int = 0; private set
var channelResults: MutableList<PiezoChannelData?> = mutableListOf(); private set
// Callbacks
var onComplete: ((PiezoEchoResult) -> Unit)? = null
var onMultiChannelComplete: ((List<PiezoChannelData>) -> Unit)? = null
var onLog: ((String) -> Unit)? = null
private var recursionDepth = 0
fun reset() {
peakRaw = 0u; peakIndex = 0u; baselineRaw = 0u; numSamples = 0u
totalPackets = 0; currentPacket = 0
isComplete = false; hasError = false; errorMessage = ""
rawData.clear(); hasFirstPacket = false
isMultiChannel = false; currentChannel = 0
channelResults.clear(); detectedBigEndian = false
}
fun startMultiChannel(channelCount: Int = 6) {
reset()
isMultiChannel = true
currentChannel = 0
channelResults = MutableList(channelCount) { null }
}
fun addPacket(data: ByteArray) {
if (isComplete || hasError) return
if (data.size < 4) return
recursionDepth++
if (recursionDepth >= 10) {
markError("Max recursion depth"); recursionDepth--; return
}
val prefix = String(data, 0, 4, Charsets.US_ASCII)
when (prefix) {
"reb:" -> processRebPacket(data)
"red:" -> processRedPacket(data)
"ree:", "raa:" -> processEndPacket(prefix, data)
}
recursionDepth--
}
// reb: Data packet
// Structure: reb:(4) + repeat_count(2, BE or LE) + 100 ADC(200) + CRC(2)
private fun processRebPacket(bytes: ByteArray) {
if (bytes.size < 8) { markError("reb: too short (${bytes.size})"); return }
if (isMultiChannel && hasFirstPacket && rawData.isNotEmpty()) {
saveCurrentChannel()
}
currentPacket = 0
hasFirstPacket = true
rawData.clear()
// Endian detection: first channel only → lock for remaining
if (currentChannel == 0 || !hasFirstPacket) {
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 = firstSampleLE > 4095 && firstSampleBE <= 4095
}
// 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)
// ADC data: bytes[6] to bytes[size-3] (skip prefix+count, exclude CRC)
val adcStart = 6
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
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")
}
private fun processRedPacket(bytes: ByteArray) {
if (!hasFirstPacket) { markError("red: before reb:"); return }
if (bytes.size < 6) return
for (i in 6 until bytes.size) rawData.add(bytes[i])
currentPacket++
}
private fun processEndPacket(prefix: String, bytes: ByteArray) {
if (isMultiChannel && prefix == "raa:") {
if (hasFirstPacket && rawData.isNotEmpty()) saveCurrentChannel()
isComplete = true
val validChannels = channelResults.filterNotNull()
onLog?.invoke("raa: multi-channel complete — ${validChannels.size} channels")
onMultiChannelComplete?.invoke(validChannels)
} else if (!isMultiChannel) {
finishSingleChannel()
}
}
private fun finishSingleChannel() {
isComplete = true
val buffer = parseADCBuffer()
val peak = buffer.maxOrNull() ?: 0u
val peakIdx = buffer.indexOf(peak).coerceAtLeast(0).toUShort()
val base: UShort = if (buffer.size >= 10) {
(buffer.takeLast(10).sumOf { it.toInt() } / 10).toUShort()
} else 0u
val result = PiezoEchoResult(0, buffer, peak, peakIdx, base, buffer.size, rawData.size)
onLog?.invoke("Complete: ${buffer.size} samples, peak=$peak @ idx $peakIdx")
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
while (i + 1 < rawData.size) {
val b0 = rawData[i].toInt() and 0xFF
val b1 = rawData[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
}
private fun markError(msg: String) {
hasError = true; errorMessage = msg
onLog?.invoke("ERROR: $msg")
}
}
// Data Models
data class PiezoEchoResult(
val channel: Int,
val buffer: List<UShort>,
val peakRaw: UShort,
val peakIndex: UShort,
val baselineRaw: UShort,
val numSamples: Int,
val rawByteCount: Int
)
data class PiezoChannelData(
val channel: Int,
val buffer: List<UShort>
) {
val peakRaw: UShort get() = buffer.maxOrNull() ?: 0u
val peakIndex: UShort get() = buffer.indexOf(peakRaw).coerceAtLeast(0).toUShort()
val baselineRaw: UShort get() = if (buffer.size >= 10) {
(buffer.takeLast(10).sumOf { it.toInt() } / 10).toUShort()
} else 0u
val numSamples: Int get() = buffer.size
val isValid: Boolean get() = buffer.size >= 20 && peakRaw > 200u && peakRaw < 4090u
}