diff --git a/app/src/main/java/com/example/medilightv2android/AppState.kt b/app/src/main/java/com/example/medilightv2android/AppState.kt index 454a70a..a4f4189 100644 --- a/app/src/main/java/com/example/medilightv2android/AppState.kt +++ b/app/src/main/java/com/example/medilightv2android/AppState.kt @@ -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 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 30c99f2..571595f 100644 --- a/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt +++ b/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt @@ -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) }}") } } } diff --git a/app/src/main/java/com/example/medilightv2android/managers/NirsManager.kt b/app/src/main/java/com/example/medilightv2android/managers/NirsManager.kt index 604c519..fbf6283 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/NirsManager.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/NirsManager.kt @@ -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) } diff --git a/app/src/main/java/com/example/medilightv2android/models/NirsData.kt b/app/src/main/java/com/example/medilightv2android/models/NirsData.kt index db8af32..7ef0c4c 100644 --- a/app/src/main/java/com/example/medilightv2android/models/NirsData.kt +++ b/app/src/main/java/com/example/medilightv2android/models/NirsData.kt @@ -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, diff --git a/app/src/main/java/com/example/medilightv2android/services/UserStorage.kt b/app/src/main/java/com/example/medilightv2android/services/UserStorage.kt index 33bd1f6..7f0d741 100644 --- a/app/src/main/java/com/example/medilightv2android/services/UserStorage.kt +++ b/app/src/main/java/com/example/medilightv2android/services/UserStorage.kt @@ -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() } diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt index 6f2d142..768669e 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt @@ -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) } diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/VivamyoMonitoringView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/VivamyoMonitoringView.kt index 53c0fe9..48efa69 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/VivamyoMonitoringView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/VivamyoMonitoringView.kt @@ -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(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) {