diff --git a/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt b/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt index 5f5b821..3080432 100644 --- a/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt +++ b/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt @@ -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) diff --git a/app/src/main/java/com/example/medilightv2android/ble/PiezoPacketCollector.kt b/app/src/main/java/com/example/medilightv2android/ble/PiezoPacketCollector.kt new file mode 100644 index 0000000..567622a --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/ble/PiezoPacketCollector.kt @@ -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() + 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 = mutableListOf(); private set + + // Callbacks + var onComplete: ((PiezoEchoResult) -> Unit)? = null + var onMultiChannelComplete: ((List) -> 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 { + val buffer = mutableListOf() + 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, + val peakRaw: UShort, + val peakIndex: UShort, + val baselineRaw: UShort, + val numSamples: Int, + val rawByteCount: Int +) + +data class PiezoChannelData( + val channel: Int, + val buffer: List +) { + 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 +} diff --git a/gradlew b/gradlew old mode 100644 new mode 100755