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:
2026-03-10 11:26:36 +09:00
parent 91c5e5e7e0
commit 254381dbaa
7 changed files with 159 additions and 14 deletions
@@ -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() }
@@ -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)
}
@@ -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) {