diff --git a/app/build.gradle b/app/build.gradle
index c73996b877..ea7dd17a90 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -189,6 +189,7 @@ dependencies {
implementation project(':implementation')
implementation project(':database')
implementation project(':pump:combo')
+ implementation project(':pump:combov2')
implementation project(':pump:dana')
implementation project(':pump:danars')
implementation project(':pump:danar')
diff --git a/app/src/main/java/info/nightscout/androidaps/activities/MyPreferenceFragment.kt b/app/src/main/java/info/nightscout/androidaps/activities/MyPreferenceFragment.kt
index b5c849d9e2..f33eea44c4 100644
--- a/app/src/main/java/info/nightscout/androidaps/activities/MyPreferenceFragment.kt
+++ b/app/src/main/java/info/nightscout/androidaps/activities/MyPreferenceFragment.kt
@@ -43,6 +43,7 @@ import info.nightscout.androidaps.plugins.general.nsclient.data.NSSettingsStatus
import info.nightscout.androidaps.plugins.general.tidepool.TidepoolPlugin
import info.nightscout.androidaps.plugins.general.wear.WearPlugin
import info.nightscout.androidaps.plugins.pump.combo.ComboPlugin
+import info.nightscout.androidaps.plugins.pump.combov2.ComboV2Plugin
import info.nightscout.androidaps.plugins.pump.eopatch.EopatchPumpPlugin
import info.nightscout.androidaps.plugins.pump.insight.LocalInsightPlugin
import info.nightscout.androidaps.plugins.pump.medtronic.MedtronicPumpPlugin
@@ -92,6 +93,7 @@ class MyPreferenceFragment : PreferenceFragmentCompat(), OnSharedPreferenceChang
@Inject lateinit var danaRv2Plugin: DanaRv2Plugin
@Inject lateinit var danaRSPlugin: DanaRSPlugin
@Inject lateinit var comboPlugin: ComboPlugin
+ @Inject lateinit var combov2Plugin: ComboV2Plugin
@Inject lateinit var insulinOrefFreePeakPlugin: InsulinOrefFreePeakPlugin
@Inject lateinit var loopPlugin: LoopPlugin
@Inject lateinit var localInsightPlugin: LocalInsightPlugin
@@ -205,6 +207,7 @@ class MyPreferenceFragment : PreferenceFragmentCompat(), OnSharedPreferenceChang
addPreferencesFromResourceIfEnabled(danaRSPlugin, rootKey, config.PUMPDRIVERS)
addPreferencesFromResourceIfEnabled(localInsightPlugin, rootKey, config.PUMPDRIVERS)
addPreferencesFromResourceIfEnabled(comboPlugin, rootKey, config.PUMPDRIVERS)
+ addPreferencesFromResourceIfEnabled(combov2Plugin, rootKey, config.PUMPDRIVERS)
addPreferencesFromResourceIfEnabled(medtronicPumpPlugin, rootKey, config.PUMPDRIVERS)
addPreferencesFromResourceIfEnabled(diaconnG8Plugin, rootKey, config.PUMPDRIVERS)
addPreferencesFromResourceIfEnabled(eopatchPumpPlugin, rootKey, config.PUMPDRIVERS)
diff --git a/app/src/main/java/info/nightscout/androidaps/di/AppComponent.kt b/app/src/main/java/info/nightscout/androidaps/di/AppComponent.kt
index 406286242c..30b97013e8 100644
--- a/app/src/main/java/info/nightscout/androidaps/di/AppComponent.kt
+++ b/app/src/main/java/info/nightscout/androidaps/di/AppComponent.kt
@@ -6,6 +6,7 @@ import dagger.android.AndroidInjectionModule
import dagger.android.AndroidInjector
import info.nightscout.androidaps.MainApp
import info.nightscout.androidaps.combo.di.ComboModule
+import info.nightscout.androidaps.combov2.di.ComboV2Module
import info.nightscout.androidaps.dana.di.DanaHistoryModule
import info.nightscout.androidaps.dana.di.DanaModule
import info.nightscout.androidaps.danar.di.DanaRModule
@@ -61,6 +62,7 @@ import javax.inject.Singleton
// pumps
ComboModule::class,
+ ComboV2Module::class,
DanaHistoryModule::class,
DanaModule::class,
DanaRModule::class,
diff --git a/app/src/main/java/info/nightscout/androidaps/di/PluginsListModule.kt b/app/src/main/java/info/nightscout/androidaps/di/PluginsListModule.kt
index 81af39bf17..a57b5b94c0 100644
--- a/app/src/main/java/info/nightscout/androidaps/di/PluginsListModule.kt
+++ b/app/src/main/java/info/nightscout/androidaps/di/PluginsListModule.kt
@@ -28,6 +28,7 @@ import info.nightscout.androidaps.plugins.general.tidepool.TidepoolPlugin
import info.nightscout.androidaps.plugins.general.wear.WearPlugin
import info.nightscout.androidaps.plugins.iob.iobCobCalculator.IobCobCalculatorPlugin
import info.nightscout.androidaps.plugins.pump.combo.ComboPlugin
+import info.nightscout.androidaps.plugins.pump.combov2.ComboV2Plugin
import info.nightscout.androidaps.plugins.pump.eopatch.EopatchPumpPlugin
import info.nightscout.androidaps.plugins.pump.insight.LocalInsightPlugin
import info.nightscout.androidaps.plugins.pump.medtronic.MedtronicPumpPlugin
@@ -424,6 +425,12 @@ abstract class PluginsListModule {
@IntKey(500)
abstract fun bindThemeSwitcherPlugin(plugin: ThemeSwitcherPlugin): PluginBase
+ @Binds
+ @PumpDriver
+ @IntoMap
+ @IntKey(510)
+ abstract fun bindComboV2Plugin(plugin: ComboV2Plugin): PluginBase
+
@Qualifier
annotation class AllConfigs
diff --git a/build.gradle b/build.gradle
index dc13e951ff..9fe9ba7a52 100644
--- a/build.gradle
+++ b/build.gradle
@@ -45,8 +45,8 @@ buildscript {
play_services_wearable_version = '17.1.0'
play_services_location_version = '20.0.0'
- kotlinx_coroutines_version = '1.6.3'
kotlinx_datetime_version = '0.3.2'
+ kotlinx_serialization_core_version = '1.3.2'
}
repositories {
google()
diff --git a/core/src/main/java/info/nightscout/androidaps/plugins/general/overview/notifications/Notification.kt b/core/src/main/java/info/nightscout/androidaps/plugins/general/overview/notifications/Notification.kt
index 1bea9d26dd..401e97f507 100644
--- a/core/src/main/java/info/nightscout/androidaps/plugins/general/overview/notifications/Notification.kt
+++ b/core/src/main/java/info/nightscout/androidaps/plugins/general/overview/notifications/Notification.kt
@@ -131,6 +131,8 @@ open class Notification {
const val IDENTIFICATION_NOT_SET = 77
const val PERMISSION_BT = 78
const val EOELOW_PATCH_ALERTS = 79
+ const val COMBO_PUMP_SUSPENDED = 80
+ const val COMBO_UNKNOWN_TBR = 81
const val USER_MESSAGE = 1000
diff --git a/pump/combov2/build.gradle b/pump/combov2/build.gradle
new file mode 100644
index 0000000000..af74d630d3
--- /dev/null
+++ b/pump/combov2/build.gradle
@@ -0,0 +1,40 @@
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlin-kapt'
+apply plugin: 'kotlin-allopen'
+apply plugin: 'com.hiya.jacoco-android'
+
+apply from: "${project.rootDir}/core/android_dependencies.gradle"
+apply from: "${project.rootDir}/core/android_module_dependencies.gradle"
+apply from: "${project.rootDir}/core/test_dependencies.gradle"
+apply from: "${project.rootDir}/core/jacoco_global.gradle"
+
+dependencies {
+ implementation project(':core')
+ implementation project(':shared')
+ implementation project(':libraries')
+ implementation(project(":pump:combov2:comboctl"))
+ implementation("org.jetbrains.kotlinx:kotlinx-datetime:$kotlinx_datetime_version")
+ // This is necessary to avoid errors like these which otherwise come up often at runtime:
+ // "WARNING: Failed to transform class kotlinx/datetime/TimeZone$Companion
+ // java.lang.NoClassDefFoundError: kotlinx/serialization/KSerializer"
+ //
+ // "Rejecting re-init on previously-failed class java.lang.Class<
+ // kotlinx.datetime.serializers.LocalDateTimeIso8601Serializer>:
+ // java.lang.NoClassDefFoundError: Failed resolution of: Lkotlinx/serialization/KSerializer"
+ //
+ // kotlinx-datetime higher than 0.2.0 depends on kotlinx-serialization, but that dependency
+ // is declared as "compileOnly". The runtime dependency on kotlinx-serialization is missing,
+ // causing this error. Solution is to add runtimeOnly here.
+ //
+ // Source: https://github.com/mockk/mockk/issues/685#issuecomment-907076353:
+ // TODO: Revisit this when upgrading kotlinx-datetime
+ runtimeOnly("org.jetbrains.kotlinx:kotlinx-serialization-core:$kotlinx_serialization_core_version")
+}
+
+android {
+ namespace 'info.nightscout.androidaps.combov2'
+ buildFeatures {
+ dataBinding true
+ }
+}
diff --git a/pump/combov2/src/main/AndroidManifest.xml b/pump/combov2/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..3e1c4bb4b6
--- /dev/null
+++ b/pump/combov2/src/main/AndroidManifest.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/pump/combov2/src/main/kotlin/info/nightscout/androidaps/combov2/di/ComboV2ActivitiesModule.kt b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/combov2/di/ComboV2ActivitiesModule.kt
new file mode 100644
index 0000000000..2d7595b6dc
--- /dev/null
+++ b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/combov2/di/ComboV2ActivitiesModule.kt
@@ -0,0 +1,14 @@
+package info.nightscout.androidaps.combov2.di
+
+import dagger.Module
+import dagger.android.ContributesAndroidInjector
+import info.nightscout.androidaps.plugins.pump.combov2.ComboV2Fragment
+import info.nightscout.androidaps.plugins.pump.combov2.activities.ComboV2PairingActivity
+
+@Module
+@Suppress("unused")
+abstract class ComboV2ActivitiesModule {
+ @ContributesAndroidInjector abstract fun contributesComboV2PairingActivity(): ComboV2PairingActivity
+
+ @ContributesAndroidInjector abstract fun contributesComboV2Fragment(): ComboV2Fragment
+}
diff --git a/pump/combov2/src/main/kotlin/info/nightscout/androidaps/combov2/di/ComboV2Module.kt b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/combov2/di/ComboV2Module.kt
new file mode 100644
index 0000000000..43c26351ce
--- /dev/null
+++ b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/combov2/di/ComboV2Module.kt
@@ -0,0 +1,8 @@
+package info.nightscout.androidaps.combov2.di
+
+import dagger.Module
+
+@Module(includes = [
+ ComboV2ActivitiesModule::class
+])
+open class ComboV2Module
diff --git a/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/AAPSComboCtlLogger.kt b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/AAPSComboCtlLogger.kt
new file mode 100644
index 0000000000..68447bf9ad
--- /dev/null
+++ b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/AAPSComboCtlLogger.kt
@@ -0,0 +1,40 @@
+package info.nightscout.androidaps.plugins.pump.combov2
+
+import android.util.Log
+import info.nightscout.shared.logging.AAPSLogger
+import info.nightscout.shared.logging.LTag
+import info.nightscout.comboctl.base.LogLevel
+import info.nightscout.comboctl.base.LoggerBackend as ComboCtlLoggerBackend
+
+internal class AAPSComboCtlLogger(private val aapsLogger: AAPSLogger) : ComboCtlLoggerBackend {
+ override fun log(tag: String, level: LogLevel, throwable: Throwable?, message: String?) {
+ val ltag = with (tag) {
+ when {
+ startsWith("Bluetooth") || startsWith("AndroidBluetooth") -> LTag.PUMPBTCOMM
+ endsWith("IO") -> LTag.PUMPCOMM
+ else -> LTag.PUMP
+ }
+ }
+
+ val fullMessage = "[$tag]" +
+ (if (throwable != null) " (${throwable::class.qualifiedName}: \"${throwable.message}\")" else "") +
+ (if (message != null) " $message" else "")
+
+ val stackInfo = Throwable().stackTrace[1]
+ val className = stackInfo.className.substringAfterLast(".")
+ val methodName = stackInfo.methodName
+ val lineNumber = stackInfo.lineNumber
+
+ when (level) {
+ // Log verbose content directly with Android's logger to not let this
+ // end up in AndroidAPS log files, which otherwise would quickly become
+ // very big, since verbose logging produces a lot of material.
+ LogLevel.VERBOSE -> Log.v(tag, message, throwable)
+
+ LogLevel.DEBUG -> aapsLogger.debug(className, methodName, lineNumber, ltag, fullMessage)
+ LogLevel.INFO -> aapsLogger.info(className, methodName, lineNumber, ltag, fullMessage)
+ LogLevel.WARN -> aapsLogger.warn(className, methodName, lineNumber, ltag, fullMessage)
+ LogLevel.ERROR -> aapsLogger.error(className, methodName, lineNumber, ltag, fullMessage)
+ }
+ }
+}
diff --git a/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/ComboV2Fragment.kt b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/ComboV2Fragment.kt
new file mode 100644
index 0000000000..c003b4d67c
--- /dev/null
+++ b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/ComboV2Fragment.kt
@@ -0,0 +1,303 @@
+package info.nightscout.androidaps.plugins.pump.combov2
+
+import android.graphics.Color
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.databinding.DataBindingUtil
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import dagger.android.support.DaggerFragment
+import info.nightscout.androidaps.interfaces.CommandQueue
+import info.nightscout.androidaps.interfaces.ResourceHelper
+import info.nightscout.androidaps.combov2.R
+import info.nightscout.androidaps.combov2.databinding.Combov2FragmentBinding
+import info.nightscout.comboctl.base.NullDisplayFrame
+import info.nightscout.comboctl.main.Pump as ComboCtlPump
+import info.nightscout.comboctl.base.Tbr as ComboCtlTbr
+import info.nightscout.comboctl.parser.BatteryState
+import info.nightscout.comboctl.parser.ReservoirState
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import java.util.Locale
+import javax.inject.Inject
+import kotlin.math.max
+
+class ComboV2Fragment : DaggerFragment() {
+ @Inject lateinit var combov2Plugin: ComboV2Plugin
+ @Inject lateinit var rh: ResourceHelper
+ @Inject lateinit var commandQueue: CommandQueue
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ val binding: Combov2FragmentBinding = DataBindingUtil.inflate(
+ inflater, R.layout.combov2_fragment, container, false)
+ val view = binding.root
+
+ binding.combov2RefreshButton.setOnClickListener {
+ binding.combov2RefreshButton.isEnabled = false
+ commandQueue.readStatus(rh.gs(R.string.user_request), null)
+ }
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ // Start all of these flows with repeatOnLifecycle()
+ // which will automatically cancel the flows when
+ // the lifecycle reaches the STOPPED stage and
+ // re-runs the lambda (as a suspended function)
+ // when the lifecycle reaches the STARTED stage.
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ combov2Plugin.pairedStateUIFlow
+ .onEach { isPaired ->
+ binding.combov2FragmentUnpairedUi.visibility = if (isPaired) View.GONE else View.VISIBLE
+ binding.combov2FragmentMainUi.visibility = if (isPaired) View.VISIBLE else View.GONE
+ }
+ .launchIn(this)
+
+ combov2Plugin.driverStateUIFlow
+ .onEach { connectionState ->
+ val text = when (connectionState) {
+ ComboV2Plugin.DriverState.NotInitialized -> rh.gs(R.string.combov2_not_initialized)
+ ComboV2Plugin.DriverState.Disconnected -> rh.gs(R.string.disconnected)
+ ComboV2Plugin.DriverState.Connecting -> rh.gs(R.string.connecting)
+ ComboV2Plugin.DriverState.CheckingPump -> rh.gs(R.string.combov2_checking_pump)
+ ComboV2Plugin.DriverState.Ready -> rh.gs(R.string.combov2_ready)
+ ComboV2Plugin.DriverState.Suspended -> rh.gs(R.string.combov2_suspended)
+ ComboV2Plugin.DriverState.Error -> rh.gs(R.string.error)
+ is ComboV2Plugin.DriverState.ExecutingCommand ->
+ when (val desc = connectionState.description) {
+ is ComboCtlPump.GettingBasalProfileCommandDesc ->
+ rh.gs(R.string.combov2_getting_basal_profile_cmddesc)
+ is ComboCtlPump.SettingBasalProfileCommandDesc ->
+ rh.gs(R.string.combov2_setting_basal_profile_cmddesc)
+ is ComboCtlPump.SettingTbrCommandDesc ->
+ if (desc.percentage != 100)
+ rh.gs(R.string.combov2_setting_tbr_cmddesc, desc.percentage, desc.durationInMinutes)
+ else
+ rh.gs(R.string.combov2_cancelling_tbr)
+ is ComboCtlPump.DeliveringBolusCommandDesc ->
+ rh.gs(R.string.combov2_delivering_bolus_cmddesc, desc.bolusAmount.cctlBolusToIU())
+ is ComboCtlPump.FetchingTDDHistoryCommandDesc ->
+ rh.gs(R.string.combov2_fetching_tdd_history_cmddesc)
+ is ComboCtlPump.UpdatingPumpDateTimeCommandDesc ->
+ rh.gs(R.string.combov2_updating_pump_datetime_cmddesc)
+ is ComboCtlPump.UpdatingPumpStatusCommandDesc ->
+ rh.gs(R.string.combov2_updating_pump_status_cmddesc)
+ else -> rh.gs(R.string.combov2_executing_command)
+ }
+ }
+ binding.combov2DriverState.text = text
+
+ binding.combov2RefreshButton.isEnabled = when (connectionState) {
+ ComboV2Plugin.DriverState.Disconnected,
+ ComboV2Plugin.DriverState.Suspended -> true
+ else -> false
+ }
+
+ binding.combov2DriverState.setTextColor(
+ when (connectionState) {
+ ComboV2Plugin.DriverState.Error -> Color.RED
+ ComboV2Plugin.DriverState.Suspended -> Color.YELLOW
+ else -> Color.WHITE
+ }
+ )
+ }
+ .launchIn(this)
+
+ combov2Plugin.lastConnectionTimestampUIFlow
+ .onEach { lastConnectionTimestamp ->
+ updateLastConnectionField(lastConnectionTimestamp, binding)
+ }
+ .launchIn(this)
+
+ // This "Activity" is not to be confused with Android's "Activity" class.
+ combov2Plugin.currentActivityUIFlow
+ .onEach { currentActivity ->
+ if (currentActivity.description.isEmpty()) {
+ binding.combov2CurrentActivityDesc.text = rh.gs(R.string.combov2_no_activity)
+ binding.combov2CurrentActivityProgress.progress = 0
+ } else {
+ binding.combov2CurrentActivityDesc.text = currentActivity.description
+ binding.combov2CurrentActivityProgress.progress = (currentActivity.overallProgress * 100.0).toInt()
+ }
+ }
+ .launchIn(this)
+
+ combov2Plugin.batteryStateUIFlow
+ .onEach { batteryState ->
+ when (batteryState) {
+ null -> binding.combov2Battery.text = ""
+ BatteryState.NO_BATTERY -> {
+ binding.combov2Battery.text = rh.gs(R.string.combov2_battery_empty_indicator)
+ binding.combov2Battery.setTextColor(Color.RED)
+ }
+ BatteryState.LOW_BATTERY -> {
+ binding.combov2Battery.text = rh.gs(R.string.combov2_battery_low_indicator)
+ binding.combov2Battery.setTextColor(Color.YELLOW)
+ }
+ BatteryState.FULL_BATTERY -> {
+ binding.combov2Battery.text = rh.gs(R.string.combov2_battery_full_indicator)
+ binding.combov2Battery.setTextColor(Color.WHITE)
+ }
+ }
+ }
+ .launchIn(this)
+
+ combov2Plugin.reservoirLevelUIFlow
+ .onEach { reservoirLevel ->
+ binding.combov2Reservoir.text = if (reservoirLevel != null)
+ "${reservoirLevel.availableUnits} ${rh.gs(R.string.insulin_unit_shortname)}"
+ else
+ ""
+
+ binding.combov2Reservoir.setTextColor(
+ when (reservoirLevel?.state) {
+ null -> Color.WHITE
+ ReservoirState.EMPTY -> Color.RED
+ ReservoirState.LOW -> Color.YELLOW
+ ReservoirState.FULL -> Color.WHITE
+ }
+ )
+ }
+ .launchIn(this)
+
+ combov2Plugin.lastBolusUIFlow
+ .onEach { lastBolus ->
+ updateLastBolusField(lastBolus, binding)
+ }
+ .launchIn(this)
+
+ combov2Plugin.currentTbrUIFlow
+ .onEach { tbr ->
+ updateCurrentTbrField(tbr, binding)
+ }
+ .launchIn(this)
+
+ combov2Plugin.baseBasalRateUIFlow
+ .onEach { baseBasalRate ->
+ binding.combov2BaseBasalRate.text = if (baseBasalRate != null)
+ rh.gs(R.string.pump_basebasalrate, baseBasalRate)
+ else
+ ""
+ }
+ .launchIn(this)
+
+ combov2Plugin.serialNumberUIFlow
+ .onEach { serialNumber ->
+ binding.combov2PumpId.text = serialNumber
+ }
+ .launchIn(this)
+
+ combov2Plugin.bluetoothAddressUIFlow
+ .onEach { bluetoothAddress ->
+ binding.combov2BluetoothAddress.text = bluetoothAddress.uppercase(Locale.ROOT)
+ }
+ .launchIn(this)
+
+ combov2Plugin.displayFrameUIFlow
+ .onEach { displayFrame ->
+ binding.combov2RtDisplayFrame.displayFrame = displayFrame ?: NullDisplayFrame
+ }
+ .launchIn(this)
+
+ launch {
+ while (true) {
+ delay(30 * 1000L) // Wait for 30 seconds
+ updateLastConnectionField(combov2Plugin.lastConnectionTimestampUIFlow.value, binding)
+ updateLastBolusField(combov2Plugin.lastBolusUIFlow.value, binding)
+ updateCurrentTbrField(combov2Plugin.currentTbrUIFlow.value, binding)
+ }
+ }
+ }
+ }
+
+ return view
+ }
+
+ private fun updateLastConnectionField(lastConnectionTimestamp: Long?, binding: Combov2FragmentBinding) {
+ val currentTimestamp = System.currentTimeMillis()
+
+ // If the last connection is >= 30 minutes ago,
+ // we display a different message, one that
+ // warns the user that a long time passed
+ when (val secondsPassed = lastConnectionTimestamp?.let { (currentTimestamp - it) / 1000 }) {
+ null ->
+ binding.combov2LastConnection.text = ""
+
+ in 0..60 -> {
+ binding.combov2LastConnection.text = rh.gs(R.string.combov2_less_than_one_minute_ago)
+ binding.combov2LastConnection.setTextColor(Color.WHITE)
+ }
+
+ in 60..(30 * 60) -> {
+ binding.combov2LastConnection.text = rh.gs(info.nightscout.androidaps.core.R.string.minago, secondsPassed / 60)
+ binding.combov2LastConnection.setTextColor(Color.WHITE)
+ }
+
+ else -> {
+ binding.combov2LastConnection.text = rh.gs(R.string.combov2_no_connection_for_n_mins, secondsPassed / 60)
+ binding.combov2LastConnection.setTextColor(Color.RED)
+ }
+ }
+ }
+
+ private fun updateLastBolusField(lastBolus: ComboCtlPump.LastBolus?, binding: Combov2FragmentBinding) {
+ val currentTimestamp = System.currentTimeMillis()
+
+ if (lastBolus == null) {
+ binding.combov2LastBolus.text = ""
+ return
+ }
+
+ // If the last bolus is >= 30 minutes ago,
+ // we display a different message, one that
+ // warns the user that a long time passed
+ val bolusAgoText = when (val secondsPassed = (currentTimestamp - lastBolus.timestamp.toEpochMilliseconds()) / 1000) {
+ in 60..(30 * 60) ->
+ rh.gs(R.string.combov2_less_than_one_minute_ago)
+
+ else ->
+ rh.gs(info.nightscout.androidaps.core.R.string.minago, secondsPassed / 60)
+ }
+
+ binding.combov2LastBolus.text =
+ rh.gs(
+ R.string.combov2_last_bolus,
+ lastBolus.bolusAmount.cctlBolusToIU(),
+ rh.gs(R.string.insulin_unit_shortname),
+ bolusAgoText
+ )
+ }
+
+ private fun updateCurrentTbrField(currentTbr: ComboCtlTbr?, binding: Combov2FragmentBinding) {
+ val currentTimestamp = System.currentTimeMillis()
+
+ if (currentTbr == null) {
+ binding.combov2CurrentTbr.text = ""
+ return
+ }
+
+ val remainingSeconds = max(
+ currentTbr.durationInMinutes * 60 - (currentTimestamp - currentTbr.timestamp.toEpochMilliseconds()) / 1000,
+ 0
+ )
+
+ binding.combov2CurrentTbr.text =
+ if (remainingSeconds >= 60)
+ rh.gs(
+ R.string.combov2_current_tbr,
+ currentTbr.percentage,
+ remainingSeconds / 60
+ )
+ else
+ rh.gs(
+ R.string.combov2_current_tbr_less_than_1min,
+ currentTbr.percentage
+ )
+ }
+}
diff --git a/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/ComboV2Plugin.kt b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/ComboV2Plugin.kt
new file mode 100644
index 0000000000..74329b229a
--- /dev/null
+++ b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/ComboV2Plugin.kt
@@ -0,0 +1,2007 @@
+package info.nightscout.androidaps.plugins.pump.combov2
+
+import android.content.Context
+import android.content.Intent
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.coroutineScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.preference.Preference
+import androidx.preference.PreferenceFragmentCompat
+import androidx.preference.SwitchPreference
+import dagger.android.HasAndroidInjector
+import info.nightscout.androidaps.combov2.R
+import info.nightscout.androidaps.data.DetailedBolusInfo
+import info.nightscout.androidaps.data.PumpEnactResult
+import info.nightscout.androidaps.events.EventPumpStatusChanged
+import info.nightscout.androidaps.events.EventRefreshOverview
+import info.nightscout.androidaps.extensions.convertedToAbsolute
+import info.nightscout.androidaps.extensions.plannedRemainingMinutes
+import info.nightscout.androidaps.extensions.toStringFull
+import info.nightscout.androidaps.interfaces.CommandQueue
+import info.nightscout.androidaps.interfaces.Constraint
+import info.nightscout.androidaps.interfaces.Constraints
+import info.nightscout.androidaps.interfaces.PluginDescription
+import info.nightscout.androidaps.interfaces.PluginType
+import info.nightscout.androidaps.interfaces.Profile
+import info.nightscout.androidaps.interfaces.ProfileFunction
+import info.nightscout.androidaps.interfaces.Pump
+import info.nightscout.androidaps.interfaces.PumpDescription
+import info.nightscout.androidaps.interfaces.PumpPluginBase
+import info.nightscout.androidaps.interfaces.PumpSync
+import info.nightscout.androidaps.interfaces.ResourceHelper
+import info.nightscout.androidaps.plugins.bus.RxBus
+import info.nightscout.androidaps.plugins.common.ManufacturerType
+import info.nightscout.androidaps.plugins.general.overview.events.EventDismissNotification
+import info.nightscout.androidaps.plugins.general.overview.events.EventNewNotification
+import info.nightscout.androidaps.plugins.general.overview.events.EventOverviewBolusProgress
+import info.nightscout.androidaps.plugins.general.overview.events.EventOverviewBolusProgress.Treatment
+import info.nightscout.androidaps.plugins.general.overview.notifications.Notification
+import info.nightscout.androidaps.plugins.pump.common.defs.PumpType
+import info.nightscout.androidaps.utils.DateUtil
+import info.nightscout.androidaps.utils.DecimalFormatter
+import info.nightscout.androidaps.utils.TimeChangeType
+import info.nightscout.androidaps.utils.ToastUtils
+import info.nightscout.androidaps.utils.alertDialogs.OKDialog
+import info.nightscout.comboctl.android.AndroidBluetoothInterface
+import info.nightscout.comboctl.base.BasicProgressStage
+import info.nightscout.comboctl.base.BluetoothAddress as ComboCtlBluetoothAddress
+import info.nightscout.comboctl.base.BluetoothException
+import info.nightscout.comboctl.base.ComboException
+import info.nightscout.comboctl.base.DisplayFrame
+import info.nightscout.comboctl.base.NullDisplayFrame
+import info.nightscout.comboctl.base.LogLevel as ComboCtlLogLevel
+import info.nightscout.comboctl.base.Logger as ComboCtlLogger
+import info.nightscout.comboctl.base.PairingPIN
+import info.nightscout.comboctl.main.Pump as ComboCtlPump
+import info.nightscout.comboctl.main.PumpManager as ComboCtlPumpManager
+import info.nightscout.comboctl.base.Tbr as ComboCtlTbr
+import info.nightscout.comboctl.main.BasalProfile
+import info.nightscout.comboctl.main.RTCommandProgressStage
+import info.nightscout.comboctl.parser.AlertScreenContent
+import info.nightscout.comboctl.parser.AlertScreenException
+import info.nightscout.comboctl.parser.BatteryState
+import info.nightscout.comboctl.parser.ReservoirState
+import info.nightscout.shared.logging.AAPSLogger
+import info.nightscout.shared.logging.LTag
+import info.nightscout.shared.sharedPreferences.SP
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.async
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.ClosedSendChannelException
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
+import org.joda.time.DateTime
+import org.json.JSONException
+import org.json.JSONObject
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.math.max
+import kotlin.math.roundToInt
+
+@Singleton
+class ComboV2Plugin @Inject constructor (
+ injector: HasAndroidInjector,
+ aapsLogger: AAPSLogger,
+ rh: ResourceHelper,
+ commandQueue: CommandQueue,
+ private val context: Context,
+ private val rxBus: RxBus,
+ private val constraintChecker: Constraints,
+ private val profileFunction: ProfileFunction,
+ private val sp: SP,
+ private val pumpSync: PumpSync,
+ private val dateUtil: DateUtil
+) :
+ PumpPluginBase(
+ PluginDescription()
+ .mainType(PluginType.PUMP)
+ .fragmentClass(ComboV2Fragment::class.java.name)
+ .pluginIcon(R.drawable.ic_combov2)
+ .pluginName(R.string.combov2_plugin_name)
+ .shortName(R.string.combov2_plugin_shortname)
+ .description(R.string.combov2_plugin_description)
+ .preferencesId(R.xml.pref_combov2),
+ injector,
+ aapsLogger,
+ rh,
+ commandQueue
+ ),
+ Pump,
+ Constraints {
+
+ // Coroutine scope and the associated job. All coroutines
+ // that are started in this plugin are part of this scope.
+ private val pumpCoroutineMainJob = SupervisorJob()
+ private val pumpCoroutineScope = CoroutineScope(Dispatchers.Default + pumpCoroutineMainJob)
+
+ private val _pumpDescription = PumpDescription()
+
+ private val pumpStateStore = SPPumpStateStore(sp)
+
+ // These are initialized in onStart() and torn down in onStop().
+ private var bluetoothInterface: AndroidBluetoothInterface? = null
+ private var pumpManager: ComboCtlPumpManager? = null
+
+ // These are initialized in connect() and torn down in disconnect().
+ private var pump: ComboCtlPump? = null
+ private var connectionSetupJob: Job? = null
+ private var stateAndStatusFlowsDeferred: Deferred? = null
+ private var pumpUIFlowsDeferred: Deferred? = null
+
+ // States for the Pump interface and for the UI.
+ private var pumpStatus: ComboCtlPump.Status? = null
+ private var lastConnectionTimestamp = 0L
+ private var lastComboAlert: AlertScreenContent? = null
+
+ // Set to true if a disconnect request came in while the driver
+ // was in the Connecting, CheckingPump, or ExecutingCommand
+ // state (in other words, while isBusy() was returning true).
+ private var disconnectRequestPending = false
+
+ // The current driver state. We use a StateFlow here to
+ // allow other components to react to state changes.
+ private val _driverStateFlow = MutableStateFlow(DriverState.NotInitialized)
+
+ // The basal profile that is set to be the pump's current profile.
+ // If the pump's actual basal profile deviates from this, it is
+ // overwritten. This check is performed in checkBasalProfile().
+ // In setNewBasalProfile(), this value is changed.
+ private var activeBasalProfile: BasalProfile? = null
+ // This is used for checking that the correct basal profile is
+ // active in the Combo. If not, loop invocation is disallowed.
+ // This is _not_ reset by disconect(). That's on purpose; it
+ // is read by isLoopInvocationAllowed(), which is called even
+ // if the pump is not connected.
+ private var lastActiveBasalProfileNumber: Int? = null
+
+ private var bolusJob: Job? = null
+
+ /*** Public functions and base class & interface overrides ***/
+
+ sealed class DriverState(val label: String) {
+ // Initial state when the driver is created.
+ object NotInitialized : DriverState("notInitialized")
+ // Driver is disconnected from the pump, or no pump
+ // is currently paired. In onStart() the driver state
+ // changes from NotInitialized to this.
+ object Disconnected : DriverState("disconnected")
+ // Driver is currently connecting to the pump. isBusy()
+ // will return true in this state.
+ object Connecting : DriverState("connecting")
+ // Driver is running checks on the pump, like verifying
+ // that the basal rate is OK, checking for any bolus
+ // and TBR activity that AAPS doesn't know about etc.
+ // isBusy() will return true in this state.
+ object CheckingPump : DriverState("checkingPump")
+ // Driver is connected and ready to execute commands.
+ object Ready : DriverState("ready")
+ // Driver is connected, but pump is suspended and
+ // cannot currently execute commands. This state is
+ // special in that it technically persists even after
+ // disconnecting. However, it is still important to
+ // model it as a driver state to prevent commands
+ // that deliver insulin from being executed (and,
+ // it is needed for the isSuspended() implementation).
+ // NOTE: Instead of comparing the driverStateFlow
+ // value with this state directly, consider using
+ // isSuspended() instead, since it is based on the
+ // driverStateUIFlow, and thus retains the Suspended
+ // and Error states even after disconnecting.
+ object Suspended : DriverState("suspended")
+ // Driver is currently executing a command.
+ // isBusy() will return true in this state.
+ class ExecutingCommand(val description: ComboCtlPump.CommandDescription) : DriverState("executingCommand")
+ object Error : DriverState("error")
+ }
+
+ val driverStateFlow = _driverStateFlow.asStateFlow()
+
+ // Used by ComboV2PairingActivity to launch its own
+ // custom activities that have a result.
+ var customDiscoveryActivityStartCallback: ((intent: Intent) -> Unit)?
+ set(value) { bluetoothInterface?.customDiscoveryActivityStartCallback = value }
+ get() = bluetoothInterface?.customDiscoveryActivityStartCallback
+
+ init {
+ ComboCtlLogger.backend = AAPSComboCtlLogger(aapsLogger)
+ updateComboCtlLogLevel()
+
+ _pumpDescription.fillFor(PumpType.ACCU_CHEK_COMBO)
+ }
+
+ override fun onStart() {
+ super.onStart()
+
+ aapsLogger.debug(LTag.PUMP, "Creating bluetooth interface")
+ bluetoothInterface = AndroidBluetoothInterface(context)
+
+ aapsLogger.debug(LTag.PUMP, "Setting up bluetooth interface")
+ bluetoothInterface!!.setup()
+
+ aapsLogger.debug(LTag.PUMP, "Setting up pump manager")
+ pumpManager = ComboCtlPumpManager(bluetoothInterface!!, pumpStateStore)
+ pumpManager!!.setup {
+ _pairedStateUIFlow.value = false
+ }
+
+ // UI flows that must have defined values right
+ // at start are initialized here.
+
+ // The paired state UI flow is special in that it is also
+ // used as the backing store for the isPaired() function,
+ // so setting up that UI state flow equals updating that
+ // paired state.
+ val paired = pumpManager!!.getPairedPumpAddresses().isNotEmpty()
+ _pairedStateUIFlow.value = paired
+
+ setDriverState(DriverState.Disconnected)
+ }
+
+ override fun onStop() {
+ pumpCoroutineScope.cancel()
+
+ runBlocking {
+ // Normally this should not happen, but to be safe,
+ // make sure any running pump instance is disconnected.
+ pump?.disconnect()
+ pump = null
+ }
+
+ pumpManager = null
+ bluetoothInterface?.teardown()
+ bluetoothInterface = null
+
+ setDriverState(DriverState.NotInitialized)
+
+ super.onStop()
+ }
+
+ override fun preprocessPreferences(preferenceFragment: PreferenceFragmentCompat) {
+ super.preprocessPreferences(preferenceFragment)
+
+ val verboseLoggingPreference = preferenceFragment.findPreference(rh.gs(R.string.key_combov2_verbose_logging))
+ verboseLoggingPreference?.setOnPreferenceChangeListener { _, newValue ->
+ updateComboCtlLogLevel(newValue as Boolean)
+ true
+ }
+
+ val unpairPumpPreference: Preference? = preferenceFragment.findPreference(rh.gs(R.string.key_combov2_unpair_pump))
+ unpairPumpPreference?.setOnPreferenceClickListener {
+ preferenceFragment.context?.let { ctx ->
+ OKDialog.showConfirmation(ctx, "Confirm pump unpairing", "Do you really want to unpair the pump?", ok = Runnable {
+ unpair()
+ })
+ }
+ false
+ }
+
+ // Setup coroutine to enable/disable the pair and unpair
+ // preferences depending on the pairing state.
+ preferenceFragment.run {
+ // TODO: Verify that the lifecycle and coroutinescope are correct here.
+ // We want to avoid duplicate coroutine launches and premature coroutine terminations.
+ // The viewLifecycle does not work here since this is called before onCreateView() is,
+ // and it is questionable whether the viewLifecycle is even the one to use - verify
+ // that lifecycle instead of viewLifecycle is the correct choice.
+ lifecycle.coroutineScope.launch {
+ lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ val pairPref: Preference? = findPreference(rh.gs(R.string.key_combov2_pair_with_pump))
+ val unpairPref: Preference? = findPreference(rh.gs(R.string.key_combov2_unpair_pump))
+
+ val isInitiallyPaired = pairedStateUIFlow.value
+ pairPref?.isEnabled = !isInitiallyPaired
+ unpairPref?.isEnabled = isInitiallyPaired
+
+ pairedStateUIFlow
+ .onEach { isPaired ->
+ pairPref?.isEnabled = !isPaired
+ unpairPref?.isEnabled = isPaired
+ }
+ .launchIn(this)
+ }
+ }
+ }
+ }
+
+ override fun isInitialized(): Boolean =
+ isPaired() && (driverStateFlow.value != DriverState.NotInitialized)
+
+ override fun isSuspended(): Boolean =
+ when (driverStateUIFlow.value) {
+ DriverState.Suspended,
+ DriverState.Error -> true
+ else -> false
+ }
+
+ override fun isBusy(): Boolean =
+ when (driverStateFlow.value) {
+ DriverState.Connecting,
+ DriverState.CheckingPump,
+ is DriverState.ExecutingCommand -> true
+ else -> false
+ }
+
+ override fun isConnected(): Boolean =
+ when (driverStateFlow.value) {
+ // NOTE: Even though the Combo is technically already connected by the
+ // time the DriverState.CheckingPump state is reached, do not return
+ // true then. That's because the pump still tries to issue commands
+ // during that state even though isBusy() returns true. Worse, it
+ // might try to call connect()!
+ // TODO: Check why this happens.
+ DriverState.Ready,
+ DriverState.Suspended,
+ is DriverState.ExecutingCommand -> true
+ else -> false
+ }
+
+ override fun isConnecting(): Boolean =
+ when (driverStateFlow.value) {
+ DriverState.Connecting,
+ DriverState.CheckingPump -> true
+ else -> false
+ }
+
+ // There is no corresponding indicator for this
+ // in Combo connections, so just return false
+ override fun isHandshakeInProgress() = false
+
+ override fun connect(reason: String) {
+ aapsLogger.debug(LTag.PUMP, "Connecting to Combo; reason: $reason")
+
+ when (driverStateFlow.value) {
+ DriverState.Connecting,
+ DriverState.CheckingPump,
+ DriverState.Ready,
+ DriverState.Suspended,
+ is DriverState.ExecutingCommand,
+ DriverState.Error -> {
+ aapsLogger.debug(
+ LTag.PUMP,
+ "Cannot connect while driver is in the ${driverStateFlow.value} state"
+ )
+ return
+ }
+ else -> Unit
+ }
+
+ if (!isPaired()) {
+ aapsLogger.debug(LTag.PUMP, "Cannot connect since no Combo has been paired")
+ return
+ }
+
+ // It makes no sense to reach this location with pump
+ // being non-null due to the checks above.
+ assert(pump == null)
+
+ lastComboAlert = null
+ pumpStatus = null
+
+ val bluetoothAddress = when (val address = getBluetoothAddress()) {
+ null -> {
+ aapsLogger.error(LTag.PUMP, "No Bluetooth address stored - pump state store may be corrupted")
+ unpairDueToPumpDataError()
+ return
+ }
+ else -> address
+ }
+
+ try {
+ runBlocking {
+ pump = pumpManager?.acquirePump(bluetoothAddress, activeBasalProfile) { event -> handlePumpEvent(event) }
+ }
+
+ if (pump == null) {
+ aapsLogger.error(LTag.PUMP, "Could not get pump instance - pump state store may be corrupted")
+ unpairDueToPumpDataError()
+ return
+ }
+
+ _bluetoothAddressUIFlow.value = bluetoothAddress.toString()
+ _serialNumberUIFlow.value = pumpManager!!.getPumpID(bluetoothAddress)
+
+ // Erase any display frame that may be left over from a previous connection.
+ @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
+ _displayFrameUIFlow.resetReplayCache()
+
+ stateAndStatusFlowsDeferred = pumpCoroutineScope.async {
+ coroutineScope {
+ pump!!.stateFlow
+ .onEach { pumpState ->
+ val driverState = when (pumpState) {
+ // The Disconnected pump state is ignored, since the Disconnected
+ // *driver* state is manually set anyway when disconnecting in
+ // in connect() and disconnectInternal(). Passing it to setDriverState()
+ // here would trigger an EventPumpStatusChanged event to be sent over
+ // the rxBus too early, potentially causing a situation where the connect()
+ // call isn't fully done yet, but the queue gets that event and thinks that
+ // it can try to reconnect now.
+ ComboCtlPump.State.Disconnected -> return@onEach
+ ComboCtlPump.State.Connecting -> DriverState.Connecting
+ ComboCtlPump.State.CheckingPump -> DriverState.CheckingPump
+ ComboCtlPump.State.ReadyForCommands -> DriverState.Ready
+ is ComboCtlPump.State.ExecutingCommand -> DriverState.ExecutingCommand(pumpState.description)
+ ComboCtlPump.State.Suspended -> DriverState.Suspended
+ is ComboCtlPump.State.Error -> DriverState.Error
+ }
+ setDriverState(driverState)
+ }
+ .launchIn(this)
+ pump!!.statusFlow
+ .onEach { newPumpStatus ->
+ if (newPumpStatus == null)
+ return@onEach
+
+ _batteryStateUIFlow.value = newPumpStatus.batteryState
+ _reservoirLevelUIFlow.value = ReservoirLevel(
+ newPumpStatus.reservoirState,
+ newPumpStatus.availableUnitsInReservoir
+ )
+
+ pumpStatus = newPumpStatus
+
+ // Send the EventRefreshOverview to keep the overview fragment's content
+ // up to date. Other actions like a CommandQueue.readStatus() call trigger
+ // such a refresh, but if the pump status is updated by something else,
+ // a refresh may not happen automatically. This event send call eliminates
+ // that possibility.
+ rxBus.send(EventRefreshOverview("ComboV2 pump status updated"))
+ }
+ .launchIn(this)
+ pump!!.lastBolusFlow
+ .onEach { lastBolus ->
+ if (lastBolus == null)
+ return@onEach
+
+ _lastBolusUIFlow.value = lastBolus
+ }
+ .launchIn(this)
+ pump!!.currentTbrFlow
+ .onEach { currentTbr ->
+ _currentTbrUIFlow.value = currentTbr
+ }
+ .launchIn(this)
+ }
+ }
+
+ setupUiFlows()
+
+ ////
+ // The actual connect procedure begins here.
+ ////
+
+ disconnectRequestPending = false
+ setDriverState(DriverState.Connecting)
+
+ connectionSetupJob = pumpCoroutineScope.launch {
+ var forciblyDisconnectDueToError = false
+
+ try {
+ pump?.connect()
+
+ // No need to set the driver state here, since the pump's stateFlow will announce that.
+
+ pump?.let {
+ // We can't read the active profile number in the suspended state, since
+ // the Combo's screen does not show any profile number then.
+ if (!isSuspended()) {
+ // Get the active basal profile number. If it is not profile #1, alert
+ // the user. We also keep a copy of that number to be able to disable
+ // loop invocation if this isn't profile #1 (see the implementation of
+ // isLoopInvocationAllowed() below).
+ val activeBasalProfileNumber = it.statusFlow.value?.activeBasalProfileNumber
+ aapsLogger.debug(LTag.PUMP, "Active basal profile number: $activeBasalProfileNumber")
+ if ((activeBasalProfileNumber != null) && (activeBasalProfileNumber != 1)) {
+ val notification = Notification(
+ Notification.COMBO_PUMP_ALARM,
+ text = rh.gs(R.string.combov2_incorrect_active_basal_profile, activeBasalProfileNumber),
+ level = Notification.URGENT
+ )
+ rxBus.send(EventNewNotification(notification))
+ }
+ lastActiveBasalProfileNumber = activeBasalProfileNumber
+ }
+
+ // Read the pump's basal profile to know later, when the loop attempts
+ // to set new profile, whether this procedure is redundant or now.
+ if (activeBasalProfile == null) {
+ aapsLogger.debug(
+ LTag.PUMP,
+ "No basal profile specified by pump queue (yet); using the basal profile that got read from the pump"
+ )
+ activeBasalProfile = it.currentBasalProfile
+ }
+ updateBaseBasalRateUI()
+ }
+ } catch (e: CancellationException) {
+ // In case of a cancellation, the Pump.connect() call
+ // rolls back any partially started connection and
+ // switches back to the disconnected state automatically.
+ // We just clean up our states here to reflect that the
+ // pump is already disconnected by this point.
+ disconnectRequestPending = false
+ setDriverState(DriverState.Disconnected)
+ // Re-throw to mark this coroutine as cancelled.
+ throw e
+ } catch (e: AlertScreenException) {
+ notifyAboutComboAlert(e.alertScreenContent)
+ forciblyDisconnectDueToError = true
+ } catch (e: Exception) {
+ val notification = Notification(
+ Notification.COMBO_PUMP_ALARM,
+ text = rh.gs(R.string.combov2_connection_error, e.message),
+ level = Notification.URGENT
+ )
+ rxBus.send(EventNewNotification(notification))
+
+ aapsLogger.error(LTag.PUMP, "Exception while connecting: ${e.stackTraceToString()}")
+
+ forciblyDisconnectDueToError = true
+ }
+
+ if (forciblyDisconnectDueToError) {
+ // In case of a connection failure, just disconnect. The command
+ // queue will retry after a while. Repeated failed attempts will
+ // eventually trigger a "pump unreachable" error message.
+ //
+ // Set this to null _before_ disconnecting, since
+ // disconnectInternal() tries to call cancelAndJoin()
+ // on connectionSetupJob, leading to a deadlock.
+ // connectionSetupJob is set to null further below
+ // as well before the executePendingDisconnect()
+ // call, for the same reason. This coroutine is
+ // close to ending anyway, and there won't be any
+ // coroutine suspension happening anymore, so there's
+ // no point in a such cancelAndJoin() call by now.
+ connectionSetupJob = null
+ disconnectInternal(forceDisconnect = true)
+
+ ToastUtils.showToastInUiThread(context, rh.gs(R.string.combov2_could_not_connect))
+ } else {
+ connectionSetupJob = null
+ // In case the pump queue issued a disconnect while the checks
+ // were running inside the connect() call above, do that
+ // postponed disconnect now. (The checks can take a long time
+ // if for example the pump's datetime deviates significantly
+ // from the system's current datetime.)
+ executePendingDisconnect()
+ }
+ }
+ } catch (e: Exception) {
+ aapsLogger.error(LTag.PUMP, "Connection failure: $e")
+ ToastUtils.showToastInUiThread(context, rh.gs(R.string.combov2_could_not_connect))
+ disconnectInternal(forceDisconnect = true)
+ }
+ }
+
+ override fun disconnect(reason: String) {
+ aapsLogger.debug(LTag.PUMP, "Disconnecting from Combo; reason: $reason")
+ disconnectInternal(forceDisconnect = false)
+ }
+
+ // This is called when (a) the AAPS watchdog is about to toggle
+ // Bluetooth (if permission is given by the user) and (b) when
+ // the command queue is being emptied. In both cases, the
+ // connection attempt must be stopped immediately, which is why
+ // forceDisconnect is set to true.
+ override fun stopConnecting() {
+ aapsLogger.debug(LTag.PUMP, "Stopping connect attempt by (forcibly) disconnecting")
+ disconnectInternal(forceDisconnect = true)
+ }
+
+ override fun getPumpStatus(reason: String) {
+ aapsLogger.debug(LTag.PUMP, "Getting pump status; reason: $reason")
+
+ lastComboAlert = null
+
+ runBlocking {
+ try {
+ executeCommand {
+ pump?.updateStatus()
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (ignored: Exception) {
+ }
+ }
+
+ // State and status are automatically updated via the associated flows.
+ }
+
+ override fun setNewBasalProfile(profile: Profile): PumpEnactResult {
+ if (!isInitialized()) {
+ aapsLogger.error(LTag.PUMP, "Cannot set profile since driver is not initialized")
+
+ val notification = Notification(
+ Notification.PROFILE_NOT_SET_NOT_INITIALIZED,
+ rh.gs(R.string.pumpNotInitializedProfileNotSet),
+ Notification.URGENT
+ )
+ rxBus.send(EventNewNotification(notification))
+
+ return PumpEnactResult(injector).apply {
+ success = false
+ enacted = false
+ comment = rh.gs(R.string.pumpNotInitializedProfileNotSet)
+ }
+ }
+
+ rxBus.send(EventDismissNotification(Notification.PROFILE_NOT_SET_NOT_INITIALIZED))
+ rxBus.send(EventDismissNotification(Notification.FAILED_UPDATE_PROFILE))
+
+ val pumpEnactResult = PumpEnactResult(injector)
+
+ val requestedBasalProfile = profile.toComboCtlBasalProfile()
+ aapsLogger.debug(LTag.PUMP, "Basal profile to set: $requestedBasalProfile")
+
+ runBlocking {
+ try {
+ executeCommand {
+ if (pump!!.setBasalProfile(requestedBasalProfile)) {
+ aapsLogger.debug(LTag.PUMP, "Basal profiles are different; new profile set")
+ activeBasalProfile = requestedBasalProfile
+ updateBaseBasalRateUI()
+
+ val notification = Notification(
+ Notification.PROFILE_SET_OK,
+ rh.gs(R.string.profile_set_ok),
+ Notification.INFO,
+ 60
+ )
+ rxBus.send(EventNewNotification(notification))
+
+ pumpEnactResult.apply {
+ success = true
+ enacted = true
+ }
+ } else {
+ aapsLogger.debug(LTag.PUMP, "Basal profiles are equal; did not have to set anything")
+ pumpEnactResult.apply {
+ success = true
+ enacted = false
+ }
+ }
+ }
+ } catch (e: CancellationException) {
+ // Cancellation is not an error, but it also means
+ // that the profile update was not enacted.
+ pumpEnactResult.apply {
+ success = true
+ enacted = false
+ }
+ throw e
+ } catch (e: Exception) {
+ aapsLogger.error("Exception thrown during basal profile update: $e")
+
+ val notification = Notification(
+ Notification.FAILED_UPDATE_PROFILE,
+ rh.gs(R.string.failedupdatebasalprofile),
+ Notification.URGENT
+ )
+ rxBus.send(EventNewNotification(notification))
+
+ pumpEnactResult.apply {
+ success = false
+ enacted = false
+ comment = rh.gs(R.string.failedupdatebasalprofile)
+ }
+ }
+ }
+ return pumpEnactResult
+ }
+
+ override fun isThisProfileSet(profile: Profile): Boolean {
+ if (!isInitialized())
+ return true
+
+ return (activeBasalProfile == profile.toComboCtlBasalProfile())
+ }
+
+ override fun lastDataTime(): Long = lastConnectionTimestamp
+
+ override val baseBasalRate: Double
+ get() {
+ val currentHour = DateTime().hourOfDay().get()
+ return activeBasalProfile?.get(currentHour)?.cctlBasalToIU() ?: 0.0
+ }
+
+ override val reservoirLevel: Double
+ get() = pumpStatus?.availableUnitsInReservoir?.toDouble() ?: 0.0
+
+ override val batteryLevel: Int
+ // The Combo does not provide any numeric battery
+ // level, so we have to use some reasonable values
+ // based on the indicated battery state.
+ get() = when (pumpStatus?.batteryState) {
+ null,
+ BatteryState.NO_BATTERY -> 5
+ BatteryState.LOW_BATTERY -> 25
+ BatteryState.FULL_BATTERY -> 100
+ }
+
+ override fun deliverTreatment(detailedBolusInfo: DetailedBolusInfo): PumpEnactResult {
+ val oldInsulinAmount = detailedBolusInfo.insulin
+ detailedBolusInfo.insulin = constraintChecker
+ .applyBolusConstraints(Constraint(detailedBolusInfo.insulin))
+ .value()
+ aapsLogger.debug(
+ LTag.PUMP,
+ "Applied bolus constraints: old insulin amount: $oldInsulinAmount new: ${detailedBolusInfo.insulin}"
+ )
+
+ // Carbs are not allowed because the Combo does not record carbs.
+ // This is defined in the ACCU_CHEK_COMBO PumpType enum's
+ // pumpCapability field, so AndroidAPS is informed about this
+ // lack of carb storage capability. We therefore do not expect
+ // nonzero carbs here.
+ // (Also, a zero insulin value makes no sense when bolusing.)
+ require((detailedBolusInfo.insulin > 0) && (detailedBolusInfo.carbs <= 0.0)) { detailedBolusInfo.toString() }
+
+ val requestedBolusAmount = detailedBolusInfo.insulin.iuToCctlBolus()
+ val bolusReason = when (detailedBolusInfo.bolusType) {
+ DetailedBolusInfo.BolusType.NORMAL -> ComboCtlPump.StandardBolusReason.NORMAL
+ DetailedBolusInfo.BolusType.SMB -> ComboCtlPump.StandardBolusReason.SUPERBOLUS
+ DetailedBolusInfo.BolusType.PRIMING -> ComboCtlPump.StandardBolusReason.PRIMING_INFUSION_SET
+ }
+
+ val pumpEnactResult = PumpEnactResult(injector)
+ pumpEnactResult.success = false
+
+ // Set up initial bolus progress along with details that are invariant.
+ // FIXME: EventOverviewBolusProgress is a singleton purely for
+ // historical reasons and could be updated to be a regular
+ // class. So far, this hasn't been done, so we must use it
+ // like a singleton, at least for now.
+ EventOverviewBolusProgress.t = Treatment(
+ insulin = 0.0,
+ carbs = 0,
+ isSMB = detailedBolusInfo.bolusType === DetailedBolusInfo.BolusType.SMB,
+ id = detailedBolusInfo.id
+ )
+
+ val bolusProgressJob = pumpCoroutineScope.launch {
+ pump!!.bolusDeliveryProgressFlow
+ .collect { progressReport ->
+ when (progressReport.stage) {
+ is RTCommandProgressStage.DeliveringBolus -> {
+ val bolusingEvent = EventOverviewBolusProgress
+ bolusingEvent.percent = (progressReport.overallProgress * 100.0).toInt()
+ bolusingEvent.status = rh.gs(R.string.bolusdelivering, detailedBolusInfo.insulin)
+ rxBus.send(bolusingEvent)
+ }
+ BasicProgressStage.Finished -> {
+ val bolusingEvent = EventOverviewBolusProgress
+ bolusingEvent.percent = (progressReport.overallProgress * 100.0).toInt()
+ bolusingEvent.status = "Bolus finished, performing post-bolus checks"
+ rxBus.send(bolusingEvent)
+ }
+ else -> Unit
+ }
+ }
+ }
+
+ // Run the delivery in a sub-coroutine to be able
+ // to cancel it via stopBolusDelivering().
+ val newBolusJob = pumpCoroutineScope.async {
+ // Store a local reference to the Pump instance. "pump"
+ // is set to null in case of an error, because then,
+ // disconnectInternal() is called (which sets pump to null).
+ // However, we still need to access the last deliverd bolus
+ // from the pump's lastBolusFlow, even if an error happened.
+ // Solve this by storing this reference and accessing the
+ // lastBolusFlow through it.
+ val acquiredPump = pump!!
+
+ try {
+ executeCommand {
+ acquiredPump.deliverBolus(requestedBolusAmount, bolusReason)
+ }
+
+ reportFinishedBolus(rh.gs(R.string.bolusdelivered, detailedBolusInfo.insulin), pumpEnactResult, succeeded = true)
+
+ // TODO: Check that an alert sound and error dialog
+ // are produced if an exception was thrown that
+ // counts as an error
+ } catch (e: CancellationException) {
+ // Cancellation is not an error, but it also means
+ // that the profile update was not enacted.
+
+ reportFinishedBolus(R.string.combov2_bolus_cancelled, pumpEnactResult, succeeded = true)
+
+ // Rethrowing to finish coroutine cancellation.
+ throw e
+ } catch (e: ComboCtlPump.BolusCancelledByUserException) {
+ aapsLogger.info(LTag.PUMP, "Bolus cancelled via Combo CMD_CANCEL_BOLUS command")
+
+ // This exception is thrown when the bolus is cancelled
+ // through a cancel bolus command that was sent to the Combo,
+ // and not due to a coroutine cancellation. Like the
+ // CancellationException block above, this is not an
+ // error, hence the "success = true".
+
+ reportFinishedBolus(R.string.combov2_bolus_cancelled, pumpEnactResult, succeeded = true)
+ } catch (e: ComboCtlPump.BolusNotDeliveredException) {
+ aapsLogger.error(LTag.PUMP, "Bolus not delivered")
+ reportFinishedBolus(R.string.combov2_bolus_not_delivered, pumpEnactResult, succeeded = false)
+ } catch (e: ComboCtlPump.UnaccountedBolusDetectedException) {
+ aapsLogger.error(LTag.PUMP, "Unaccounted bolus detected")
+ reportFinishedBolus(R.string.combov2_unaccounted_bolus_detected_cancelling_bolus, pumpEnactResult, succeeded = false)
+ } catch (e: ComboCtlPump.InsufficientInsulinAvailableException) {
+ aapsLogger.error(LTag.PUMP, "Insufficient insulin in reservoir")
+ reportFinishedBolus(R.string.combov2_insufficient_insulin_in_reservoir, pumpEnactResult, succeeded = false)
+ } catch (e: Exception) {
+ aapsLogger.error(LTag.PUMP, "Exception thrown during bolus delivery: $e")
+ reportFinishedBolus(R.string.combov2_bolus_delivery_failed, pumpEnactResult, succeeded = false)
+ } finally {
+ // The delivery was enacted if even a partial amount was infused.
+ pumpEnactResult.enacted = acquiredPump.lastBolusFlow.value?.let { it.bolusAmount > 0 } ?: false
+ bolusJob = null
+ bolusProgressJob.cancelAndJoin()
+ }
+ }
+
+ bolusJob = newBolusJob
+
+ // Do a blocking wait until the bolus coroutine completes or is cancelled.
+ // AndroidAPS expects deliverTreatment() calls to block and to be cancellable
+ // (via stopBolusDelivering()), so we run a separate bolus coroutine and
+ // wait here until it is done.
+ runBlocking {
+ try {
+ aapsLogger.debug(LTag.PUMP, "Waiting for bolus coroutine to finish")
+ newBolusJob.join()
+ aapsLogger.debug(LTag.PUMP, "Bolus coroutine finished")
+ } catch (_: CancellationException) {
+ aapsLogger.debug(LTag.PUMP, "Bolus coroutine was cancelled")
+ }
+ }
+
+ return pumpEnactResult
+ }
+
+ override fun stopBolusDelivering() {
+ aapsLogger.debug(LTag.PUMP, "Stopping bolus delivery")
+ runBlocking {
+ bolusJob?.cancelAndJoin()
+ bolusJob = null
+ }
+ aapsLogger.debug(LTag.PUMP, "Bolus delivery stopped")
+ }
+
+ override fun setTempBasalAbsolute(absoluteRate: Double, durationInMinutes: Int, profile: Profile, enforceNew: Boolean, tbrType: PumpSync.TemporaryBasalType): PumpEnactResult {
+ val pumpEnactResult = PumpEnactResult(injector)
+ pumpEnactResult.isPercent = false
+
+ // Corner case: Current base basal rate is 0 IU. We cannot do
+ // anything then, otherwise we get into a division by zero below
+ // when converting absoluteRate to a percentage.
+ if (baseBasalRate == 0.0) {
+ pumpEnactResult.apply {
+ success = false
+ enacted = false
+ comment = rh.gs(R.string.combov2_cannot_set_absolute_tbr_if_basal_zero)
+ }
+ return pumpEnactResult
+ }
+
+ // The Combo cannot handle absolute rates directly.
+ // We have to convert it to a percentage instead,
+ // and the percentage must be an integer multiple
+ // of 10, otherwise the Combo won't accept it.
+
+ val percentage = (absoluteRate / baseBasalRate * 100).toInt()
+ val roundedPercentage = (absoluteRate / baseBasalRate * 10).roundToInt() * 10
+
+ if (percentage != roundedPercentage)
+ aapsLogger.debug(LTag.PUMP, "Calculated percentage of $percentage% out of absolute rate $absoluteRate; rounded percentage to $roundedPercentage%")
+ else
+ aapsLogger.debug(LTag.PUMP, "Calculated percentage of $percentage% out of absolute rate $absoluteRate")
+
+ val cctlTbrType = when (tbrType) {
+ PumpSync.TemporaryBasalType.NORMAL -> ComboCtlTbr.Type.NORMAL
+ PumpSync.TemporaryBasalType.EMULATED_PUMP_SUSPEND -> ComboCtlTbr.Type.EMULATED_COMBO_STOP
+ PumpSync.TemporaryBasalType.PUMP_SUSPEND -> ComboCtlTbr.Type.COMBO_STOPPED // TODO: Can this happen? It is currently not allowed by ComboCtlPump.setTbr()
+ PumpSync.TemporaryBasalType.SUPERBOLUS -> ComboCtlTbr.Type.SUPERBOLUS
+ }
+
+ setTbrInternal(roundedPercentage, durationInMinutes, cctlTbrType, force100Percent = false, pumpEnactResult)
+
+ return pumpEnactResult
+ }
+
+ override fun setTempBasalPercent(percent: Int, durationInMinutes: Int, profile: Profile, enforceNew: Boolean, tbrType: PumpSync.TemporaryBasalType): PumpEnactResult {
+ val pumpEnactResult = PumpEnactResult(injector)
+ pumpEnactResult.isPercent = true
+
+ val adjustedPercentage = percent
+ .let {
+ if (it > _pumpDescription.maxTempPercent) {
+ aapsLogger.debug(
+ LTag.PUMP,
+ "Reducing requested TBR to the maximum support " +
+ "by the pump: $percent -> ${_pumpDescription.maxTempPercent}"
+ )
+ _pumpDescription.maxTempPercent
+ } else
+ it
+ }
+ .let {
+ val roundedPercentage = ((it + 5) / 10) * 10
+ if (roundedPercentage != it) {
+ aapsLogger.debug(LTag.PUMP, "Rounded requested percentage:$it -> $roundedPercentage")
+ roundedPercentage
+ } else
+ it
+ }
+
+ val cctlTbrType = when (tbrType) {
+ PumpSync.TemporaryBasalType.NORMAL -> ComboCtlTbr.Type.NORMAL
+ PumpSync.TemporaryBasalType.EMULATED_PUMP_SUSPEND -> ComboCtlTbr.Type.EMULATED_COMBO_STOP
+ PumpSync.TemporaryBasalType.PUMP_SUSPEND -> ComboCtlTbr.Type.COMBO_STOPPED // TODO: Can this happen? It is currently not allowed by ComboCtlPump.setTbr()
+ PumpSync.TemporaryBasalType.SUPERBOLUS -> ComboCtlTbr.Type.SUPERBOLUS
+ }
+
+ setTbrInternal(adjustedPercentage, durationInMinutes, cctlTbrType, force100Percent = false, pumpEnactResult)
+
+ return pumpEnactResult
+ }
+
+ override fun cancelTempBasal(enforceNew: Boolean): PumpEnactResult {
+ // TODO: Check if some of the additional checks in ComboPlugin.cancelTempBasal can be carried over here.
+ // Note that ComboCtlPump.setTbr itself checks the TBR that is actually active after setting the TBR
+ // is done, and throws exceptions when there's a mismatch. It considers mismatches as an error, unlike
+ // the ComboPlugin.cancelTempBasal code, which just sets enact to false when there's a mismatch.
+
+ val pumpEnactResult = PumpEnactResult(injector)
+ pumpEnactResult.isPercent = true
+ pumpEnactResult.isTempCancel = enforceNew
+ setTbrInternal(100, 0, tbrType = ComboCtlTbr.Type.NORMAL, force100Percent = enforceNew, pumpEnactResult)
+ return pumpEnactResult
+ }
+
+ private fun setTbrInternal(percentage: Int, durationInMinutes: Int, tbrType: ComboCtlTbr.Type, force100Percent: Boolean, pumpEnactResult: PumpEnactResult) {
+ runBlocking {
+ try {
+ executeCommand {
+ pump!!.setTbr(percentage, durationInMinutes, tbrType, force100Percent)
+ }
+
+ pumpEnactResult.apply {
+ success = true
+ enacted = true
+ comment = rh.gs(R.string.combov2_setting_tbr_succeeded)
+ }
+ } catch (e: ComboCtlPump.UnexpectedTbrStateException) {
+ aapsLogger.error(LTag.PUMP, "Setting TBR failed with exception: $e")
+ pumpEnactResult.apply {
+ success = false
+ enacted = false
+ comment = rh.gs(R.string.combov2_setting_tbr_failed)
+ }
+ } catch (e: Exception) {
+ aapsLogger.error(LTag.PUMP, "Setting TBR failed with exception: $e")
+ pumpEnactResult.apply {
+ success = false
+ enacted = false
+ comment = rh.gs(R.string.combov2_setting_tbr_failed)
+ }
+ }
+ }
+ }
+
+ // It is currently not known how to program an extended bolus into the Combo.
+ // Until that is reverse engineered, inform callers that we can't handle this.
+
+ override fun setExtendedBolus(insulin: Double, durationInMinutes: Int): PumpEnactResult =
+ createFailurePumpEnactResult(R.string.combov2_extended_bolus_not_supported)
+
+ override fun cancelExtendedBolus(): PumpEnactResult =
+ createFailurePumpEnactResult(R.string.combov2_extended_bolus_not_supported)
+
+ override fun getJSONStatus(profile: Profile, profileName: String, version: String): JSONObject {
+ if (!isInitialized())
+ return JSONObject()
+
+ val now = dateUtil.now()
+ if ((lastConnectionTimestamp != 0L) && ((now - lastConnectionTimestamp) > 60 * 60 * 1000)) {
+ return JSONObject()
+ }
+ val pumpJson = JSONObject()
+
+ try {
+ pumpJson.apply {
+ put("clock", dateUtil.toISOString(now))
+ // NOTE: This is called "status" because this is what the
+ // Nightscout pump plugin API schema expects. It is not to
+ // be confused with the "status" in the ComboCtl Pump class.
+ // See the Nightscout /devicestatus/ API docs for more.
+ put("status", JSONObject().apply {
+ val driverState = driverStateFlow.value
+ val suspended = isSuspended()
+ val bolusing = (driverState is DriverState.ExecutingCommand) &&
+ (driverState.description is ComboCtlPump.DeliveringBolusCommandDesc)
+ put("status", driverState.label)
+ put("suspended", suspended)
+ put("bolusing", bolusing)
+ put("timestamp", dateUtil.toISOString(lastConnectionTimestamp))
+ })
+ pumpStatus?.let {
+ // Battery level is set inside this let-block as well. Even though
+ // batteryLevel is not a direct pumpStatus member, it is a property
+ // that *does* access pumpStatus (with null check).
+ put("battery", JSONObject().apply {
+ put("percent", batteryLevel)
+ })
+ put("reservoir", it.availableUnitsInReservoir)
+ } ?: aapsLogger.info(
+ LTag.PUMP,
+ "Cannot include reservoir level in JSON status " +
+ "since no such level is currently known"
+ )
+ put("extended", JSONObject().apply {
+ put("Version", version)
+ lastBolusUIFlow.value?.let {
+ put("LastBolus", dateUtil.dateAndTimeString(it.timestamp.toEpochMilliseconds()))
+ put("LastBolusAmount", it.bolusAmount.cctlBolusToIU())
+ }
+ val tb = pumpSync.expectedPumpState().temporaryBasal
+ tb?.let {
+ put("TempBasalAbsoluteRate", tb.convertedToAbsolute(now, profile))
+ put("TempBasalStart", dateUtil.dateAndTimeString(tb.timestamp))
+ put("TempBasalRemaining", tb.plannedRemainingMinutes)
+ }
+ if (activeBasalProfile != null)
+ put("BaseBasalRate", baseBasalRate)
+ else
+ aapsLogger.info(
+ LTag.PUMP,
+ "Cannot include base basal rate in JSON status " +
+ "since no basal profile is currently active"
+ )
+ try {
+ // TODO: What about the profileName argument?
+ // Is it obsolete?
+ put("ActiveProfile", profileFunction.getProfileName())
+ } catch (e: Exception) {
+ aapsLogger.error("Unhandled exception", e)
+ }
+ when (val alert = lastComboAlert) {
+ is AlertScreenContent.Warning ->
+ put("WarningCode", alert.code)
+ is AlertScreenContent.Error ->
+ put("ErrorCode", alert.code)
+ else -> Unit
+ }
+ })
+ }
+ } catch (e: JSONException) {
+ aapsLogger.error(LTag.PUMP, "Unhandled JSON exception", e)
+ }
+ aapsLogger.info(LTag.PUMP, "Produced pump JSON status: $pumpJson")
+
+ return pumpJson
+ }
+
+ override fun manufacturer() = ManufacturerType.Roche
+
+ override fun model() = PumpType.ACCU_CHEK_COMBO
+
+ override fun serialNumber(): String {
+ val bluetoothAddress = getBluetoothAddress()
+ return if ((bluetoothAddress != null) && (pumpManager != null))
+ pumpManager!!.getPumpID(bluetoothAddress)
+ else
+ rh.gs(R.string.combov2_not_paired)
+ }
+
+ override val pumpDescription: PumpDescription
+ get() = _pumpDescription
+
+ override fun shortStatus(veryShort: Boolean): String {
+ var ret = ""
+ if (lastConnectionTimestamp != 0L) {
+ val agoMsec: Long = System.currentTimeMillis() - lastConnectionTimestamp
+ val agoMin = (agoMsec / 60.0 / 1000.0).toInt()
+ ret += rh.gs(R.string.combov2_short_status_last_connection, agoMin) + "\n"
+ }
+
+ val alertCodeString = when (val alert = lastComboAlert) {
+ is AlertScreenContent.Warning -> "W${alert.code}"
+ is AlertScreenContent.Error -> "E${alert.code}"
+ else -> null
+ }
+ if (alertCodeString != null)
+ ret += rh.gs(R.string.combov2_short_status_alert, alertCodeString) + "\n"
+
+ lastBolusUIFlow.value?.let {
+ val localBolusTimestamp = it.timestamp.toLocalDateTime(TimeZone.currentSystemDefault())
+ ret += rh.gs(
+ R.string.combov2_short_status_last_bolus, DecimalFormatter.to2Decimal(it.bolusAmount.cctlBolusToIU()),
+ String.format("%02d:%02d", localBolusTimestamp.hour, localBolusTimestamp.minute)
+ ) + "\n"
+ }
+
+ val temporaryBasal = pumpSync.expectedPumpState().temporaryBasal
+ temporaryBasal?.let {
+ ret += rh.gs(
+ R.string.combov2_short_status_temp_basal,
+ it.toStringFull(dateUtil)
+ ) + "\n"
+ }
+
+ pumpStatus?.let {
+ ret += rh.gs(
+ R.string.combov2_short_status_reservoir,
+ it.availableUnitsInReservoir
+ ) + "\n"
+ val batteryStateDesc = when (it.batteryState) {
+ BatteryState.NO_BATTERY -> rh.gs(R.string.combov2_short_status_battery_state_empty)
+ BatteryState.LOW_BATTERY -> rh.gs(R.string.combov2_short_status_battery_state_low)
+ BatteryState.FULL_BATTERY -> rh.gs(R.string.combov2_short_status_battery_state_full)
+ }
+ ret += rh.gs(
+ R.string.combov2_short_status_battery_state,
+ batteryStateDesc
+ ) + "\n"
+ }
+
+ return ret
+ }
+
+ override val isFakingTempsByExtendedBoluses = false
+
+ override fun loadTDDs(): PumpEnactResult {
+ val pumpEnactResult = PumpEnactResult(injector)
+
+ runBlocking {
+ try {
+ // Map key = timestamp; value = TDD
+ val tddMap = mutableMapOf()
+
+ executeCommand {
+ val tddHistory = pump!!.fetchTDDHistory()
+
+ tddHistory
+ .filter { it.totalDailyAmount >= 1 }
+ .forEach { tddHistoryEntry ->
+ val timestamp = tddHistoryEntry.date.toEpochMilliseconds()
+ tddMap[timestamp] = (tddMap[timestamp] ?: 0) + tddHistoryEntry.totalDailyAmount
+ }
+ }
+
+ for (tddEntry in tddMap) {
+ val timestamp = tddEntry.key
+ val totalDailyAmount = tddEntry.value
+
+ pumpSync.createOrUpdateTotalDailyDose(
+ timestamp,
+ bolusAmount = 0.0,
+ basalAmount = 0.0,
+ totalAmount = totalDailyAmount.cctlBasalToIU(),
+ pumpId = null,
+ pumpType = PumpType.ACCU_CHEK_COMBO,
+ pumpSerial = serialNumber()
+ )
+ }
+
+ pumpEnactResult.apply {
+ success = true
+ enacted = true
+ }
+ } catch (e: CancellationException) {
+ pumpEnactResult.apply {
+ success = true
+ enacted = false
+ comment = rh.gs(R.string.combov2_load_tdds_cancelled)
+ }
+ throw e
+ } catch (e: Exception) {
+ aapsLogger.error("Exception thrown during TDD retrieval: $e")
+
+ pumpEnactResult.apply {
+ success = false
+ enacted = false
+ comment = rh.gs(R.string.combov2_retrieving_tdds_failed)
+ }
+ }
+ }
+
+ return pumpEnactResult
+ }
+
+ override fun canHandleDST() = true
+
+ override fun timezoneOrDSTChanged(timeChangeType: TimeChangeType) {
+ // Currently just logging this; the ComboCtl.Pump code will set the new datetime
+ // (as localtime) as part of the on-connect checks automatically.
+ // TODO: It may be useful to do this here, since setting the datetime takes
+ // a while with the Combo. It has to be done via the RT mode, which is slow.
+ aapsLogger.info(LTag.PUMP, "Time, Date and/or TimeZone changed. Time change type = $timeChangeType")
+ }
+
+ /*** Loop constraints ***/
+ // These restrict the function of the loop in case of an event
+ // that makes running a loop too risky, for example because something
+ // went wrong while bolusing, or because the incorrect basal profile
+ // was found to be active.
+
+ override fun isLoopInvocationAllowed(value: Constraint): Constraint {
+ if (!isSuspended() && (lastActiveBasalProfileNumber != null)) {
+ val isAllowed = (lastActiveBasalProfileNumber == 1)
+ aapsLogger.info(
+ LTag.PUMP,
+ "Currently active basal profile: $lastActiveBasalProfileNumber -> loop invocation allowed: $isAllowed"
+ )
+
+ if (!isAllowed) {
+ value.set(
+ aapsLogger,
+ false,
+ rh.gs(R.string.combov2_incorrect_active_basal_profile, lastActiveBasalProfileNumber),
+ this
+ )
+ }
+ } else {
+ aapsLogger.info(
+ LTag.PUMP,
+ "Cannot currently determine which basal profile is active in the pump"
+ )
+ // We don't disallow the invocation in this case since the only reasons for lastActiveBasalProfileNumber
+ // being null are (1) we are in the initial, uninitialized state (in which case looping won't
+ // work anyway) and (2) the pump is currently suspended (which already will not allow for looping).
+ }
+
+ return value
+ }
+
+ /*** Pairing API ***/
+
+ fun getPairingProgressFlow() =
+ pumpManager?.pairingProgressFlow ?: throw IllegalStateException("Attempting access uninitialized pump manager")
+
+ fun resetPairingProgress() = pumpManager?.resetPairingProgress()
+
+ private val _previousPairingAttemptFailedFlow = MutableStateFlow(false)
+ val previousPairingAttemptFailedFlow = _previousPairingAttemptFailedFlow.asStateFlow()
+
+ private var pairingJob: Job? = null
+ private var pairingPINChannel: Channel? = null
+
+ fun startPairing() {
+ val discoveryDuration = sp.getInt(R.string.key_combov2_discovery_duration, 300)
+
+ val newPINChannel = Channel(capacity = Channel.RENDEZVOUS)
+ pairingPINChannel = newPINChannel
+
+ _previousPairingAttemptFailedFlow.value = false
+
+ // Update the log level here in case the user changed it.
+ updateComboCtlLogLevel()
+
+ pairingJob = pumpCoroutineScope.async {
+ try {
+ val pairingResult = pumpManager?.pairWithNewPump(discoveryDuration) { newPumpAddress, previousAttemptFailed ->
+ aapsLogger.info(
+ LTag.PUMP,
+ "New pairing PIN request from Combo pump with Bluetooth " +
+ "address $newPumpAddress (previous attempt failed: $previousAttemptFailed)"
+ )
+ _previousPairingAttemptFailedFlow.value = previousAttemptFailed
+ newPINChannel.receive()
+ } ?: throw IllegalStateException("Attempting to access uninitialized pump manager")
+
+ if (pairingResult !is ComboCtlPumpManager.PairingResult.Success)
+ return@async
+
+ _pairedStateUIFlow.value = true
+
+ // Notify AndroidAPS that this is a new pump and that
+ // the history that is associated with any previously
+ // paired pump is to be discarded.
+ pumpSync.connectNewPump()
+
+ // Schedule a status update, since pairing can take
+ // a while. By the time we reach this point, the queue
+ // connection attempt may have reached the timeout,
+ // and reading the status is part of what AndroidAPS
+ // was trying to do, so do that now.
+ // If we reach this point before the timeout, then the
+ // queue will contain a pump_driver_changed readstatus
+ // command already. The queue will see that and ignore
+ // this readStatus() call automatically.
+ commandQueue.readStatus(rh.gs(R.string.pump_paired), null)
+ } finally {
+ pairingJob = null
+ pairingPINChannel?.close()
+ pairingPINChannel = null
+ }
+ }
+ }
+
+ fun cancelPairing() {
+ runBlocking {
+ aapsLogger.debug(LTag.PUMP, "Cancelling pairing")
+ pairingJob?.cancelAndJoin()
+ aapsLogger.debug(LTag.PUMP, "Pairing cancelled")
+ }
+ }
+
+ suspend fun providePairingPIN(pairingPIN: PairingPIN) {
+ try {
+ pairingPINChannel?.send(pairingPIN)
+ } catch (ignored: ClosedSendChannelException) {
+ }
+ }
+
+ fun unpair() {
+ val bluetoothAddress = getBluetoothAddress() ?: return
+
+ disconnectInternal(forceDisconnect = true)
+
+ runBlocking {
+ try {
+ val pump = pumpManager?.acquirePump(bluetoothAddress) ?: return@runBlocking
+ pump.unpair()
+ pumpManager?.releasePump(bluetoothAddress)
+ } catch (ignored: ComboException) {
+ } catch (ignored: BluetoothException) {
+ }
+ }
+
+ // Reset the UI flows that are associated with the pump
+ // that just got unpaired to prevent the UI from showing
+ // information about that now-unpaired pump anymore.
+ _currentActivityUIFlow.value = noCurrentActivity()
+ _lastConnectionTimestampUIFlow.value = null
+ _batteryStateUIFlow.value = null
+ _reservoirLevelUIFlow.value = null
+ _lastBolusUIFlow.value = null
+ _baseBasalRateUIFlow.value = null
+ _serialNumberUIFlow.value = ""
+ _bluetoothAddressUIFlow.value = ""
+ }
+
+
+ /*** User interface flows ***/
+
+ // "UI flows" are hot flows that are meant to be used for showing
+ // information about the pump and its current state on the UI.
+ // These are kept in the actual plugin class to make sure they
+ // are always available, even if no pump is paired (which means
+ // that the "pump" variable is set to null and thus its flows
+ // are inaccessible).
+ //
+ // A few UI flows are internally also used for other checks, such
+ // as pairedStateUIFlow (which is used internally to verify whether
+ // or not the pump is paired).
+ //
+ // Some UI flows are nullable and have a null initial state to
+ // indicate to UIs that they haven't been filled with actual
+ // state yet.
+
+ // This is a variant of driverStateFlow that retains the Error
+ // and Suspended state even after disconnecting to make sure these
+ // states kept being showed to the user post-disconnect.
+ private val _driverStateUIFlow = MutableStateFlow(DriverState.NotInitialized)
+ val driverStateUIFlow = _driverStateUIFlow.asStateFlow()
+
+ // "Activity" is not to be confused with the Android Activity class.
+ // An "activity" is something that a command does, for example
+ // establishing a BT connection, or delivering a bolus, setting
+ // a basal rate factor, reading the current pump datetime etc.
+ data class CurrentActivityInfo(val description: String, val overallProgress: Double)
+ private fun noCurrentActivity() = CurrentActivityInfo("", 0.0)
+ private var _currentActivityUIFlow = MutableStateFlow(noCurrentActivity())
+ val currentActivityUIFlow = _currentActivityUIFlow.asStateFlow()
+
+ private var _lastConnectionTimestampUIFlow = MutableStateFlow(null)
+ val lastConnectionTimestampUIFlow = _lastConnectionTimestampUIFlow.asStateFlow()
+
+ private var _batteryStateUIFlow = MutableStateFlow(null)
+ val batteryStateUIFlow = _batteryStateUIFlow.asStateFlow()
+
+ data class ReservoirLevel(val state: ReservoirState, val availableUnits: Int)
+ private var _reservoirLevelUIFlow = MutableStateFlow(null)
+ val reservoirLevelUIFlow = _reservoirLevelUIFlow.asStateFlow()
+
+ private var _lastBolusUIFlow = MutableStateFlow(null)
+ val lastBolusUIFlow = _lastBolusUIFlow.asStateFlow()
+
+ private var _currentTbrUIFlow = MutableStateFlow(null)
+ val currentTbrUIFlow = _currentTbrUIFlow.asStateFlow()
+
+ private var _baseBasalRateUIFlow = MutableStateFlow(null)
+ val baseBasalRateUIFlow = _baseBasalRateUIFlow.asStateFlow()
+
+ private var _serialNumberUIFlow = MutableStateFlow("")
+ val serialNumberUIFlow = _serialNumberUIFlow.asStateFlow()
+
+ private var _bluetoothAddressUIFlow = MutableStateFlow("")
+ val bluetoothAddressUIFlow = _bluetoothAddressUIFlow.asStateFlow()
+
+ private var _pairedStateUIFlow = MutableStateFlow(false)
+ val pairedStateUIFlow = _pairedStateUIFlow.asStateFlow()
+
+ // UI flow to show the current RT display frame on the UI. Unlike
+ // the other UI flows, this is a SharedFlow, not a StateFlow,
+ // since frames aren't "states", and StateFlow filters out duplicates
+ // (which isn't useful in this case). The flow is configured such
+ // that it never suspends; if its replay cache contains a frame already,
+ // that older frame is overwritten. This makes sure the flow always
+ // contains the current frame.
+ private var _displayFrameUIFlow = MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
+ val displayFrameUIFlow = _displayFrameUIFlow.asSharedFlow()
+
+
+ /*** Misc private functions ***/
+
+ private fun setupUiFlows() {
+ pumpUIFlowsDeferred = pumpCoroutineScope.async {
+ try {
+ coroutineScope {
+ pump!!.connectProgressFlow
+ .onEach { progressReport ->
+ val description = when (val progStage = progressReport.stage) {
+ is BasicProgressStage.EstablishingBtConnection ->
+ rh.gs(
+ R.string.combov2_establishing_bt_connection,
+ progStage.currentAttemptNr
+ )
+ BasicProgressStage.PerformingConnectionHandshake -> rh.gs(R.string.combov2_pairing_performing_handshake)
+ else -> ""
+ }
+ _currentActivityUIFlow.value = CurrentActivityInfo(
+ description,
+ progressReport.overallProgress
+ )
+ }
+ .launchIn(this)
+
+ pump!!.setDateTimeProgressFlow
+ .onEach { progressReport ->
+ val description = when (progressReport.stage) {
+ RTCommandProgressStage.SettingDateTimeHour,
+ RTCommandProgressStage.SettingDateTimeMinute -> rh.gs(R.string.combov2_setting_current_pump_time)
+ RTCommandProgressStage.SettingDateTimeYear,
+ RTCommandProgressStage.SettingDateTimeMonth,
+ RTCommandProgressStage.SettingDateTimeDay -> rh.gs(R.string.combov2_setting_current_pump_date)
+ else -> ""
+ }
+ _currentActivityUIFlow.value = CurrentActivityInfo(
+ description,
+ progressReport.overallProgress
+ )
+ }
+ .launchIn(this)
+
+ pump!!.getBasalProfileFlow
+ .onEach { progressReport ->
+ val description = when (val stage = progressReport.stage) {
+ is RTCommandProgressStage.GettingBasalProfile ->
+ rh.gs(R.string.combov2_getting_basal_profile, stage.numSetFactors)
+ else -> ""
+ }
+ _currentActivityUIFlow.value = CurrentActivityInfo(
+ description,
+ progressReport.overallProgress
+ )
+ }
+ .launchIn(this)
+
+ pump!!.setBasalProfileFlow
+ .onEach { progressReport ->
+ val description = when (val stage = progressReport.stage) {
+ is RTCommandProgressStage.SettingBasalProfile ->
+ rh.gs(R.string.combov2_setting_basal_profile, stage.numSetFactors)
+ else -> ""
+ }
+ _currentActivityUIFlow.value = CurrentActivityInfo(
+ description,
+ progressReport.overallProgress
+ )
+ }
+ .launchIn(this)
+
+ pump!!.bolusDeliveryProgressFlow
+ .onEach { progressReport ->
+ val description = when (val stage = progressReport.stage) {
+ is RTCommandProgressStage.DeliveringBolus ->
+ rh.gs(
+ R.string.combov2_delivering_bolus,
+ stage.deliveredAmount.cctlBolusToIU(),
+ stage.totalAmount.cctlBolusToIU()
+ )
+ else -> ""
+ }
+ _currentActivityUIFlow.value = CurrentActivityInfo(
+ description,
+ progressReport.overallProgress
+ )
+ }
+ .launchIn(this)
+
+ pump!!.parsedDisplayFrameFlow
+ .onEach { parsedDisplayFrame ->
+ _displayFrameUIFlow.emit(
+ parsedDisplayFrame?.displayFrame ?: NullDisplayFrame
+ )
+ }
+ .launchIn(this)
+
+ launch {
+ while (true) {
+ updateBaseBasalRateUI()
+ val currentMinute = DateTime().minuteOfHour().get()
+
+ // Calculate how many minutes need to pass until we
+ // reach the next hour and thus the next basal profile
+ // factor becomes active. That way, the amount of UI
+ // refreshes is minimized.
+ // We cap the max waiting period to 58 minutes instead
+ // of 60 to allow for a small tolerance range for cases
+ // when this loop iterates exactly when the current hour
+ // is about to turn.
+ val minutesUntilNextFactor = max((58 - currentMinute), 0)
+ delay(minutesUntilNextFactor * 60 * 1000L)
+ }
+ }
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ aapsLogger.error(LTag.PUMP, "Exception thrown in UI flows coroutine scope: $e")
+ throw e
+ }
+ }
+ }
+
+ private fun updateBaseBasalRateUI() {
+ val currentHour = DateTime().hourOfDay().get()
+ // This sets value to null if no profile is set,
+ // which keeps the base basal rate on the UI blank.
+ _baseBasalRateUIFlow.value = activeBasalProfile?.get(currentHour)?.cctlBasalToIU()
+ }
+
+ private fun handlePumpEvent(event: ComboCtlPump.Event) {
+ aapsLogger.debug(LTag.PUMP, "Handling pump event $event")
+
+ when (event) {
+ is ComboCtlPump.Event.BatteryLow -> {
+ val notification = Notification(
+ Notification.COMBO_PUMP_ALARM,
+ text = rh.gs(R.string.combov2_battery_low_warning),
+ level = Notification.NORMAL
+ )
+ rxBus.send(EventNewNotification(notification))
+ }
+
+ is ComboCtlPump.Event.ReservoirLow -> {
+ val notification = Notification(
+ Notification.COMBO_PUMP_ALARM,
+ text = rh.gs(R.string.combov2_reservoir_low_warning),
+ level = Notification.NORMAL
+ )
+ rxBus.send(EventNewNotification(notification))
+ }
+
+ is ComboCtlPump.Event.QuickBolusInfused -> {
+ pumpSync.syncBolusWithPumpId(
+ event.timestamp.toEpochMilliseconds(),
+ event.bolusAmount.cctlBolusToIU(),
+ DetailedBolusInfo.BolusType.NORMAL,
+ event.bolusId,
+ PumpType.ACCU_CHEK_COMBO,
+ serialNumber()
+ )
+ }
+
+ is ComboCtlPump.Event.StandardBolusInfused -> {
+ val bolusType = when (event.standardBolusReason) {
+ ComboCtlPump.StandardBolusReason.NORMAL -> DetailedBolusInfo.BolusType.NORMAL
+ ComboCtlPump.StandardBolusReason.SUPERBOLUS -> DetailedBolusInfo.BolusType.SMB
+ ComboCtlPump.StandardBolusReason.PRIMING_INFUSION_SET -> DetailedBolusInfo.BolusType.PRIMING
+ }
+ pumpSync.syncBolusWithPumpId(
+ event.timestamp.toEpochMilliseconds(),
+ event.bolusAmount.cctlBolusToIU(),
+ bolusType,
+ event.bolusId,
+ PumpType.ACCU_CHEK_COMBO,
+ serialNumber()
+ )
+ }
+
+ is ComboCtlPump.Event.ExtendedBolusStarted -> {
+ pumpSync.syncExtendedBolusWithPumpId(
+ event.timestamp.toEpochMilliseconds(),
+ event.totalBolusAmount.cctlBolusToIU(),
+ event.totalDurationMinutes.toLong() * 60 * 1000,
+ false,
+ event.bolusId,
+ PumpType.ACCU_CHEK_COMBO,
+ serialNumber()
+ )
+ }
+
+ is ComboCtlPump.Event.ExtendedBolusEnded -> {
+ pumpSync.syncStopExtendedBolusWithPumpId(
+ event.timestamp.toEpochMilliseconds(),
+ event.bolusId,
+ PumpType.ACCU_CHEK_COMBO,
+ serialNumber()
+ )
+ }
+
+ is ComboCtlPump.Event.TbrStarted -> {
+ aapsLogger.debug(LTag.PUMP, "Pump reports TBR started; expected state according to AAPS: ${pumpSync.expectedPumpState()}")
+ val tbrStartTimestampInMs = event.tbr.timestamp.toEpochMilliseconds()
+ val tbrType = when (event.tbr.type) {
+ ComboCtlTbr.Type.NORMAL -> PumpSync.TemporaryBasalType.NORMAL
+ ComboCtlTbr.Type.EMULATED_100_PERCENT -> PumpSync.TemporaryBasalType.NORMAL
+ ComboCtlTbr.Type.SUPERBOLUS -> PumpSync.TemporaryBasalType.SUPERBOLUS
+ ComboCtlTbr.Type.EMULATED_COMBO_STOP -> PumpSync.TemporaryBasalType.EMULATED_PUMP_SUSPEND
+ ComboCtlTbr.Type.COMBO_STOPPED -> PumpSync.TemporaryBasalType.PUMP_SUSPEND
+ }
+ pumpSync.syncTemporaryBasalWithPumpId(
+ timestamp = tbrStartTimestampInMs,
+ rate = event.tbr.percentage.toDouble(),
+ duration = event.tbr.durationInMinutes.toLong() * 60 * 1000,
+ isAbsolute = false,
+ type = tbrType,
+ pumpId = tbrStartTimestampInMs,
+ pumpType = PumpType.ACCU_CHEK_COMBO,
+ pumpSerial = serialNumber()
+ )
+ }
+
+ is ComboCtlPump.Event.TbrEnded -> {
+ aapsLogger.debug(LTag.PUMP, "Pump reports TBR ended; expected state according to AAPS: ${pumpSync.expectedPumpState()}")
+ val tbrEndTimestampInMs = event.timestampWhenTbrEnded.toEpochMilliseconds()
+ pumpSync.syncStopTemporaryBasalWithPumpId(
+ timestamp = tbrEndTimestampInMs,
+ endPumpId = tbrEndTimestampInMs,
+ pumpType = PumpType.ACCU_CHEK_COMBO,
+ pumpSerial = serialNumber()
+ )
+ }
+
+ is ComboCtlPump.Event.UnknownTbrDetected -> {
+ // Inform about this unknown TBR that was observed (and automatically aborted).
+ val remainingDurationString = String.format(
+ "%02d:%02d",
+ event.remainingTbrDurationInMinutes / 60,
+ event.remainingTbrDurationInMinutes % 60
+ )
+ val notification = Notification(
+ Notification.COMBO_UNKNOWN_TBR,
+ text = rh.gs(
+ R.string.combov2_unknown_tbr_detected,
+ event.tbrPercentage,
+ remainingDurationString
+ ),
+ level = Notification.URGENT
+ )
+ rxBus.send(EventNewNotification(notification))
+ }
+
+ else -> Unit
+ }
+ }
+
+ // Marked as synchronized since this may get called by a finishing
+ // connect operation and by the command queue at the same time.
+ @Synchronized private fun disconnectInternal(forceDisconnect: Boolean) {
+ // Sometimes, the CommandQueue may decide to call disconnect while the
+ // driver is still busy with something, for example because some checks
+ // are being performed. Ignore disconnect requests in that case, unless
+ // the forceDisconnect flag is set.
+ if (!forceDisconnect && isBusy()) {
+ disconnectRequestPending = true
+ aapsLogger.debug(LTag.PUMP, "Ignoring disconnect request since driver is currently busy")
+ return
+ }
+
+ if (isDisconnected()) {
+ aapsLogger.debug(LTag.PUMP, "Already disconnected")
+ return
+ }
+
+ // It makes no sense to reach this location with pump
+ // being null due to the checks above.
+ assert(pump != null)
+
+ // Run these operations in a coroutine to be able to wait
+ // until the disconnect really completes and the UI flows
+ // are all cancelled & their coroutines finished. Otherwise
+ // we can end up with race conditions because the coroutines
+ // are still ongoing in the background.
+ runBlocking {
+ // Disconnecting the pump needs to be done in one of two
+ // ways, depending on whether we try to disconnect while
+ // the pump is in the Connecting state or not:
+ //
+ // 1. Pump is in the Connecting state. A disconnectInternal()
+ // call then means that we are aborting the ongoing connect
+ // attempt. Internally, the pump may be waiting for a blocking
+ // Bluetooth device connect procedure to complete.
+ // 2. Pump is past the Connecting state. The blocking connect
+ // procedure is already over.
+ //
+ // In case #1, the internal IO loops inside the pump are not
+ // yet running. Also, connectionSetupJob.join() won't finish
+ // because of the blocking connect procedure. In this case,
+ // cancel that coroutine/Job, but don't join yet. Cancel,
+ // then disconnect the pump, then join. That way, the blocking
+ // Bluetooth connect procedure is aborted (closing a Bluetooth
+ // socket usually does that), the connectionSetupJob is unblocked,
+ // it can be canceled, and join() can finish. Since there is no
+ // IO coroutine running, there won't be any IO errors when
+ // disconnecting before joining connectionSetupJob.
+ //
+ // In case #2, the internal IO loops inside the pump *are*
+ // running, so disconnecting before joining is risky. Therefore,
+ // in this case, do cancel *and* join connectionSetupJob before
+ // actually disconnecting the pump. Otherwise, errors occur, since
+ // the connection setup code will try to communicate even though
+ // the Pump.disconnect() call shuts down the RFCOMM socket,
+ // making all send/receive calls fail.
+
+ if (pump!!.stateFlow.value == ComboCtlPump.State.Connecting) {
+ // Case #1 from above
+ aapsLogger.debug(LTag.PUMP, "Cancelling ongoing connect attempt")
+ connectionSetupJob?.cancel()
+ pump?.disconnect()
+ connectionSetupJob?.join()
+ } else {
+ // Case #2 from above
+ aapsLogger.debug(LTag.PUMP, "Disconnecting Combo (if not disconnected already by a cancelling request)")
+ connectionSetupJob?.cancelAndJoin()
+ pump?.disconnect()
+ }
+
+ aapsLogger.debug(LTag.PUMP, "Combo disconnected; cancelling UI flows coroutine")
+ pumpUIFlowsDeferred?.cancelAndJoin()
+ aapsLogger.debug(LTag.PUMP, "Cancelling state and status flows coroutine")
+ stateAndStatusFlowsDeferred?.cancelAndJoin()
+ aapsLogger.debug(LTag.PUMP, "Releasing pump instance back to pump manager")
+
+ getBluetoothAddress()?.let { pumpManager?.releasePump(it) }
+ }
+
+ connectionSetupJob = null
+ pumpUIFlowsDeferred = null
+ stateAndStatusFlowsDeferred = null
+ pump = null
+
+ disconnectRequestPending = false
+
+ aapsLogger.debug(LTag.PUMP, "Combo disconnect complete")
+ setDriverState(DriverState.Disconnected)
+ }
+
+ private fun isPaired() = pairedStateUIFlow.value
+
+ private fun updateComboCtlLogLevel() =
+ updateComboCtlLogLevel(sp.getBoolean(R.string.key_combov2_verbose_logging, false))
+
+ private fun updateComboCtlLogLevel(enableVerbose: Boolean) {
+ aapsLogger.debug(LTag.PUMP, "${if (enableVerbose) "Enabling" else "Disabling"} verbose logging")
+ ComboCtlLogger.threshold = if (enableVerbose) ComboCtlLogLevel.VERBOSE else ComboCtlLogLevel.DEBUG
+ }
+
+ private fun setDriverState(newState: DriverState) {
+ val oldState = _driverStateFlow.value
+
+ if (oldState == newState)
+ return
+
+ // Update the last connection timestamp after executing a command.
+ // Other components like CommandReadStatus expect the lastDataTime()
+ // timestamp to be updated right after a command execution.
+ // As a special case, if all we did was to check the pump status,
+ // and afterwards disconnected, also update. The CheckingPump
+ // state masks multiple command executions.
+ if ((oldState is DriverState.ExecutingCommand) || ((oldState == DriverState.CheckingPump) && (newState == DriverState.Disconnected)))
+ updateLastConnectionTimestamp()
+
+ // If the pump is suspended, or if an error occurred, we want
+ // to show the "suspended" and "error" state labels on the UI
+ // even after disconnecting. Otherwise, the user may not see
+ // that an error occurred or the pump is suspended.
+ val updateUIState = when (newState) {
+ DriverState.Disconnected -> {
+ when (driverStateUIFlow.value) {
+ DriverState.Error,
+ DriverState.Suspended -> false
+ else -> true
+ }
+ }
+ else -> true
+ }
+ if (updateUIState) {
+ _driverStateUIFlow.value = newState
+
+ // Also show a notification to alert the user to the fact
+ // that the Combo is currently suspended, otherwise this
+ // only shows up in the Combo fragment.
+ if (newState == DriverState.Suspended) {
+ val notification = Notification(
+ Notification.COMBO_PUMP_SUSPENDED,
+ text = rh.gs(R.string.combov2_pump_is_suspended),
+ level = Notification.NORMAL
+ )
+ rxBus.send(EventNewNotification(notification))
+ }
+ }
+
+ _driverStateFlow.value = newState
+
+ if (newState == DriverState.Disconnected)
+ _currentActivityUIFlow.value = noCurrentActivity()
+
+ aapsLogger.info(LTag.PUMP, "Setting Combo driver state: old: $oldState new: $newState")
+
+ // TODO: Is it OK to send CONNECTED twice? It can happen when changing from Ready to Suspended.
+ when (newState) {
+ DriverState.Disconnected -> rxBus.send(EventPumpStatusChanged(EventPumpStatusChanged.Status.DISCONNECTED))
+ DriverState.Connecting -> rxBus.send(EventPumpStatusChanged(EventPumpStatusChanged.Status.CONNECTING))
+ DriverState.Ready,
+ DriverState.Suspended -> rxBus.send(EventPumpStatusChanged(EventPumpStatusChanged.Status.CONNECTED))
+ else -> Unit
+ }
+ }
+
+ private fun executePendingDisconnect() {
+ if (!disconnectRequestPending)
+ return
+
+ aapsLogger.debug(LTag.PUMP, "Executing pending disconnect request")
+ disconnectInternal(forceDisconnect = true)
+ }
+
+ private fun unpairDueToPumpDataError() {
+ disconnectInternal(forceDisconnect = true)
+ val notification = Notification(
+ id = Notification.PUMP_ERROR,
+ date = dateUtil.now(),
+ text = rh.gs(R.string.combov2_cannot_access_pump_data),
+ level = Notification.URGENT,
+ validTo = 0
+ )
+ rxBus.send(EventNewNotification(notification))
+ unpair()
+ }
+
+ // Utility function to run a ComboCtlPump command (deliverBolus for example)
+ // and do common checks afterwards (like handling AlertScreenException).
+ // IMPORTANT: This disconnects in case of an error, so if any other
+ // nontrivial procedure needs to be done for the command in case of an
+ // error, do this inside a try-finally block in the block.
+ private suspend fun executeCommand(
+ block: suspend CoroutineScope.() -> Unit
+ ) {
+ try {
+ coroutineScope {
+ block.invoke(this)
+ }
+
+ // The AAPS pump command queue may have asked for a disconnect
+ // while the command was being executed. Do this postponed
+ // disconnect now that we are done with the command.
+ executePendingDisconnect()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: AlertScreenException) {
+ lastComboAlert = e.alertScreenContent
+
+ notifyAboutComboAlert(e.alertScreenContent)
+
+ // Disconnect since we are now in the Error state.
+ disconnectInternal(forceDisconnect = true)
+
+ throw e
+ } catch (t: Throwable) {
+ // Disconnect since we are now in the Error state.
+ disconnectInternal(forceDisconnect = true)
+ throw t
+ }
+ }
+
+ private fun updateLastConnectionTimestamp() {
+ lastConnectionTimestamp = System.currentTimeMillis()
+ _lastConnectionTimestampUIFlow.value = lastConnectionTimestamp
+ }
+
+ private fun getAlertDescription(alert: AlertScreenContent) =
+ when (alert) {
+ is AlertScreenContent.Warning -> {
+ val desc = when (alert.code) {
+ 4 -> rh.gs(R.string.combov2_warning_4)
+ 10 -> rh.gs(R.string.combov2_warning_10)
+ else -> ""
+ }
+
+ "${rh.gs(R.string.combov2_warning)} W${alert.code}" +
+ if (desc.isEmpty()) "" else ": $desc"
+ }
+
+ is AlertScreenContent.Error -> {
+ val desc = when (alert.code) {
+ 1 -> rh.gs(R.string.combov2_error_1)
+ 2 -> rh.gs(R.string.combov2_error_2)
+ 4 -> rh.gs(R.string.combov2_error_4)
+ 5 -> rh.gs(R.string.combov2_error_5)
+ 6 -> rh.gs(R.string.combov2_error_6)
+ 7 -> rh.gs(R.string.combov2_error_7)
+ 8 -> rh.gs(R.string.combov2_error_8)
+ 9 -> rh.gs(R.string.combov2_error_9)
+ 10 -> rh.gs(R.string.combov2_error_10)
+ 11 -> rh.gs(R.string.combov2_error_11)
+ else -> ""
+ }
+
+ "${rh.gs(R.string.combov2_error)} E${alert.code}" +
+ if (desc.isEmpty()) "" else ": $desc"
+ }
+
+ else -> rh.gs(R.string.combov2_unrecognized_alert)
+ }
+
+ private fun notifyAboutComboAlert(alert: AlertScreenContent) {
+ val notification = Notification(
+ Notification.COMBO_PUMP_ALARM,
+ text = "${rh.gs(R.string.combov2_combo_alert)}: ${getAlertDescription(alert)}",
+ level = if (alert is AlertScreenContent.Warning) Notification.NORMAL else Notification.URGENT
+ )
+ rxBus.send(EventNewNotification(notification))
+ }
+
+ private fun reportFinishedBolus(status: String, pumpEnactResult: PumpEnactResult, succeeded: Boolean) {
+ val bolusingEvent = EventOverviewBolusProgress
+ bolusingEvent.status = status
+ bolusingEvent.percent = 100
+ rxBus.send(bolusingEvent)
+
+ pumpEnactResult.apply {
+ success = succeeded
+ comment = status
+ }
+ }
+
+ private fun reportFinishedBolus(stringId: Int, pumpEnactResult: PumpEnactResult, succeeded: Boolean) =
+ reportFinishedBolus(rh.gs(stringId), pumpEnactResult, succeeded)
+
+ private fun createFailurePumpEnactResult(comment: Int) =
+ PumpEnactResult(injector)
+ .success(false)
+ .enacted(false)
+ .comment(comment)
+
+ private fun getBluetoothAddress(): ComboCtlBluetoothAddress? =
+ pumpManager!!.getPairedPumpAddresses().firstOrNull()
+
+ private fun isDisconnected() =
+ when (driverStateFlow.value) {
+ DriverState.NotInitialized,
+ DriverState.Disconnected -> true
+ else -> false
+ }
+}
diff --git a/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/ComboV2RTDisplayFrameView.kt b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/ComboV2RTDisplayFrameView.kt
new file mode 100644
index 0000000000..51c0c9fee3
--- /dev/null
+++ b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/ComboV2RTDisplayFrameView.kt
@@ -0,0 +1,84 @@
+package info.nightscout.androidaps.plugins.pump.combov2
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Rect
+import android.util.AttributeSet
+import android.view.View
+import info.nightscout.comboctl.base.DISPLAY_FRAME_HEIGHT
+import info.nightscout.comboctl.base.DISPLAY_FRAME_WIDTH
+import info.nightscout.comboctl.base.DisplayFrame
+import info.nightscout.comboctl.base.NUM_DISPLAY_FRAME_PIXELS
+import info.nightscout.comboctl.base.NullDisplayFrame
+
+/**
+ * Custom [View] to show a Combo remote terminal display frame on the UI.
+ *
+ * The [DisplayFrame] is shown on the UI via [Canvas]. To do that, the frame
+ * is converted to a [Bitmap], and then that bitmap is rendered on the UI.
+ *
+ * Callers pass new frames to the view by setting the [displayFrame] property.
+ * The frame -> bitmap conversion happens on-demand, when [onDraw] is called.
+ * That way, if this view is not shown on the UI (because for example the
+ * associated fragment is not visible at the moment), no unnecessary conversions
+ * are performed, saving computational effort.
+ *
+ * The frame is drawn unsmoothed to better mimic the Combo's LCD.
+ */
+internal class ComboV2RTDisplayFrameView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : View(context, attrs, defStyleAttr) {
+ private companion object {
+ const val BACKGROUND_SHADE = 0xB5
+ const val FOREGROUND_SHADE = 0x20
+ }
+
+ private val bitmap = Bitmap.createBitmap(DISPLAY_FRAME_WIDTH, DISPLAY_FRAME_HEIGHT, Bitmap.Config.ARGB_8888, false)
+ private val bitmapPixels = IntArray(NUM_DISPLAY_FRAME_PIXELS) { BACKGROUND_SHADE }
+ private val bitmapPaint = Paint().apply {
+ style = Paint.Style.FILL
+ // These are necessary to ensure nearest neighbor scaling.
+ isAntiAlias = false
+ isFilterBitmap = false
+ }
+ private val bitmapRect = Rect()
+
+ private var isNewDisplayFrame = true
+ var displayFrame = NullDisplayFrame
+ set(value) {
+ field = value
+ isNewDisplayFrame = true
+ // Necessary to inform Android that during
+ // the next UI update it should call onDraw().
+ invalidate()
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ updateBitmap()
+ canvas.drawBitmap(bitmap, null, bitmapRect, bitmapPaint)
+ }
+
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
+ super.onSizeChanged(w, h, oldw, oldh)
+ bitmapRect.set(0, 0, w, h)
+ }
+
+ private fun updateBitmap() {
+ if (!isNewDisplayFrame)
+ return
+
+ for (pixelIdx in 0 until NUM_DISPLAY_FRAME_PIXELS) {
+ val srcPixel = if (displayFrame[pixelIdx]) FOREGROUND_SHADE else BACKGROUND_SHADE
+ bitmapPixels[pixelIdx] = Color.argb(0xFF, srcPixel, srcPixel, srcPixel)
+ }
+
+ bitmap.setPixels(bitmapPixels, 0, DISPLAY_FRAME_WIDTH, 0, 0, DISPLAY_FRAME_WIDTH, DISPLAY_FRAME_HEIGHT)
+
+ isNewDisplayFrame = false
+ }
+}
\ No newline at end of file
diff --git a/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/SPPumpStateStore.kt b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/SPPumpStateStore.kt
new file mode 100644
index 0000000000..95d9850766
--- /dev/null
+++ b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/SPPumpStateStore.kt
@@ -0,0 +1,165 @@
+package info.nightscout.androidaps.plugins.pump.combov2
+
+import info.nightscout.comboctl.base.BluetoothAddress
+import info.nightscout.comboctl.base.CurrentTbrState
+import info.nightscout.comboctl.base.InvariantPumpData
+import info.nightscout.comboctl.base.Nonce
+import info.nightscout.comboctl.base.PumpStateStore
+import info.nightscout.comboctl.base.Tbr
+import info.nightscout.comboctl.base.toBluetoothAddress
+import info.nightscout.comboctl.base.toCipher
+import info.nightscout.comboctl.base.toNonce
+import info.nightscout.shared.sharedPreferences.SP
+import info.nightscout.shared.sharedPreferences.SPDelegateInt
+import info.nightscout.shared.sharedPreferences.SPDelegateLong
+import info.nightscout.shared.sharedPreferences.SPDelegateString
+import kotlinx.datetime.Instant
+import kotlinx.datetime.UtcOffset
+
+/**
+ * AndroidAPS [SP] based pump state store.
+ *
+ * This store is set up to contain a single paired pump. AndroidAPS is not
+ * designed to handle multiple pumps, so this simplification makes sense.
+ * This affects all accessors, which
+ */
+class SPPumpStateStore(private val sp: SP) : PumpStateStore {
+ private var btAddress: String
+ by SPDelegateString(sp, BT_ADDRESS_KEY, "")
+
+ // The nonce is updated with commit instead of apply to make sure
+ // is atomically written to storage synchronously, minimizing
+ // the likelihood that it could be lost due to app crashes etc.
+ // It is very important to not lose the nonce, hence that choice.
+ private var nonceString: String
+ by SPDelegateString(sp, NONCE_KEY, Nonce.nullNonce().toString(), commit = true)
+
+ private var cpCipherString: String
+ by SPDelegateString(sp, CP_CIPHER_KEY, "")
+ private var pcCipherString: String
+ by SPDelegateString(sp, PC_CIPHER_KEY, "")
+ private var keyResponseAddressInt: Int
+ by SPDelegateInt(sp, KEY_RESPONSE_ADDRESS_KEY, 0)
+ private var pumpID: String
+ by SPDelegateString(sp, PUMP_ID_KEY, "")
+ private var tbrTimestamp: Long
+ by SPDelegateLong(sp, TBR_TIMESTAMP_KEY, 0)
+ private var tbrPercentage: Int
+ by SPDelegateInt(sp, TBR_PERCENTAGE_KEY, 0)
+ private var tbrDuration: Int
+ by SPDelegateInt(sp, TBR_DURATION_KEY, 0)
+ private var tbrType: String
+ by SPDelegateString(sp, TBR_TYPE_KEY, "")
+ private var utcOffsetSeconds: Int
+ by SPDelegateInt(sp, UTC_OFFSET_KEY, 0)
+
+ override fun createPumpState(
+ pumpAddress: BluetoothAddress,
+ invariantPumpData: InvariantPumpData,
+ utcOffset: UtcOffset,
+ tbrState: CurrentTbrState
+ ) {
+ // Write these values via edit() instead of using the delegates
+ // above to be able to write all of them with a single commit.
+ sp.edit(commit = true) {
+ putString(BT_ADDRESS_KEY, pumpAddress.toString().uppercase())
+ putString(CP_CIPHER_KEY, invariantPumpData.clientPumpCipher.toString())
+ putString(PC_CIPHER_KEY, invariantPumpData.pumpClientCipher.toString())
+ putInt(KEY_RESPONSE_ADDRESS_KEY, invariantPumpData.keyResponseAddress.toInt() and 0xFF)
+ putString(PUMP_ID_KEY, invariantPumpData.pumpID)
+ putLong(TBR_TIMESTAMP_KEY, if (tbrState is CurrentTbrState.TbrStarted) tbrState.tbr.timestamp.epochSeconds else -1)
+ putInt(TBR_PERCENTAGE_KEY, if (tbrState is CurrentTbrState.TbrStarted) tbrState.tbr.percentage else -1)
+ putInt(TBR_DURATION_KEY, if (tbrState is CurrentTbrState.TbrStarted) tbrState.tbr.durationInMinutes else -1)
+ putString(TBR_TYPE_KEY, if (tbrState is CurrentTbrState.TbrStarted) tbrState.tbr.type.stringId else "")
+ putInt(UTC_OFFSET_KEY, utcOffset.totalSeconds)
+ }
+ }
+
+ override fun deletePumpState(pumpAddress: BluetoothAddress): Boolean {
+ val hasState = sp.contains(NONCE_KEY)
+
+ sp.edit(commit = true) {
+ remove(BT_ADDRESS_KEY)
+ remove(NONCE_KEY)
+ remove(CP_CIPHER_KEY)
+ remove(PC_CIPHER_KEY)
+ remove(KEY_RESPONSE_ADDRESS_KEY)
+ remove(TBR_TIMESTAMP_KEY)
+ remove(TBR_PERCENTAGE_KEY)
+ remove(TBR_DURATION_KEY)
+ remove(TBR_TYPE_KEY)
+ remove(UTC_OFFSET_KEY)
+ }
+
+ return hasState
+ }
+
+ override fun hasPumpState(pumpAddress: BluetoothAddress) =
+ sp.contains(NONCE_KEY)
+
+ override fun getAvailablePumpStateAddresses() =
+ if (btAddress.isBlank()) setOf() else setOf(btAddress.toBluetoothAddress())
+
+ override fun getInvariantPumpData(pumpAddress: BluetoothAddress) = InvariantPumpData(
+ clientPumpCipher = cpCipherString.toCipher(),
+ pumpClientCipher = pcCipherString.toCipher(),
+ keyResponseAddress = keyResponseAddressInt.toByte(),
+ pumpID = pumpID
+ )
+
+ override fun getCurrentTxNonce(pumpAddress: BluetoothAddress) = nonceString.toNonce()
+
+ override fun setCurrentTxNonce(pumpAddress: BluetoothAddress, currentTxNonce: Nonce) {
+ nonceString = currentTxNonce.toString()
+ }
+
+ override fun getCurrentUtcOffset(pumpAddress: BluetoothAddress) =
+ UtcOffset(seconds = utcOffsetSeconds)
+
+ override fun setCurrentUtcOffset(pumpAddress: BluetoothAddress, utcOffset: UtcOffset) {
+ utcOffsetSeconds = utcOffset.totalSeconds
+ }
+
+ override fun getCurrentTbrState(pumpAddress: BluetoothAddress) =
+ if (tbrTimestamp >= 0)
+ CurrentTbrState.TbrStarted(Tbr(
+ timestamp = Instant.fromEpochSeconds(tbrTimestamp),
+ percentage = tbrPercentage,
+ durationInMinutes = tbrDuration,
+ type = Tbr.Type.fromStringId(tbrType)!!
+ ))
+ else
+ CurrentTbrState.NoTbrOngoing
+
+
+ override fun setCurrentTbrState(pumpAddress: BluetoothAddress, currentTbrState: CurrentTbrState) {
+ when (currentTbrState) {
+ is CurrentTbrState.TbrStarted -> {
+ tbrTimestamp = currentTbrState.tbr.timestamp.epochSeconds
+ tbrPercentage = currentTbrState.tbr.percentage
+ tbrDuration = currentTbrState.tbr.durationInMinutes
+ tbrType = currentTbrState.tbr.type.stringId
+ }
+ else -> {
+ tbrTimestamp = -1
+ tbrPercentage = -1
+ tbrDuration = -1
+ tbrType = ""
+ }
+ }
+ }
+
+ companion object {
+ const val BT_ADDRESS_KEY = "combov2-bt-address-key"
+ const val NONCE_KEY = "combov2-nonce-key"
+ const val CP_CIPHER_KEY = "combov2-cp-cipher-key"
+ const val PC_CIPHER_KEY = "combov2-pc-cipher-key"
+ const val KEY_RESPONSE_ADDRESS_KEY = "combov2-key-response-address-key"
+ const val PUMP_ID_KEY = "combov2-pump-id-key"
+ const val TBR_TIMESTAMP_KEY = "combov2-tbr-timestamp"
+ const val TBR_PERCENTAGE_KEY = "combov2-tbr-percentage"
+ const val TBR_DURATION_KEY = "combov2-tbr-duration"
+ const val TBR_TYPE_KEY = "combov2-tbr-type"
+ const val UTC_OFFSET_KEY = "combov2-utc-offset"
+ }
+}
diff --git a/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/Utility.kt b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/Utility.kt
new file mode 100644
index 0000000000..bc249c18d5
--- /dev/null
+++ b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/Utility.kt
@@ -0,0 +1,25 @@
+package info.nightscout.androidaps.plugins.pump.combov2
+
+import info.nightscout.comboctl.main.BasalProfile
+import info.nightscout.comboctl.main.NUM_COMBO_BASAL_PROFILE_FACTORS
+import info.nightscout.androidaps.interfaces.Profile as AAPSProfile
+
+// Utility extension functions for clearer conversion between
+// ComboCtl units and AAPS units. ComboCtl uses integer-encoded
+// decimals. For basal values, the last 3 digits of an integer
+// make up the decimal, so for example, 1568 actually means
+// 1.568 IU, and 419 means 0.419 IU etc. Similarly, for bolus
+// values, the last digit makes up the decimal. 57 means 5.7 IU,
+// 4 means 0.4 IU etc.
+// To emphasize better that such a conversion is taking place,
+// these extension functions are put in place.
+internal fun Int.cctlBasalToIU() = this.toDouble() / 1000.0
+internal fun Int.cctlBolusToIU() = this.toDouble() / 10.0
+internal fun Double.iuToCctlBolus() = (this * 10.0).toInt()
+
+fun AAPSProfile.toComboCtlBasalProfile(): BasalProfile {
+ val factors = IntRange(0, NUM_COMBO_BASAL_PROFILE_FACTORS - 1).map { hour ->
+ (this.getBasalTimeFromMidnight(hour * 60 * 60) * 1000.0).toInt()
+ }
+ return BasalProfile(factors)
+}
\ No newline at end of file
diff --git a/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/activities/ComboV2PairingActivity.kt b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/activities/ComboV2PairingActivity.kt
new file mode 100644
index 0000000000..d4db4ef718
--- /dev/null
+++ b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/activities/ComboV2PairingActivity.kt
@@ -0,0 +1,238 @@
+package info.nightscout.androidaps.plugins.pump.combov2.activities
+
+import android.app.Activity
+import android.os.Bundle
+import android.text.Editable
+import android.text.TextWatcher
+import android.view.View
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.databinding.DataBindingUtil
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import info.nightscout.androidaps.activities.NoSplashAppCompatActivity
+import info.nightscout.androidaps.combov2.R
+import info.nightscout.androidaps.combov2.databinding.Combov2PairingActivityBinding
+import info.nightscout.androidaps.plugins.pump.combov2.ComboV2Plugin
+import info.nightscout.androidaps.utils.alertDialogs.OKDialog
+import info.nightscout.shared.logging.LTag
+import info.nightscout.comboctl.base.BasicProgressStage
+import info.nightscout.comboctl.base.PairingPIN
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import javax.inject.Inject
+
+class ComboV2PairingActivity : NoSplashAppCompatActivity() {
+ @Inject lateinit var combov2Plugin: ComboV2Plugin
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Install an activity result caller for when the user presses
+ // "deny" or "reject" in the dialog that pops up when Android
+ // asks for permission to enable device discovery. In such a
+ // case, without this caller, the logic would continue to look
+ // for devices even though discovery isn't actually happening.
+ // With this caller, we can cancel pairing in this case instead.
+ val startPairingActivityLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ when (result.resultCode) {
+ Activity.RESULT_CANCELED -> {
+ aapsLogger.info(LTag.PUMP, "User rejected discovery request; cancelling pairing")
+ combov2Plugin.cancelPairing()
+ }
+ else -> Unit
+ }
+ }
+ combov2Plugin.customDiscoveryActivityStartCallback = { intent ->
+ startPairingActivityLauncher.launch(intent)
+ }
+
+ val binding: Combov2PairingActivityBinding = DataBindingUtil.setContentView(
+ this, R.layout.combov2_pairing_activity)
+
+ binding.combov2PairingFinishedOk.setOnClickListener {
+ finish()
+ }
+ binding.combov2PairingAborted.setOnClickListener {
+ finish()
+ }
+
+ val pinFormatRegex = "(\\d{1,3})(\\d{1,3})?(\\d{1,4})?".toRegex()
+ val nonDigitsRemovalRegex = "\\D".toRegex()
+ val whitespaceRemovalRegex = "\\s".toRegex()
+
+ // Add a custom TextWatcher to format the PIN in the
+ // same format it is shown on the Combo LCD, which is:
+ //
+ // xxx xxx xxxx
+ binding.combov2PinEntryEdit.addTextChangedListener(object : TextWatcher {
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
+ }
+
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
+ }
+
+ override fun afterTextChanged(s: Editable?) {
+ if (s == null)
+ return
+
+ val originalText = s.toString()
+ val trimmedText = originalText.trim().replace(nonDigitsRemovalRegex, "")
+
+ val digitGroupValues = pinFormatRegex.matchEntire(trimmedText)?.let { matchResult ->
+ // Get the digit groups. Skip the first group, which contains the entire original string.
+ if (matchResult.groupValues.isEmpty())
+ listOf()
+ else
+ matchResult.groupValues.subList(1, matchResult.groupValues.size)
+ } ?: listOf()
+
+ // Join the groups to a string with a whitespace in between to construct
+ // a correct PIN string (see the format above). Skip empty groups to
+ // not have a trailing whitespace.
+ val processedText = digitGroupValues.filter { it.isNotEmpty() }.joinToString(" ")
+
+ if (originalText != processedText) {
+ // Remove and add this listener to modify the text
+ // without causing an infinite loop (text is changed,
+ // listener is called, listener changes text).
+ binding.combov2PinEntryEdit.removeTextChangedListener(this)
+
+ // Shift the cursor position to skip the whitespaces.
+ val cursorPosition = when (val it = binding.combov2PinEntryEdit.selectionStart) {
+ 4 -> 5
+ 8 -> 9
+ else -> it
+ }
+
+ binding.combov2PinEntryEdit.setText(processedText)
+ binding.combov2PinEntryEdit.setSelection(cursorPosition)
+ binding.combov2PinEntryEdit.addTextChangedListener(this)
+ }
+ }
+ })
+
+ binding.combov2EnterPin.setOnClickListener {
+ // We need to skip whitespaces since the
+ // TextWatcher above inserts some.
+ val pinString = binding.combov2PinEntryEdit.text.replace(whitespaceRemovalRegex, "")
+ runBlocking {
+ val PIN = PairingPIN(pinString.map { it - '0' }.toIntArray())
+ combov2Plugin.providePairingPIN(PIN)
+ }
+ }
+
+ binding.combov2StartPairing.setOnClickListener {
+ combov2Plugin.startPairing()
+ }
+
+ binding.combov2CancelPairing.setOnClickListener {
+ OKDialog.showConfirmation(this, "Confirm pairing cancellation", "Do you really want to cancel pairing?", ok = Runnable {
+ combov2Plugin.cancelPairing()
+ })
+ }
+
+ lifecycleScope.launch {
+ lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ combov2Plugin.getPairingProgressFlow()
+ .onEach { progressReport ->
+ val stage = progressReport.stage
+
+ binding.combov2PairingSectionInitial.visibility =
+ if (stage == BasicProgressStage.Idle) View.VISIBLE else View.GONE
+ binding.combov2PairingSectionFinished.visibility =
+ if (stage == BasicProgressStage.Finished) View.VISIBLE else View.GONE
+ binding.combov2PairingSectionAborted.visibility =
+ if (stage is BasicProgressStage.Aborted) View.VISIBLE else View.GONE
+ binding.combov2PairingSectionMain.visibility = when (stage) {
+ BasicProgressStage.Idle,
+ BasicProgressStage.Finished,
+ is BasicProgressStage.Aborted -> View.GONE
+ else -> View.VISIBLE
+ }
+
+ if (stage is BasicProgressStage.Aborted) {
+ binding.combov2PairingAbortedReasonText.text = when (stage) {
+ is BasicProgressStage.Cancelled -> rh.gs(R.string.combov2_pairing_cancelled)
+ is BasicProgressStage.Timeout -> rh.gs(R.string.combov2_pairing_combo_scan_timeout_reached)
+ is BasicProgressStage.Error -> rh.gs(R.string.combov2_pairing_failed_due_to_error, stage.cause.toString())
+ else -> rh.gs(R.string.combov2_pairing_aborted_unknown_reasons)
+ }
+ }
+
+ binding.combov2CurrentPairingStepDesc.text = when (val progStage = stage) {
+ BasicProgressStage.ScanningForPumpStage ->
+ rh.gs(R.string.combov2_scanning_for_pump)
+
+ is BasicProgressStage.EstablishingBtConnection -> {
+ rh.gs(
+ R.string.combov2_establishing_bt_connection,
+ progStage.currentAttemptNr
+ )
+ }
+
+ BasicProgressStage.PerformingConnectionHandshake ->
+ rh.gs(R.string.combov2_pairing_performing_handshake)
+
+ BasicProgressStage.ComboPairingKeyAndPinRequested ->
+ rh.gs(R.string.combov2_pairing_pump_requests_pin)
+
+ BasicProgressStage.ComboPairingFinishing ->
+ rh.gs(R.string.combov2_pairing_finishing)
+
+ else -> ""
+ }
+
+ if (stage == BasicProgressStage.ComboPairingKeyAndPinRequested) {
+ binding.combov2PinEntryUi.visibility = View.VISIBLE
+ } else
+ binding.combov2PinEntryUi.visibility = View.INVISIBLE
+
+ // Scanning for the pump can take a long time and happens at the
+ // beginning, so set the progress bar to indeterminate during that
+ // time to show _something_ to the user.
+ binding.combov2PairingProgressBar.isIndeterminate =
+ (stage == BasicProgressStage.ScanningForPumpStage)
+
+ binding.combov2PairingProgressBar.progress = (progressReport.overallProgress * 100).toInt()
+ }
+ .launchIn(this)
+
+ combov2Plugin.previousPairingAttemptFailedFlow
+ .onEach { previousAttemptFailed ->
+ binding.combov2PinFailureIndicator.visibility =
+ if (previousAttemptFailed) View.VISIBLE else View.INVISIBLE
+ }
+ .launchIn(this)
+ }
+ }
+ }
+
+ override fun onBackPressed() {
+ aapsLogger.info(LTag.PUMP, "User pressed the back button; cancelling any ongoing pairing")
+ combov2Plugin.cancelPairing()
+ super.onBackPressed()
+ }
+
+ override fun onDestroy() {
+ // Reset the pairing progress reported to allow for future pairing attempts.
+ // Do this only after pairing was finished or aborted. onDestroy() can be
+ // called in the middle of a pairing process, and we do not want to reset
+ // the progress reporter mid-pairing.
+ when (combov2Plugin.getPairingProgressFlow().value.stage) {
+ BasicProgressStage.Finished,
+ is BasicProgressStage.Aborted -> {
+ aapsLogger.debug(
+ LTag.PUMP,
+ "Resetting pairing progress reporter after pairing was finished/aborted"
+ )
+ combov2Plugin.resetPairingProgress()
+ }
+ else -> Unit
+ }
+
+ super.onDestroy()
+ }
+}
diff --git a/pump/combov2/src/main/res/drawable/ic_combov2.xml b/pump/combov2/src/main/res/drawable/ic_combov2.xml
new file mode 100644
index 0000000000..3f3647f097
--- /dev/null
+++ b/pump/combov2/src/main/res/drawable/ic_combov2.xml
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pump/combov2/src/main/res/layout/combov2_fragment.xml b/pump/combov2/src/main/res/layout/combov2_fragment.xml
new file mode 100644
index 0000000000..2c5b304726
--- /dev/null
+++ b/pump/combov2/src/main/res/layout/combov2_fragment.xml
@@ -0,0 +1,456 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pump/combov2/src/main/res/layout/combov2_pairing_activity.xml b/pump/combov2/src/main/res/layout/combov2_pairing_activity.xml
new file mode 100644
index 0000000000..8b899aa4de
--- /dev/null
+++ b/pump/combov2/src/main/res/layout/combov2_pairing_activity.xml
@@ -0,0 +1,256 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pump/combov2/src/main/res/values/strings.xml b/pump/combov2/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..498048321a
--- /dev/null
+++ b/pump/combov2/src/main/res/values/strings.xml
@@ -0,0 +1,121 @@
+
+
+ Accu-Chek Combo (new plugin)
+ COMBOV2
+ Pump integration for Accu-Chek Combo pumps, successor to Ruffy (EXPERIMENTAL)
+ Could not connect to the pump
+ Not paired to a pump
+ Pump terminated connection
+ Combo warning
+ Combo error
+ Call hotline for update
+ Bluetooth fault; redo pairing
+ Reservoir empty
+ Battery empty
+ Occlusion
+ End of backup pump operation
+ Mechanical error
+ Electronic error
+ Power interrupt
+ End of loan pump operation
+ Reservoir error
+ Infusion set not primed
+ Extended bolus is not supported
+ Accu-Check Combo
+ Pair with pump
+ Unpair pump
+ Driver state
+ Current activity
+ Bluetooth address
+ combov2_settings
+ combov2_pair_with_pump
+ combov2_unpair_pump
+ Start pairing
+ Combo pairing in progress
+ Steps to perform pairing with your Combo:\n \n 1.
+ On your pump, navigate to the Bluetooth Settings\n 2. Check if a device is already shown
+ as paired; if so, go to "Delete device" screen to delete/unpair that device\n 3. Go to the
+ "Add device" screen, and initiate pairing\n 4. Click on the Start pairingbutton below
+ If no connection is established
+ after more than ~5 minutes:\n \n 1. Press Back or the "Cancel Pairing" button\n 2. Cancel
+ the pairing on the Combo (press both UP and MENU buttons at the same time to cancel pairing)\n 3. Try to pair
+ again
+ 0123456789
+ Enter PIN
+ Cancel pairing
+ 10-digit PIN
+ Successfully paired with Combo
+ Pairing with Combo cancelled by user
+ Combo scan timeout reached
+ Pairing failed due to error: %1$s
+ Pairing aborted for unknown reasons
+ Scanning for pump
+ Establishing Bluetooth connection (attempt no. %1$d)
+ Performing handshake with pump
+ Pump requests 10-digit PIN
+ Finishing pairing
+ No connection for %1$d minutes
+ Less than 1 minute ago
+ Setting current pump time
+ Setting current pump date
+ Not initialized
+ Checking pump
+ Ready
+ Suspended
+ Pump is suspended
+ Executing command
+ Getting basal profile
+ Setting basal profile
+ Setting %1$d%% TBR for %2$d minutes
+ Cancelling ongoing TBR
+ Delivering %1$.1f U bolus
+ Fetching TDD history
+ Updating pump datetime
+ Updating pump status
+ PIN did not work. Check if there was a typo. If this
+ keeps happening, cancel and retry pairing.
+ combov2_discovery_duration
+ Discovery duration (in seconds)
+ combov2_verbose_logging
+ Enable verbose Combo logging
+ Getting basal profile; %1$d factor(s) read
+ Setting basal profile; %1$d factor(s) written
+ Delivering bolus (%1$.1f of %2$.1f U delivered)
+ Cannot deliver treatment - pump is suspended
+ Insufficient insulin in reservoir
+ Bolus cancelled
+ Bolus delivery failed. It appears no bolus was delivered. To be sure, please check the pump to avoid a double bolus and then bolus again. To guard against bugs, boluses are not automatically retried.
+ Bolus not delivered
+ Cannot access pump data; the pump must be paired again
+ Unaccounted bolus deliveries detected. Cancelling bolus for safety reasons.
+ Incorrect active basal profile; profile 1 must be the active one, not profile %1$d
+ Unrecognized Combo alert
+ Combo alert
+ %1$.1f %2$s (%3$s)
+ %1$d%% (%2$d min remaining)
+ %1$d%% (less than 1 min remaining)
+ Loading TDDs cancelled
+ Retrieving TDDs failed
+ {fa-bed}
+ {fa-battery-empty}
+ {fa-battery-quarter}
+ {fa-battery-full}
+ Pump battery is low
+ Pump reservoir level is low
+ Setting TBR succeeded
+ Setting TBR failed
+ Cannot set absolute TBR if base basal rate is zero
+ Pair AndroidAPS and Android with a currently unpaired Accu-Chek Combo pump
+ Unpair AndroidAPS and Android from the currently paired Accu-Chek Combo pump
+ Unknown TBR was detected and stopped; percentage: %1$d%%; remaining duration: %2$s
+ Connection error: %1$s
+ LastConn: %1$d min ago
+ Alert: %s
+ LastBolus: %1$sU @ %2$s
+ Temp: %s
+ Reserv: %dU
+ empty
+ low
+ full
+ Batt: %s
+
diff --git a/pump/combov2/src/main/res/xml/pref_combov2.xml b/pump/combov2/src/main/res/xml/pref_combov2.xml
new file mode 100644
index 0000000000..621a12a10d
--- /dev/null
+++ b/pump/combov2/src/main/res/xml/pref_combov2.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/settings.gradle b/settings.gradle
index 27b894fa13..ffc1e98c15 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -9,6 +9,7 @@ include ':ui'
include ':implementation'
include ':plugins'
include ':pump:combo'
+include ':pump:combov2'
include ':pump:combov2:comboctl'
include ':pump:dana'
include ':pump:danar'