feat: BLE stability fixes, NIRS streaming improvements, catheter count feature
- Fix BLE API deprecation warnings with API 33+ version checks - Move isServiceReady to onDescriptorWrite for reliable CCCD setup - Fix battery polling: immediate query on connect, pause during NIRS streaming - Add mcj retry mechanism for NIRS streaming reliability - Add debug logging to BLE, NIRS parsing, and processing pipeline - Add catheter count feature to Piezo monitoring (persistent count, add dialog, auto-decrement on catheterize) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package com.example.medilightv2android
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.example.medilightv2android.ble.BleManager
|
||||
@@ -22,6 +23,7 @@ class AppState(private val context: Context) {
|
||||
var bladderLevel by mutableStateOf(BladderLevel())
|
||||
var isDeviceConnected by mutableStateOf(false)
|
||||
var piezoSettings by mutableStateOf(PiezoSettings())
|
||||
var catheterCount = mutableIntStateOf(0)
|
||||
|
||||
private val storage = UserStorage.getInstance(context)
|
||||
private val historyStore = MeasurementHistoryStore.getInstance(context)
|
||||
@@ -40,6 +42,7 @@ class AppState(private val context: Context) {
|
||||
catheterizationThreshold = storage.catheterizationLevel
|
||||
)
|
||||
piezoSettings = storage.piezoSettings
|
||||
catheterCount.intValue = storage.catheterCount
|
||||
|
||||
currentScreen = when {
|
||||
!storage.hasCompletedOnboarding -> AppScreen.ONBOARDING
|
||||
@@ -147,6 +150,11 @@ class AppState(private val context: Context) {
|
||||
bladderLevel = newLevel
|
||||
storage.bladderLevel = 0
|
||||
notificationService.cancelAll()
|
||||
|
||||
if (catheterCount.intValue > 0) {
|
||||
catheterCount.intValue--
|
||||
storage.catheterCount = catheterCount.intValue
|
||||
}
|
||||
}
|
||||
|
||||
fun showHistory() {
|
||||
@@ -157,6 +165,11 @@ class AppState(private val context: Context) {
|
||||
currentScreen = if (sensorMode == SensorMode.NIRS) AppScreen.VIVAMYO_MONITORING else AppScreen.PIEZO_MONITORING
|
||||
}
|
||||
|
||||
fun addCatheterCount(amount: Int = 1) {
|
||||
catheterCount.intValue += amount
|
||||
storage.catheterCount = catheterCount.intValue
|
||||
}
|
||||
|
||||
fun updateCatheterizationThreshold(threshold: Int) {
|
||||
bladderLevel = bladderLevel.copy(catheterizationThreshold = threshold)
|
||||
storage.catheterizationLevel = threshold
|
||||
|
||||
@@ -244,16 +244,28 @@ class BleManager private constructor(private val context: Context) {
|
||||
|
||||
// Send
|
||||
fun sendRaw(data: ByteArray) {
|
||||
val characteristic = txCharacteristic ?: return
|
||||
val gatt = bluetoothGatt ?: return
|
||||
val characteristic = txCharacteristic
|
||||
val gatt = bluetoothGatt
|
||||
if (characteristic == null) {
|
||||
Log.e(TAG, "sendRaw: txCharacteristic is null!")
|
||||
return
|
||||
}
|
||||
if (gatt == null) {
|
||||
Log.e(TAG, "sendRaw: bluetoothGatt is null!")
|
||||
return
|
||||
}
|
||||
val cmdPreview = if (data.size >= 4) String(data, 0, 4, Charsets.US_ASCII) else "?"
|
||||
Log.d(TAG, "sendRaw: $cmdPreview (${data.size} bytes)")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
gatt.writeCharacteristic(characteristic, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE)
|
||||
val result = gatt.writeCharacteristic(characteristic, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE)
|
||||
Log.d(TAG, "sendRaw result: $result")
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
characteristic.value = data
|
||||
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||
@Suppress("DEPRECATION")
|
||||
gatt.writeCharacteristic(characteristic)
|
||||
val ok = gatt.writeCharacteristic(characteristic)
|
||||
Log.d(TAG, "sendRaw success: $ok")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +305,8 @@ class BleManager private constructor(private val context: Context) {
|
||||
|
||||
fun startBatteryPolling() {
|
||||
batteryTimer?.let { handler.removeCallbacks(it) }
|
||||
handler.postDelayed({ sendBatteryQuery() }, 1000)
|
||||
// Query immediately, then every 30s
|
||||
sendBatteryQuery()
|
||||
val poll = object : Runnable {
|
||||
override fun run() {
|
||||
sendBatteryQuery()
|
||||
@@ -301,10 +314,10 @@ class BleManager private constructor(private val context: Context) {
|
||||
}
|
||||
}
|
||||
batteryTimer = poll
|
||||
handler.postDelayed(poll, 31000)
|
||||
handler.postDelayed(poll, 30000)
|
||||
}
|
||||
|
||||
private fun stopBatteryPolling() {
|
||||
fun stopBatteryPolling() {
|
||||
batteryTimer?.let { handler.removeCallbacks(it) }
|
||||
batteryTimer = null
|
||||
}
|
||||
@@ -353,7 +366,6 @@ class BleManager private constructor(private val context: Context) {
|
||||
} catch (_: Exception) {}
|
||||
|
||||
gatt.discoverServices()
|
||||
startBatteryPolling()
|
||||
onConnectionStateChanged?.invoke(true)
|
||||
}
|
||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
@@ -439,6 +451,7 @@ class BleManager private constructor(private val context: Context) {
|
||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
if (txCharacteristic != null && rxCharacteristic != null) {
|
||||
isServiceReady.value = true
|
||||
startBatteryPolling()
|
||||
Log.d(TAG, "Service ready! (CCCD written)")
|
||||
}
|
||||
} else {
|
||||
@@ -469,7 +482,15 @@ class BleManager private constructor(private val context: Context) {
|
||||
|
||||
private fun processReceivedData(data: ByteArray) {
|
||||
val prefix = String(data, 0, minOf(4, data.size), Charsets.US_ASCII)
|
||||
Log.d(TAG, "Received: $prefix (${data.size} bytes)")
|
||||
Log.d(TAG, "Received: $prefix (${data.size} bytes), callback=${
|
||||
when {
|
||||
prefix.startsWith("rcj") -> "onNirsMcjReceived=${onNirsMcjReceived != null}"
|
||||
prefix.startsWith("rta") -> "onNirsPowerOnReceived=${onNirsPowerOnReceived != null}"
|
||||
prefix.startsWith("rqq") || prefix.startsWith("rsh") -> "onNirsSensorActivated=${onNirsSensorActivated != null}"
|
||||
prefix.startsWith("rag") -> "onNirsMagReceived=${onNirsMagReceived != null}"
|
||||
else -> "n/a"
|
||||
}
|
||||
}")
|
||||
when (prefix) {
|
||||
"rta:", "rta!" -> onNirsPowerOnReceived?.invoke()
|
||||
"rsh:", "rsh!", "rqq:", "rqq!" -> onNirsSensorActivated?.invoke()
|
||||
@@ -482,6 +503,7 @@ class BleManager private constructor(private val context: Context) {
|
||||
batteryLevel.value = ((millivolts - 3700) * 100 / 500).coerceIn(0, 100)
|
||||
}
|
||||
}
|
||||
else -> Log.w(TAG, "Unknown prefix: '$prefix' hex=${data.take(8).joinToString(" ") { "%02X".format(it) }}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,14 @@ class NirsManager private constructor() {
|
||||
currentOxy.value = result.oxyFiltered
|
||||
sampleCount.intValue++
|
||||
|
||||
if (sampleCount.intValue <= 3 || sampleCount.intValue % 50 == 0) {
|
||||
android.util.Log.d("NirsManager", "Sample #${sampleCount.intValue}: " +
|
||||
"hasGain=${calcEngine.hasGain}, " +
|
||||
"led[0..5]=${packet.ledValues.take(6)}, " +
|
||||
"degain[0..2]=${result.degain.take(3).map { "%.2f".format(it) }}, " +
|
||||
"oxy=StO2:${"%.1f".format(result.oxyFiltered.stO2)} HbO2:${"%.1f".format(result.oxyFiltered.hbO2)} HHb:${"%.1f".format(result.oxyFiltered.hb)}")
|
||||
}
|
||||
|
||||
appendTrimming(oxyHbO2History, result.oxyFiltered.hbO2)
|
||||
appendTrimming(oxyHbHistory, result.oxyFiltered.hb)
|
||||
}
|
||||
|
||||
@@ -12,8 +12,13 @@ data class NirsMcjPacket(
|
||||
val ledValues: IntArray
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "NirsMcjPacket"
|
||||
|
||||
fun parse(data: ByteArray): NirsMcjPacket? {
|
||||
if (data.size < 72) return null
|
||||
if (data.size < 72) {
|
||||
android.util.Log.e(TAG, "parse: data too small: ${data.size} < 72")
|
||||
return null
|
||||
}
|
||||
|
||||
fun u16(offset: Int): Int =
|
||||
((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
|
||||
@@ -34,10 +39,15 @@ data class NirsMcjPacket(
|
||||
val ledValues = IntArray(24)
|
||||
for (i in 0 until 24) {
|
||||
val offset = base + 20 + (i * 2)
|
||||
if (offset + 1 >= data.size) return null
|
||||
if (offset + 1 >= data.size) {
|
||||
android.util.Log.e(TAG, "parse: LED[$i] out of bounds: offset=${offset+1} >= size=${data.size}")
|
||||
return null
|
||||
}
|
||||
ledValues[i] = u16(offset)
|
||||
}
|
||||
|
||||
android.util.Log.d(TAG, "parse OK: battery=$battery led[0..5]=${ledValues.take(6)}")
|
||||
|
||||
return NirsMcjPacket(
|
||||
battery, temperature,
|
||||
accX, accY, accZ,
|
||||
|
||||
@@ -73,6 +73,10 @@ class UserStorage private constructor(context: Context) {
|
||||
get() = prefs.getInt("bladder_level", 0)
|
||||
set(value) { prefs.edit().putInt("bladder_level", value).apply() }
|
||||
|
||||
var catheterCount: Int
|
||||
get() = prefs.getInt("catheter_count", 0)
|
||||
set(value) { prefs.edit().putInt("catheter_count", value).apply() }
|
||||
|
||||
var hasCompletedOnboarding: Boolean
|
||||
get() = prefs.getBoolean("has_completed_onboarding", false)
|
||||
set(value) { prefs.edit().putBoolean("has_completed_onboarding", value).apply() }
|
||||
|
||||
+47
@@ -43,6 +43,7 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
var showSettings by remember { mutableStateOf(false) }
|
||||
var isMeasuring by remember { mutableStateOf(false) }
|
||||
var showCatheterizeConfirm by remember { mutableStateOf(false) }
|
||||
var showAddCatheterDialog by remember { mutableStateOf(false) }
|
||||
var showDisconnectionBanner by remember { mutableStateOf(false) }
|
||||
var reconnectionState by remember { mutableStateOf(BleReconnectionState.DISCONNECTED) }
|
||||
var isAutoMeasuring by remember { mutableStateOf(false) }
|
||||
@@ -50,6 +51,7 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
val urgency = appState.bladderLevel.urgency
|
||||
val batteryLvl by bleManager.batteryLevel
|
||||
val isConnected by bleManager.isConnected
|
||||
val catheterCount by appState.catheterCount
|
||||
|
||||
fun measure() {
|
||||
isMeasuring = true
|
||||
@@ -74,6 +76,36 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
}
|
||||
}
|
||||
|
||||
// Add catheter dialog
|
||||
if (showAddCatheterDialog) {
|
||||
var addAmount by remember { mutableStateOf("1") }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showAddCatheterDialog = false },
|
||||
title = { Text("Add Catheters") },
|
||||
text = {
|
||||
Column {
|
||||
Text("Enter the number of catheters to add:")
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = addAmount,
|
||||
onValueChange = { addAmount = it.filter { c -> c.isDigit() } },
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val amount = addAmount.toIntOrNull() ?: 0
|
||||
if (amount > 0) appState.addCatheterCount(amount)
|
||||
showAddCatheterDialog = false
|
||||
}) { Text("Add", color = MlPrimary) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showAddCatheterDialog = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Catheterize confirmation dialog
|
||||
if (showCatheterizeConfirm) {
|
||||
AlertDialog(
|
||||
@@ -124,6 +156,21 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
}
|
||||
}
|
||||
|
||||
// Catheter count
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MlPrimary.copy(alpha = 0.1f))
|
||||
.clickable { showAddCatheterDialog = true }
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.Add, "Add catheter", tint = MlPrimary, modifier = Modifier.size(14.dp))
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Text("$catheterCount", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
IconButton(onClick = { appState.showHistory() }) {
|
||||
Icon(Icons.Default.History, "History", tint = MlSecondaryText)
|
||||
}
|
||||
|
||||
+44
-3
@@ -56,18 +56,56 @@ fun VivamyoMonitoringView(appState: AppState) {
|
||||
val sampleCount by nirsManager.sampleCount
|
||||
val isFilterEnabled by nirsManager.isFilterEnabled
|
||||
|
||||
var mcjRetryCount = remember { mutableIntStateOf(0) }
|
||||
var mcjRetryRunnable by remember { mutableStateOf<Runnable?>(null) }
|
||||
|
||||
fun cancelMcjRetry() {
|
||||
mcjRetryRunnable?.let { handler.removeCallbacks(it) }
|
||||
mcjRetryRunnable = null
|
||||
mcjRetryCount.intValue = 0
|
||||
}
|
||||
|
||||
fun startNirsStream() {
|
||||
cancelMcjRetry()
|
||||
|
||||
bleManager.onNirsMcjReceived = { data ->
|
||||
cancelMcjRetry() // Got response, cancel retry
|
||||
if (nirsManager.isStreaming.value) {
|
||||
NirsMcjPacket.parse(data)?.let { packet ->
|
||||
val packet = NirsMcjPacket.parse(data)
|
||||
if (packet != null) {
|
||||
nirsManager.processMcjPacket(packet)
|
||||
} else {
|
||||
android.util.Log.e("VivamyoMonitor", "NirsMcjPacket.parse returned null! data.size=${data.size}")
|
||||
}
|
||||
handler.postDelayed({
|
||||
if (nirsManager.isStreaming.value) bleManager.sendMcjQuery()
|
||||
if (nirsManager.isStreaming.value) {
|
||||
bleManager.sendMcjQuery()
|
||||
// Retry if no response within 2s
|
||||
val retry = Runnable {
|
||||
if (nirsManager.isStreaming.value && mcjRetryCount.intValue < 3) {
|
||||
mcjRetryCount.intValue++
|
||||
bleManager.sendMcjQuery()
|
||||
}
|
||||
}
|
||||
mcjRetryRunnable = retry
|
||||
handler.postDelayed(retry, 2000)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
// Start mcj chain directly (same as iOS)
|
||||
bleManager.sendMcjQuery()
|
||||
|
||||
// Retry if first mcj gets no response within 2s
|
||||
val initialRetry = Runnable {
|
||||
if (nirsManager.isStreaming.value && mcjRetryCount.intValue < 5) {
|
||||
mcjRetryCount.intValue++
|
||||
bleManager.sendMcjQuery()
|
||||
}
|
||||
}
|
||||
mcjRetryRunnable = initialRetry
|
||||
handler.postDelayed(initialRetry, 2000)
|
||||
}
|
||||
|
||||
fun toggleStreaming() {
|
||||
@@ -75,7 +113,10 @@ fun VivamyoMonitoringView(appState: AppState) {
|
||||
isToggling = true
|
||||
if (isStreaming) {
|
||||
nirsManager.stopStreaming()
|
||||
cancelMcjRetry()
|
||||
bleManager.startBatteryPolling() // Resume battery polling
|
||||
} else {
|
||||
bleManager.stopBatteryPolling() // Pause battery polling during streaming
|
||||
nirsManager.startStreaming()
|
||||
startNirsStream()
|
||||
}
|
||||
@@ -89,7 +130,7 @@ fun VivamyoMonitoringView(appState: AppState) {
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) { onDispose { nirsManager.stopStreaming() } }
|
||||
DisposableEffect(Unit) { onDispose { nirsManager.stopStreaming(); cancelMcjRetry() } }
|
||||
|
||||
// Clear confirm dialog
|
||||
if (showClearConfirm) {
|
||||
|
||||
Reference in New Issue
Block a user