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