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 @@ + + + + + + + + + + + + +