refactor: 데모 브랜치 패키지 통일 com.example.medilightv2android → com.medithings.vesiscan

사용자앱 (feature/tab-navigation) 과 패키지 이름 일치. namespace + applicationId
둘 다 com.medithings.vesiscan 로 변경 (기존 데모 앱은 재설치 필요).

## 변경 범위
- Kotlin 148 파일: package + import 문 (714 occurrences)
- 디렉토리 이동: com/example/medilightv2android → com/medithings/vesiscan
  (main, test, androidTest 각각)
- app/build.gradle.kts: namespace, applicationId
- docs/FLAVOR_DEMO_STABLE.md: 참조 갱신
- V41DetectorCH4Test: BvDispatchResult.methodChosen → method (dto field name fix)

## 주의
- applicationId 가 바뀌므로 기존 데모 앱 (com.example.medilightv2android.demo) 은
  Android 관점에서 다른 앱으로 취급 — 재설치 시 PIN/설정 초기화됨.
- Fresh install 권장. 기존 앱 (com.example...) 은 별도로 uninstall 필요.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 14:30:43 +09:00
parent 55d55225ab
commit bcbc7b440c
159 changed files with 2865 additions and 717 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
package com.medithings.vesiscan.ble
object CRC16 {
private const val POLYNOMIAL: Int = 0x1021
private const val INITIAL_VALUE: Int = 0xFFFF
fun calculate(data: ByteArray): Int {
var crc = INITIAL_VALUE
for (byte in data) {
crc = crc xor ((byte.toInt() and 0xFF) shl 8)
for (j in 0 until 8) {
crc = if ((crc and 0x8000) != 0) {
(crc shl 1) xor POLYNOMIAL
} else {
crc shl 1
}
crc = crc and 0xFFFF
}
}
return crc
}
fun calculate(string: String): Int = calculate(string.toByteArray(Charsets.UTF_8))
fun appendCRC(data: ByteArray): ByteArray {
val crc = calculate(data)
return data + byteArrayOf(
(crc and 0xFF).toByte(), // Low byte first (Little Endian)
((crc shr 8) and 0xFF).toByte() // High byte
)
}
fun appendCRC(string: String): ByteArray = appendCRC(string.toByteArray(Charsets.UTF_8))
fun verify(data: ByteArray): Boolean {
if (data.size < 2) return false
val payload = data.copyOfRange(0, data.size - 2)
val receivedCRC = (data[data.size - 2].toInt() and 0xFF) or
((data[data.size - 1].toInt() and 0xFF) shl 8)
return calculate(payload) == receivedCRC
}
fun buildCommand(cmd: String, params: IntArray = intArrayOf()): ByteArray {
val cmdBytes = "$cmd?".toByteArray(Charsets.UTF_8)
val paramBytes = ByteArray(params.size * 2)
for (i in params.indices) {
paramBytes[i * 2] = (params[i] and 0xFF).toByte() // Low byte
paramBytes[i * 2 + 1] = ((params[i] shr 8) and 0xFF).toByte() // High byte
}
return appendCRC(cmdBytes + paramBytes)
}
fun buildCommandASCII(cmd: String, asciiParams: String = ""): ByteArray {
return appendCRC("$cmd?$asciiParams".toByteArray(Charsets.UTF_8))
}
/** 파라미터를 16비트 Big Endian으로 추가 (펌웨어 테스터와 동일 포맷) */
fun buildCommandBE(cmd: String, params: IntArray = intArrayOf()): ByteArray {
val cmdBytes = "$cmd?".toByteArray(Charsets.UTF_8)
val paramBytes = ByteArray(params.size * 2)
for (i in params.indices) {
paramBytes[i * 2] = ((params[i] shr 8) and 0xFF).toByte() // High byte first
paramBytes[i * 2 + 1] = (params[i] and 0xFF).toByte() // Low byte
}
return appendCRC(cmdBytes + paramBytes)
}
fun buildSimpleCommand(cmd: String): ByteArray {
return appendCRC(cmd.toByteArray(Charsets.UTF_8))
}
}
@@ -0,0 +1,186 @@
package com.medithings.vesiscan.ble
/**
* IMU 6축 raw 샘플 (Accel xyz + Gyro xyz).
*
* FW VBTFW0116+ rim: 응답 한 패킷에 15 샘플 (ODR 50Hz → 약 300ms 분량).
* Sensitivity:
* - Accel ±4g: 1 LSB = 1/8192 g
* - Gyro ±500 dps: 1 LSB = 1/65.536 dps
*/
data class ImuSample(
val ax: Float, val ay: Float, val az: Float, // g 단위 (중력 가속도)
val gx: Float, val gy: Float, val gz: Float // dps 단위 (degrees/sec)
) {
/** 전체 가속도 크기 — 정지 시 ≈ 1g */
val accelMagnitude: Float get() = kotlin.math.sqrt(ax * ax + ay * ay + az * az)
/** Gyro 전체 크기 — 정지 시 ≈ 0 dps */
val gyroMagnitude: Float get() = kotlin.math.sqrt(gx * gx + gy * gy + gz * gz)
}
/**
* rim: 패킷 파서.
*
* 패킷 구조 (총 188B):
* [tag 4B "rim:"] [num_sample 2B BE] [imu_raw_data 180B] [crc 2B]
*
* imu_raw_data = 15 samples × 12B/sample, 모두 BE int16:
* [Ax 2B] [Ay 2B] [Az 2B] [Gx 2B] [Gy 2B] [Gz 2B]
*
* 샘플 시간 순서: samples[0] = oldest (FIFO 순), samples[N-1] = newest.
* 실측 검증 완료 (Az ≈ +1g 평평 자세, 2026-05-27).
*/
class ImuPacketCollector {
var samples: List<ImuSample> = emptyList()
private set
var onComplete: ((List<ImuSample>) -> Unit)? = null
var onLog: ((String) -> Unit)? = null
companion object {
private const val ACCEL_LSB_PER_G = 8192f // ±4g FSR
private const val GYRO_LSB_PER_DPS = 65.536f // ±500 dps FSR
private const val SAMPLE_SIZE_BYTES = 12
private const val HEADER_BYTES = 6 // tag(4) + num(2)
private const val CRC_BYTES = 2
}
fun reset() {
samples = emptyList()
}
/**
* rsp: 패킷 처리 — msp 명령 응답.
* Format 가정 (firmware spec 미확정 — defensive parsing):
* 변형 1 (multi-sample, rim: 와 동일): [tag 4B][num 2B BE][N×12B sample][crc 2B]
* 변형 2 (single-sample): [tag 4B][12B sample][crc 2B] = 18B
*
* 시도 순서: 변형 1 → 실패 시 변형 2 → 둘 다 실패 시 hex log + 무시.
*/
fun parseRsp(data: ByteArray) {
if (data.size < 4 + CRC_BYTES) {
onLog?.invoke("rsp: too short (${data.size}B)")
return
}
// 변형 1 시도 (rim: 와 동일 format)
val num1 = readBe16(data, 4)
val expected1 = HEADER_BYTES + num1 * SAMPLE_SIZE_BYTES + CRC_BYTES
if (num1 in 1..15 && data.size >= expected1) {
parseMultiSample(data, num1)
return
}
// 변형 2 시도 (single sample, 4B tag + 12B sample + 2B crc = 18B)
if (data.size >= 18) {
val parsed = ArrayList<ImuSample>(1)
val off = 4
val axRaw = readBe16Signed(data, off)
val ayRaw = readBe16Signed(data, off + 2)
val azRaw = readBe16Signed(data, off + 4)
val gxRaw = readBe16Signed(data, off + 6)
val gyRaw = readBe16Signed(data, off + 8)
val gzRaw = readBe16Signed(data, off + 10)
parsed.add(ImuSample(
ax = axRaw / ACCEL_LSB_PER_G,
ay = ayRaw / ACCEL_LSB_PER_G,
az = azRaw / ACCEL_LSB_PER_G,
gx = gxRaw / GYRO_LSB_PER_DPS,
gy = gyRaw / GYRO_LSB_PER_DPS,
gz = gzRaw / GYRO_LSB_PER_DPS,
))
samples = parsed
val first = parsed[0]
onLog?.invoke("rsp: 1 sample (single fmt, Az=${"%.3f".format(first.az)}g)")
onComplete?.invoke(parsed)
return
}
// 둘 다 실패 — hex dump
val hex = data.take(kotlin.math.min(data.size, 24)).joinToString(" ") { "%02X".format(it) }
onLog?.invoke("rsp: unknown format (${data.size}B) hex=$hex")
}
private fun parseMultiSample(data: ByteArray, numSamples: Int) {
val parsed = ArrayList<ImuSample>(numSamples)
for (i in 0 until numSamples) {
val off = HEADER_BYTES + i * SAMPLE_SIZE_BYTES
val axRaw = readBe16Signed(data, off)
val ayRaw = readBe16Signed(data, off + 2)
val azRaw = readBe16Signed(data, off + 4)
val gxRaw = readBe16Signed(data, off + 6)
val gyRaw = readBe16Signed(data, off + 8)
val gzRaw = readBe16Signed(data, off + 10)
parsed.add(ImuSample(
ax = axRaw / ACCEL_LSB_PER_G,
ay = ayRaw / ACCEL_LSB_PER_G,
az = azRaw / ACCEL_LSB_PER_G,
gx = gxRaw / GYRO_LSB_PER_DPS,
gy = gyRaw / GYRO_LSB_PER_DPS,
gz = gzRaw / GYRO_LSB_PER_DPS,
))
}
samples = parsed
onLog?.invoke("rsp: $numSamples samples (multi fmt, last Az=${"%.3f".format(parsed.last().az)}g)")
onComplete?.invoke(parsed)
}
/** rim: 패킷 한 번 받으면 호출. 188B 전체 ByteArray. */
fun parseRim(data: ByteArray) {
if (data.size < HEADER_BYTES + CRC_BYTES) {
onLog?.invoke("rim: too short (${data.size}B)")
return
}
// RAW DUMP — division 전 원본 byte + int16 값 logcat 으로 출력 (디버그용).
val totalHex = data.joinToString(" ") { "%02X".format(it) }
android.util.Log.d("ImuRaw", "rim FULL HEX (${data.size}B): $totalHex")
val numSamples = readBe16(data, 4)
val payloadBytes = data.size - HEADER_BYTES - CRC_BYTES
val expectedBytes = numSamples * SAMPLE_SIZE_BYTES
if (payloadBytes < expectedBytes) {
onLog?.invoke("rim: payload short ($payloadBytes < $expectedBytes for $numSamples samples)")
return
}
val parsed = ArrayList<ImuSample>(numSamples)
for (i in 0 until numSamples) {
val off = HEADER_BYTES + i * SAMPLE_SIZE_BYTES
val axRaw = readBe16Signed(data, off)
val ayRaw = readBe16Signed(data, off + 2)
val azRaw = readBe16Signed(data, off + 4)
val gxRaw = readBe16Signed(data, off + 6)
val gyRaw = readBe16Signed(data, off + 8)
val gzRaw = readBe16Signed(data, off + 10)
// 첫 sample / 마지막 sample 만 raw int16 dump (모든 sample 로깅하면 폭주)
if (i == 0 || i == numSamples - 1) {
android.util.Log.d("ImuRaw",
"rim sample[$i] raw int16: ax=$axRaw ay=$ayRaw az=$azRaw " +
"gx=$gxRaw gy=$gyRaw gz=$gzRaw " +
"(g if /8192: ax=${"%.4f".format(axRaw / 8192f)} ay=${"%.4f".format(ayRaw / 8192f)} az=${"%.4f".format(azRaw / 8192f)})")
}
parsed.add(ImuSample(
ax = axRaw / ACCEL_LSB_PER_G,
ay = ayRaw / ACCEL_LSB_PER_G,
az = azRaw / ACCEL_LSB_PER_G,
gx = gxRaw / GYRO_LSB_PER_DPS,
gy = gyRaw / GYRO_LSB_PER_DPS,
gz = gzRaw / GYRO_LSB_PER_DPS,
))
}
samples = parsed
onLog?.invoke("rim: $numSamples samples parsed (last: Az=${"%.3f".format(parsed.last().az)}g)")
onComplete?.invoke(parsed)
}
private fun readBe16(data: ByteArray, idx: Int): Int {
val hi = data[idx].toInt() and 0xFF
val lo = data[idx + 1].toInt() and 0xFF
return (hi shl 8) or lo
}
private fun readBe16Signed(data: ByteArray, idx: Int): Int {
var v = readBe16(data, idx)
if (v >= 0x8000) v -= 0x10000
return v
}
}
@@ -0,0 +1,314 @@
package com.medithings.vesiscan.ble
/**
* Collects multi-packet Piezo echo responses: reb: → (red:) → raa:
*
* NEW packet layout (FW update — VBT post-2026-06):
* 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 {
// Header metadata (last received reb 기준 — 호환용)
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
var forceBigEndian: Boolean = true // VBT 기기 기본 BE, 기기 연결 시 설정
// Multi-channel + session tracking
var isMultiChannel: Boolean = false; private set
var channelResults: MutableList<PiezoChannelData?> = mutableListOf(); private set
private var currentSetSession: Int? = null
var isSetDropped: Boolean = false; private set
// Callbacks
var onComplete: ((PiezoEchoResult) -> Unit)? = null
var onMultiChannelComplete: ((List<PiezoChannelData>) -> Unit)? = null
/** true 면 다음 onMultiChannelComplete 1회 skip (자동 reset).
* posture polling 으로 mtb 보낼 때 측정 callback 가 spurious fire 되는 거 차단용. */
var skipNextComplete: Boolean = false
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
channelResults.clear(); detectedBigEndian = false
currentSetSession = null
isSetDropped = false
}
fun startMultiChannel(channelCount: Int = 6) {
reset()
isMultiChannel = true
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) {
"rbb:" -> {
onLog?.invoke("rbb: header skipped (${data.size}B) — waiting for reb:")
}
"reb:" -> processRebPacket(data)
"red:" -> processRedPacket(data)
"ree:", "raa:" -> processEndPacket(prefix, data)
}
recursionDepth--
}
// reb: Data packet (NEW format, FW post-2026-06)
// 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) {
if (bytes.size < 10) { markError("reb: too short (${bytes.size}, expected ≥10)"); return }
val chSession = bytes[4].toInt() and 0xFF
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
}
}
// Endian detection — first ADC sample at bytes[8..9]. Lock for the remaining channels.
// VBT = Big Endian, 2025MEDIP = Little Endian. Fallback: forceBigEndian flag.
val isFirstChannelOfSet = if (isMultiChannel) (chNum == 0) else (!hasFirstPacket)
if (isFirstChannelOfSet) {
val b8 = bytes.getOrElse(8) { 0 }.toInt() and 0xFF
val b9 = bytes.getOrElse(9) { 0 }.toInt() and 0xFF
val firstSampleLE = b8 or (b9 shl 8)
val firstSampleBE = (b8 shl 8) or b9
detectedBigEndian = when {
firstSampleLE > 4095 && firstSampleBE <= 4095 -> true
firstSampleBE > 4095 && firstSampleLE <= 4095 -> false
else -> forceBigEndian
}
onLog?.invoke("Endian: ${if (detectedBigEndian) "BE" else "LE"} (LE=$firstSampleLE BE=$firstSampleBE force=$forceBigEndian)")
}
// num_sample at bytes[6..7]
val b6 = bytes[6].toInt() and 0xFF
val b7 = bytes[7].toInt() and 0xFF
val repeatCount = if (detectedBigEndian) (b6 shl 8) or b7 else b6 or (b7 shl 8)
// ADC data at bytes[8..size-3] (exclude trailing CRC 2B)
val adcStart = 8
val adcEnd = bytes.size - 2
if (adcEnd <= adcStart) { markError("reb: no ADC data"); return }
val sampleCount = (adcEnd - adcStart) / 2
if (isMultiChannel) {
// Parse this channel's ADC directly into a buffer and store at ch_num position
val buffer = parseADCBufferRange(bytes, adcStart, adcEnd)
channelResults[chNum] = PiezoChannelData(chNum, buffer)
totalPackets++
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()
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) {
if (isMultiChannel) {
onLog?.invoke("red: ignored in multi-channel mode (ch_num-indexed set)")
return
}
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:") {
// Set 종료 마커 — 완전성 검사 후 콜백 결정
isComplete = true
when {
isSetDropped -> {
onLog?.invoke("raa: set dropped (session=$currentSetSession) — no callback")
}
channelResults.any { it == null } -> {
val missing = channelResults.mapIndexedNotNull { i, c -> if (c == null) i else null }
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")
if (skipNextComplete) {
skipNextComplete = false
onLog?.invoke("raa: skip onComplete (posture poll)")
} else {
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 {
// Single-channel mode 완료 (ree: 또는 raa:)
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 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
}