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:
@@ -274,7 +274,9 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Piezo Commands
|
// 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)))
|
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)))
|
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
|
// NIRS Commands
|
||||||
fun sendNirsPowerOn() {
|
fun sendNirsPowerOn() {
|
||||||
sendRaw(CRC16.buildCommand("mta", intArrayOf(1)))
|
sendRaw(CRC16.buildCommand("mta", intArrayOf(1)))
|
||||||
@@ -505,6 +513,14 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
"rag:", "rag!" -> onNirsMagReceived?.invoke(data)
|
"rag:", "rag!" -> onNirsMagReceived?.invoke(data)
|
||||||
"rcj:", "rcj!" -> onNirsMcjReceived?.invoke(data)
|
"rcj:", "rcj!" -> onNirsMcjReceived?.invoke(data)
|
||||||
"rpa:" -> onPiezoDataReceived?.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:" -> {
|
"rsn:" -> {
|
||||||
if (data.size >= 6) {
|
if (data.size >= 6) {
|
||||||
val millivolts = ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF)
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user