diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b7121ae9777..b3da95d07f3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -181,6 +181,7 @@ dependencies { implementation(project(":implementation")) implementation(project(":database:impl")) implementation(project(":database:persistence")) + implementation(project(":pump:apex")) implementation(project(":pump:combov2")) implementation(project(":pump:dana")) implementation(project(":pump:danars")) diff --git a/app/src/main/kotlin/app/aaps/di/AppComponent.kt b/app/src/main/kotlin/app/aaps/di/AppComponent.kt index 89703326da1..9f47a564978 100644 --- a/app/src/main/kotlin/app/aaps/di/AppComponent.kt +++ b/app/src/main/kotlin/app/aaps/di/AppComponent.kt @@ -15,6 +15,7 @@ import app.aaps.plugins.main.di.PluginsModule import app.aaps.plugins.source.di.SourceModule import app.aaps.plugins.sync.di.OpenHumansModule import app.aaps.plugins.sync.di.SyncModule +import app.aaps.pump.apex.di.ApexModule import app.aaps.pump.common.di.PumpCommonModule import app.aaps.pump.dana.di.DanaHistoryModule import app.aaps.pump.dana.di.DanaModule @@ -88,6 +89,7 @@ import javax.inject.Singleton RileyLinkModule::class, MedtrumModule::class, EquilModule::class, + ApexModule::class, VirtualPumpModule::class ] ) diff --git a/app/src/main/kotlin/app/aaps/di/PluginsListModule.kt b/app/src/main/kotlin/app/aaps/di/PluginsListModule.kt index 30570b41c1d..d8c92c2c66e 100644 --- a/app/src/main/kotlin/app/aaps/di/PluginsListModule.kt +++ b/app/src/main/kotlin/app/aaps/di/PluginsListModule.kt @@ -67,6 +67,7 @@ import app.aaps.pump.medtrum.MedtrumPlugin import app.aaps.pump.omnipod.dash.OmnipodDashPumpPlugin import app.aaps.pump.omnipod.eros.OmnipodErosPumpPlugin import app.aaps.pump.virtual.VirtualPumpPlugin +import app.aaps.pump.apex.ApexPumpPlugin import dagger.Binds import dagger.Module import dagger.multibindings.IntKey @@ -222,6 +223,12 @@ abstract class PluginsListModule { @IntKey(170) abstract fun bindEquilPumpPlugin(plugin: EquilPumpPlugin): PluginBase + @Binds + @PumpDriver + @IntoMap + @IntKey(171) + abstract fun bindApexPumpPlugin(plugin: ApexPumpPlugin): PluginBase + @Binds @AllConfigs @IntoMap diff --git a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/DoseStepSize.kt b/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/DoseStepSize.kt index e0078146a64..e77fb81a97b 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/DoseStepSize.kt +++ b/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/DoseStepSize.kt @@ -39,6 +39,13 @@ enum class DoseStepSize(private val entries: Array) { DoseStepSizeEntry(2.0, 15.0, 0.1), DoseStepSizeEntry(15.0, 40.0, 0.5) ) + ), + Apex( + arrayOf( + DoseStepSizeEntry(0.0, 1.0, 0.025), + DoseStepSizeEntry(1.0, 2.0, 0.05), + DoseStepSizeEntry(2.0, Double.MAX_VALUE, 0.1) + ) ); fun getStepSizeForAmount(amount: Double): Double { diff --git a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/ManufacturerType.kt b/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/ManufacturerType.kt index 51daa400c22..603727ecc0e 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/ManufacturerType.kt +++ b/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/ManufacturerType.kt @@ -13,6 +13,6 @@ enum class ManufacturerType(val description: String) { Ypsomed("Ypsomed"), G2e("G2e"), Eoflow("Eoflow"), - Equil("Equil"); - + Equil("Equil"), + Apex("APEX"); } \ No newline at end of file diff --git a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpCapability.kt b/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpCapability.kt index 36dc916fa9a..53f6548f843 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpCapability.kt +++ b/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpCapability.kt @@ -20,6 +20,7 @@ enum class PumpCapability { DiaconnCapabilities(arrayOf(Capability.Bolus, Capability.ExtendedBolus, Capability.TempBasal, Capability.BasalProfileSet, Capability.Refill, Capability.ReplaceBattery, Capability.TDD, Capability.ManualTDDLoad)), // EopatchCapabilities(arrayOf(Capability.Bolus, Capability.ExtendedBolus, Capability.TempBasal, Capability.BasalProfileSet, Capability.BasalRate30min)), MedtrumCapabilities(arrayOf(Capability.Bolus, Capability.TempBasal, Capability.BasalProfileSet, Capability.BasalRate30min, Capability.TDD)), // Technically the pump supports ExtendedBolus, but not implemented (yet) + ApexCapabilities(arrayOf(Capability.Bolus, Capability.TempBasal, Capability.BasalProfileSet, Capability.BasalRate30min, Capability.TDD, Capability.ManualTDDLoad, Capability.ReplaceBattery, Capability.Refill)) ; var children: ArrayList = ArrayList() diff --git a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpDescription.kt b/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpDescription.kt index 94c2ba0ce92..0720265a578 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpDescription.kt +++ b/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpDescription.kt @@ -1,6 +1,6 @@ package app.aaps.core.data.pump.defs -class PumpDescription { +open class PumpDescription { var pumpType = PumpType.GENERIC_AAPS var isBolusCapable = false @@ -25,6 +25,7 @@ class PumpDescription { var basalMaximumRate = 0.0 var isRefillingCapable = false var isBatteryReplaceable = false + var maxBolusSize = 0.0 //var storesCarbInfo = false var is30minBasalRatesCapable = false @@ -64,6 +65,7 @@ class PumpDescription { needsManualTDDLoad = true hasCustomUnreachableAlertCheck = false useHardwareLink = false + maxBolusSize = 0.0 } companion object { diff --git a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpType.kt b/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpType.kt index 2de1a2c3f2b..c212fac9bab 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpType.kt +++ b/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpType.kt @@ -474,6 +474,22 @@ enum class PumpType( pumpCapability = PumpCapability.DiaconnCapabilities, source = Source.EQuil, useHardwareLink = true, + ), + APEX_TRUCARE_III( + description = "APEX TruCare III", + manufacturer = ManufacturerType.Apex, + model = "TruCare III", + extendedBolusSettings = DoseSettings(0.025, 15, 24 * 60, 0.025), + tbrSettings = DoseSettings(0.025, 15, 24 * 60, 0.0), + pumpTempBasalType = PumpTempBasalType.Absolute, + bolusSize = 0.025, + baseBasalMinValue = 0.025, + baseBasalStep = 0.025, + baseBasalSpecialSteps = DoseStepSize.Apex, + specialBolusSize = DoseStepSize.Apex, + pumpCapability = PumpCapability.ApexCapabilities, + source = Source.ApexTruCareIII, + useHardwareLink = true, ); fun manufacturer() = parent?.manufacturer ?: manufacturer ?: throw IllegalStateException() @@ -511,7 +527,8 @@ enum class PumpType( MDI, VirtualPump, Unknown, - EQuil + EQuil, + ApexTruCareIII } companion object { diff --git a/database/impl/src/main/kotlin/app/aaps/database/entities/embedments/InterfaceIDs.kt b/database/impl/src/main/kotlin/app/aaps/database/entities/embedments/InterfaceIDs.kt index 078c6cb40ca..2847fcb1e3f 100644 --- a/database/impl/src/main/kotlin/app/aaps/database/entities/embedments/InterfaceIDs.kt +++ b/database/impl/src/main/kotlin/app/aaps/database/entities/embedments/InterfaceIDs.kt @@ -53,7 +53,8 @@ data class InterfaceIDs @Ignore constructor( MEDTRUM_UNTESTED, USER, CACHE, - EQUIL; + EQUIL, + APEX_TRUCARE_III; companion object { diff --git a/database/persistence/src/main/kotlin/app/aaps/database/persistence/converters/PumpTypeExtension.kt b/database/persistence/src/main/kotlin/app/aaps/database/persistence/converters/PumpTypeExtension.kt index e17b554e168..053efd0ddc2 100644 --- a/database/persistence/src/main/kotlin/app/aaps/database/persistence/converters/PumpTypeExtension.kt +++ b/database/persistence/src/main/kotlin/app/aaps/database/persistence/converters/PumpTypeExtension.kt @@ -42,6 +42,7 @@ fun InterfaceIDs.PumpType.fromDb(): PumpType = InterfaceIDs.PumpType.MEDTRUM_UNTESTED -> PumpType.MEDTRUM_UNTESTED InterfaceIDs.PumpType.CACHE -> PumpType.CACHE InterfaceIDs.PumpType.EQUIL -> PumpType.EQUIL + InterfaceIDs.PumpType.APEX_TRUCARE_III -> PumpType.APEX_TRUCARE_III } fun PumpType.toDb(): InterfaceIDs.PumpType = @@ -83,5 +84,6 @@ fun PumpType.toDb(): InterfaceIDs.PumpType = PumpType.MEDTRUM_UNTESTED -> InterfaceIDs.PumpType.MEDTRUM_UNTESTED PumpType.CACHE -> InterfaceIDs.PumpType.CACHE PumpType.EQUIL -> InterfaceIDs.PumpType.EQUIL + PumpType.APEX_TRUCARE_III -> InterfaceIDs.PumpType.APEX_TRUCARE_III } diff --git a/plugins/constraints/src/main/kotlin/app/aaps/plugins/constraints/safety/SafetyPlugin.kt b/plugins/constraints/src/main/kotlin/app/aaps/plugins/constraints/safety/SafetyPlugin.kt index 4cbc3eb373e..f76b750387c 100644 --- a/plugins/constraints/src/main/kotlin/app/aaps/plugins/constraints/safety/SafetyPlugin.kt +++ b/plugins/constraints/src/main/kotlin/app/aaps/plugins/constraints/safety/SafetyPlugin.kt @@ -110,6 +110,10 @@ class SafetyPlugin @Inject constructor( if (pump.pumpDescription.tempBasalStyle == PumpDescription.ABSOLUTE) { val pumpLimit = pump.pumpDescription.pumpType.tbrSettings()?.maxDose ?: 0.0 absoluteRate.setIfSmaller(pumpLimit, rh.gs(app.aaps.core.ui.R.string.limitingbasalratio, pumpLimit, rh.gs(app.aaps.core.ui.R.string.pumplimit)), this) + + // Not all pumps have dynamic TBR constraint + val dynamicPumpLimit = pump.pumpDescription.maxTempAbsolute + if (dynamicPumpLimit > 0.0) absoluteRate.setIfSmaller(dynamicPumpLimit, rh.gs(app.aaps.core.ui.R.string.limitingbasalratio, dynamicPumpLimit, rh.gs(app.aaps.core.ui.R.string.pumplimit)), this) } // do rounding @@ -149,6 +153,12 @@ class SafetyPlugin @Inject constructor( insulin.setIfSmaller(maxBolus, rh.gs(app.aaps.core.ui.R.string.limitingbolus, maxBolus, rh.gs(R.string.maxvalueinpreferences)), this) insulin.setIfSmaller(hardLimits.maxBolus(), rh.gs(app.aaps.core.ui.R.string.limitingbolus, hardLimits.maxBolus(), rh.gs(R.string.hardlimit)), this) val pump = activePlugin.activePump + val dynamicPumpLimit = pump.pumpDescription.maxBolusSize + if (dynamicPumpLimit > 0.0) { + // Not all pumps have dynamic bolus size constraint + insulin.setIfSmaller(dynamicPumpLimit, rh.gs(app.aaps.core.ui.R.string.limitingbolus, dynamicPumpLimit, rh.gs(app.aaps.core.ui.R.string.pumplimit)), this) + } + val rounded = pump.pumpDescription.pumpType.determineCorrectBolusSize(insulin.value()) insulin.setIfDifferent(rounded, rh.gs(app.aaps.core.ui.R.string.pumplimit), this) return insulin diff --git a/pump/apex/.gitignore b/pump/apex/.gitignore new file mode 100644 index 00000000000..796b96d1c40 --- /dev/null +++ b/pump/apex/.gitignore @@ -0,0 +1 @@ +/build diff --git a/pump/apex/build.gradle.kts b/pump/apex/build.gradle.kts new file mode 100644 index 00000000000..6765abb1e31 --- /dev/null +++ b/pump/apex/build.gradle.kts @@ -0,0 +1,31 @@ +plugins { + alias(libs.plugins.android.library) + id("kotlin-android") + id("kotlin-kapt") + id("android-module-dependencies") + id("test-module-dependencies") + id("jacoco-module-dependencies") +} + +android { + namespace = "app.aaps.pump.apex" + buildFeatures { + dataBinding = true + } +} + +dependencies { + implementation(project(":core:data")) + implementation(project(":core:interfaces")) + implementation(project(":core:keys")) + implementation(project(":core:libraries")) + implementation(project(":core:objects")) + implementation(project(":core:ui")) + implementation(project(":core:utils")) + implementation(project(":core:validators")) + implementation(project(":pump:pump-common")) + testImplementation(project(":shared:tests")) + + kapt(libs.com.google.dagger.compiler) + kapt(libs.com.google.dagger.android.processor) +} \ No newline at end of file diff --git a/pump/apex/proguard-rules.pro b/pump/apex/proguard-rules.pro new file mode 100644 index 00000000000..f1b424510da --- /dev/null +++ b/pump/apex/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/pump/apex/src/main/AndroidManifest.xml b/pump/apex/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..122ecc65431 --- /dev/null +++ b/pump/apex/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/ApexPump.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/ApexPump.kt new file mode 100644 index 00000000000..e1758839cbb --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/ApexPump.kt @@ -0,0 +1,245 @@ +package app.aaps.pump.apex + +import app.aaps.core.interfaces.pump.DetailedBolusInfo +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.rx.events.EventOverviewBolusProgress +import app.aaps.pump.apex.connectivity.commands.pump.Alarm +import app.aaps.pump.apex.connectivity.commands.pump.BolusEntry +import app.aaps.pump.apex.connectivity.commands.pump.StatusV1 +import app.aaps.pump.apex.connectivity.commands.pump.StatusV2 +import app.aaps.pump.apex.connectivity.commands.pump.Version +import org.joda.time.DateTime +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.roundToInt + +/** + * @author Roman Rikhter (teledurak@gmail.com) + */ +@Singleton +class ApexPump @Inject constructor() { + private var _status: PumpStatus? = null + val status: PumpStatus? + get() = _status + + val batteryLevel: BatteryLevel + get() = status?.batteryLevel ?: BatteryLevel(0, 0.0, true) + + val isAdvancedBolusEnabled: Boolean + get() = status?.isAdvancedBolusEnabled ?: false + + val currentBasalPattern: Int + get() = status?.currentBasalPattern ?: 0 + + val maxBasal: Double + get() = status?.maxBasal ?: 0.0 + + val maxBolus: Double + get() = status?.maxBolus ?: 0.0 + + val dateTime: DateTime + get() = status?.dateTime ?: DateTime(0) + + val reservoirLevel: Double + get() = status?.reservoirLevel ?: 0.0 + + val alarms: List + get() = status?.alarms ?: listOf() + + val tbr: TBR? + get() = status?.tbr + + val basal: Basal? + get() = status?.basal + + val isSuspended: Boolean + get() = basal == null + + val isTBRunning: Boolean + get() = tbr != null + + val settingsAreUnadvised: Boolean + get() = isAdvancedBolusEnabled || currentBasalPattern != ApexService.USED_BASAL_PATTERN_INDEX + + var lastV1: StatusV1? = null + var lastV2: StatusV2? = null + + var inProgressBolus: InProgressBolus? = null + var lastBolus: BolusEntry? = null + var firmwareVersion: Version? = null + var serialNumber: String = "" + var gettingReady: Boolean = true + + val isBolusing: Boolean + get() = inProgressBolus != null + + fun updateFromV1(obj: StatusV1): StatusUpdate { + val updates = arrayListOf() + val new = PumpStatus( + batteryLevel = BatteryLevel(obj.batteryLevel!!.approximatePercentage, batteryLevel.voltage, true), + isAdvancedBolusEnabled = obj.advancedBolusEnabled, + currentBasalPattern = obj.currentBasalPattern, + maxBasal = obj.maxBasal * 0.025, + maxBolus = obj.maxBolus * 0.025, + tbr = if (obj.isTemporaryBasalActive) TBR( + if (obj.temporaryBasalRateIsAbsolute) obj.temporaryBasalRate * 0.025 else null, + if (obj.temporaryBasalRateIsAbsolute) null else obj.temporaryBasalRate, + obj.temporaryBasalRateIsAbsolute, + obj.temporaryBasalRateDuration, + obj.temporaryBasalRateElapsed, + ) else null, + basal = if (obj.currentBasalRate == UShort.MAX_VALUE.toInt()) + null + else Basal( + obj.currentBasalRate * 0.025, + obj.currentBasalEndHour, + obj.currentBasalEndMinute, + ), + dateTime = obj.dateTime, + reservoirLevel = obj.reservoir / 1000.0, + alarms = obj.alarms + ) + + updates.apply { when { + new.basal != basal -> add(Update.BasalChanged) + new.tbr != tbr -> add(Update.TBRChanged) + new.maxBasal != maxBasal || new.maxBolus != maxBolus -> add(Update.ConstraintsChanged) + new.currentBasalPattern != currentBasalPattern || new.isAdvancedBolusEnabled != isAdvancedBolusEnabled -> add(Update.UnadvisedSettingsChanged) + new.batteryLevel != batteryLevel -> add(Update.BatteryChanged) + new.reservoirLevel != reservoirLevel -> add(Update.ReservoirChanged) + new.alarms != alarms -> add(Update.AlarmsChanged) + } } + + lastV1 = obj + + val ret = StatusUpdate( + changes = updates, + previous = status, + current = new, + ) + _status = new + return ret + } + + fun updateFromV2(obj: StatusV2): StatusUpdate { + val new = PumpStatus( + batteryLevel = BatteryLevel(batteryLevel.percentage, obj.batteryVoltage, batteryLevel.approximate), + dateTime = dateTime, + reservoirLevel = reservoirLevel, + tbr = tbr, + alarms = alarms, + currentBasalPattern = currentBasalPattern, + maxBasal = maxBasal, + maxBolus = maxBolus, + basal = basal, + isAdvancedBolusEnabled = isAdvancedBolusEnabled, + ) + val updates = mutableListOf() + updates.apply { when { + batteryLevel.voltage != new.batteryLevel.voltage -> add(Update.BatteryChanged) + } } + + lastV2 = obj + + val ret = StatusUpdate( + changes = updates, + previous = status, + current = new, + ) + _status = new + return ret + } + + data class BatteryLevel( + val percentage: Int, + val voltage: Double?, // v2+ + val approximate: Boolean + ) + + data class Basal( + val rate: Double, + val endHour: Int, + val endMinute: Int, + ) + + data class TBR( + val rate: Double?, + val percentage: Int?, + val isAbsolute: Boolean, + val durationMinutes: Int, + val elapsedMinutes: Int, + ) + + data class InProgressBolus( + var requestedDose: Double = 0.0, + var currentDose: Double = 0.0, + var temporaryId: Long = 0, + var cancelled: Boolean = false, + var detailedBolusInfo: DetailedBolusInfo, + var treatment: EventOverviewBolusProgress.Treatment, + var failed: Boolean = false, + ) + + enum class Update { + AlarmsChanged, + BasalChanged, + TBRChanged, + ConstraintsChanged, + UnadvisedSettingsChanged, + BatteryChanged, + ReservoirChanged, + } + + data class PumpStatus( + val dateTime: DateTime, + val batteryLevel: BatteryLevel, + val reservoirLevel: Double, + val alarms: List, + val isAdvancedBolusEnabled: Boolean, + val currentBasalPattern: Int, + val maxBasal: Double, + val maxBolus: Double, + val tbr: TBR?, + val basal: Basal?, + ) { + fun overall(): String { + return "Date ${dateTime}, battery ${batteryLevel.percentage}, " + + "reservoir ${reservoirLevel}, alarms ${alarms.joinToString(", ", "[", "]") { it.name }}, " + + "maxBasal $maxBasal, maxBolus $maxBolus, " + + "TBR ${tbr?.rate}, basal ${basal?.rate}" + } + + fun getPumpStatus(rh: ResourceHelper): String = when { + alarms.isNotEmpty() -> rh.gs(R.string.overview_pump_status_alarm) + basal == null -> rh.gs(R.string.overview_pump_status_suspended) + else -> rh.gs(R.string.overview_pump_status_normal) + } + + fun getBatteryLevel(rh: ResourceHelper): String = if (batteryLevel.voltage == null) + rh.gs(R.string.overview_pump_battery_approximate, batteryLevel.percentage) + else + rh.gs(R.string.overview_pump_battery_exact, batteryLevel.percentage, (batteryLevel.voltage * 1000).roundToInt()) + + fun getReservoirLevel(rh: ResourceHelper): String = rh.gs(R.string.overview_pump_reservoir, reservoirLevel) + + fun getTBR(rh: ResourceHelper): String = if (tbr != null) { + val diff = tbr.durationMinutes - tbr.elapsedMinutes + val id = if (tbr.isAbsolute) R.string.overview_pump_tempbasal else R.string.overview_pump_tempbasal_percentage + val value = if (tbr.isAbsolute) tbr.rate else tbr.percentage + if (diff >= 60) + rh.gs(id, value, diff / 60, diff % 60) + else + rh.gs(id, value, diff) + } else "-" + + fun getBasal(rh: ResourceHelper): String = if (basal != null) { + rh.gs(R.string.overview_pump_basal, basal.rate, basal.endHour, basal.endMinute) + } else "-" + } + + data class StatusUpdate( + val changes: List, + val previous: PumpStatus?, + val current: PumpStatus, + ) +} \ No newline at end of file diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/ApexPumpPlugin.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/ApexPumpPlugin.kt new file mode 100644 index 00000000000..5f2014548e9 --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/ApexPumpPlugin.kt @@ -0,0 +1,525 @@ +package app.aaps.pump.apex + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import androidx.preference.PreferenceCategory +import androidx.preference.PreferenceManager +import androidx.preference.PreferenceScreen +import app.aaps.core.data.plugin.PluginType +import app.aaps.core.data.pump.defs.DoseStepSize +import app.aaps.core.data.pump.defs.ManufacturerType +import app.aaps.core.data.pump.defs.PumpDescription +import app.aaps.core.data.pump.defs.PumpType +import app.aaps.core.data.pump.defs.TimeChangeType +import app.aaps.core.interfaces.constraints.ConstraintsChecker +import app.aaps.core.interfaces.constraints.PluginConstraints +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.objects.Instantiator +import app.aaps.core.interfaces.plugin.PluginDescription +import app.aaps.core.interfaces.profile.Profile +import app.aaps.core.interfaces.pump.DetailedBolusInfo +import app.aaps.core.interfaces.pump.Pump +import app.aaps.core.interfaces.pump.PumpEnactResult +import app.aaps.core.interfaces.pump.PumpPluginBase +import app.aaps.core.interfaces.pump.PumpSync +import app.aaps.core.interfaces.pump.defs.fillFor +import app.aaps.core.interfaces.queue.CommandQueue +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.core.interfaces.rx.bus.RxBus +import app.aaps.core.interfaces.rx.events.EventAppExit +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.interfaces.utils.fabric.FabricPrivacy +import app.aaps.core.keys.Preferences +import app.aaps.core.objects.constraints.ConstraintObject +import app.aaps.core.utils.wait +import app.aaps.core.validators.preferences.AdaptiveDoublePreference +import app.aaps.core.validators.preferences.AdaptiveListPreference +import app.aaps.core.validators.preferences.AdaptiveStringPreference +import app.aaps.pump.apex.connectivity.ApexBluetooth +import app.aaps.pump.apex.connectivity.commands.pump.AlarmLength +import app.aaps.pump.apex.ui.ApexFragment +import app.aaps.pump.apex.utils.keys.ApexBooleanKey +import app.aaps.pump.apex.utils.keys.ApexDoubleKey +import app.aaps.pump.apex.utils.keys.ApexStringKey +import app.aaps.pump.apex.utils.toApexReadableProfile +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import org.json.JSONException + +import org.json.JSONObject +import javax.inject.Inject +import kotlin.math.abs + +/** + * @author Roman Rikhter (teledurak@gmail.com) + */ +class ApexPumpPlugin @Inject constructor( + aapsLogger: AAPSLogger, + rh: ResourceHelper, + commandQueue: CommandQueue, + val context: Context, + val preferences: Preferences, + val rxBus: RxBus, + val aapsSchedulers: AapsSchedulers, + val fabricPrivacy: FabricPrivacy, + val instantiator: Instantiator, + val dateUtil: DateUtil, + val pump: ApexPump, + private val constraintsChecker: ConstraintsChecker +): PumpPluginBase( + PluginDescription() + .mainType(PluginType.PUMP) + .fragmentClass(ApexFragment::class.java.name) + .pluginIcon(R.drawable.ic_apex) + .pluginName(R.string.apex_plugin_name) + .shortName(R.string.apex_plugin_shortname) + .description(R.string.apex_plugin_description) + .preferencesId(PluginDescription.PREFERENCE_SCREEN), + aapsLogger, rh, commandQueue +), Pump, PluginConstraints { + + init { + preferences.registerPreferences(ApexBooleanKey::class.java) + preferences.registerPreferences(ApexDoubleKey::class.java) + preferences.registerPreferences(ApexStringKey::class.java) + } + + private val disposable = CompositeDisposable() + + private var service: ApexService? = null + private val connection: ServiceConnection = object : ServiceConnection { + override fun onServiceDisconnected(name: ComponentName) { + aapsLogger.debug(LTag.PUMP, "Service is disconnected") + service = null + } + + override fun onServiceConnected(name: ComponentName, service: IBinder) { + aapsLogger.debug(LTag.PUMP, "Service is connected") + val mLocalBinder = service as ApexService.LocalBinder + this@ApexPumpPlugin.service = mLocalBinder.serviceInstance.also { + it.startConnection() + } + } + } + + + override fun onStart() { + super.onStart() + aapsLogger.debug(LTag.PUMP, "Starting APEX plugin") + context.bindService(Intent(context, ApexService::class.java), connection, Context.BIND_AUTO_CREATE) + disposable += rxBus + .toObservable(EventAppExit::class.java) + .observeOn(aapsSchedulers.io) + .subscribe({ context.unbindService(connection) }, fabricPrivacy::logException) + } + + override fun onStop() { + aapsLogger.debug(LTag.PUMP, "Stopping APEX plugin") + service?.disconnect() + context.unbindService(connection) + disposable.clear() + super.onStop() + } + + override val isFakingTempsByExtendedBoluses = false + override fun canHandleDST() = false + + override fun manufacturer() = ManufacturerType.Apex + override fun model() = PumpType.APEX_TRUCARE_III + override fun serialNumber() = preferences.get(ApexStringKey.LastConnectedSerialNumber) + + override val baseBasalRate: Double + get() = pump.basal?.rate ?: 0.0 + override val reservoirLevel: Double + get() = pump.reservoirLevel + override val batteryLevel: Int + get() = pump.batteryLevel.percentage + override val pumpDescription = PumpDescription().fillFor(model()) + + override fun isBusy() = false //service?.isBusy ?: false + override fun isSuspended() = pump.isSuspended + override fun isInitialized() = !pump.gettingReady && pump.status != null + override fun isConnecting() = service?.connectionStatus == ApexBluetooth.Status.CONNECTING + override fun isHandshakeInProgress() = false + override fun isConnected() = service?.connectionStatus == ApexBluetooth.Status.CONNECTED + override fun isBatteryChangeLoggingEnabled() = preferences.get(ApexBooleanKey.LogBatteryChange) + + // We should be always connected to the pump. + override fun connect(reason: String) { + aapsLogger.debug(LTag.PUMP, "Triggered connect: $reason") + if (service?.apexBluetooth?.status == ApexBluetooth.Status.DISCONNECTED) { + service?.startConnection() + } + } + override fun disconnect(reason: String) { + aapsLogger.debug(LTag.PUMP, "Triggered disconnect: $reason") + } + override fun stopConnecting() { + aapsLogger.debug(LTag.PUMP, "Triggered stopConnecting") + } + + override fun getJSONStatus(profile: Profile, profileName: String, version: String): JSONObject { + val now = System.currentTimeMillis() + if (!isInitialized()) return JSONObject() + + val date = pump.dateTime + if (date.millis + 60 * 60 * 1000L < System.currentTimeMillis()) { + return JSONObject() + } + val status = pump.status!! + + val pumpJson = JSONObject() + val statusJson = JSONObject() + val extendedJson = JSONObject() + try { + statusJson.put( + "status", if (!isSuspended()) "normal" + else if (isInitialized() && isSuspended()) "suspended" + else "inactive" + ) + statusJson.put("timestamp", dateUtil.toISOString(date.millis)) + val lastBolus = pump.lastBolus + if (lastBolus != null) { + extendedJson.put("lastBolus", dateUtil.dateAndTimeString(lastBolus.dateTime.millis)) + extendedJson.put("lastBolusAmount", lastBolus.standardPerformed * 0.025) + } + if (status.tbr != null) { + extendedJson.put("TempBasalAbsoluteRate", status.tbr.rate ?: (baseBasalRate * status.tbr.percentage!! / 100.0)) + extendedJson.put("TempBasalStart", dateUtil.dateAndTimeString(now - status.tbr.elapsedMinutes * 1000)) + extendedJson.put("TempBasalRemaining", status.tbr.elapsedMinutes - status.tbr.durationMinutes) + } + extendedJson.put("BaseBasalRate", baseBasalRate) + try { + extendedJson.put("ActiveProfile", profileName) + } catch (ignored: Exception) {} + pumpJson.put("status", statusJson) + pumpJson.put("extended", extendedJson) + pumpJson.put("reservoir", status.reservoirLevel.toInt()) + pumpJson.put("clock", dateUtil.toISOString(now)) + } catch (e: JSONException) { + aapsLogger.error(LTag.PUMP, "Unhandled exception: $e") + } + return pumpJson + } + + override fun shortStatus(veryShort: Boolean): String { + if (!isInitialized()) return rh.gs(app.aaps.pump.common.R.string.pump_status_not_initialized) + val status = pump.status!! + + val ret = "${rh.gs(R.string.status_conn_status)}: ${service!!.connectionStatus.toLocalString(rh)}\n" + + "${rh.gs(R.string.status_pump_status)}: ${status.getPumpStatus(rh)}\n" + + "${rh.gs(R.string.status_last_bolus)}: ${pump.lastBolus?.toShortLocalString(rh) ?: "-"}\n" + + "${rh.gs(R.string.status_tbr)}: ${status.getTBR(rh)}\n" + + "${rh.gs(R.string.status_basal)}: ${status.getBasal(rh)}\n" + + "${rh.gs(R.string.status_reservoir)}: ${status.getReservoirLevel(rh)}\n" + + "${rh.gs(R.string.status_battery)}: ${status.getBatteryLevel(rh)}" + + aapsLogger.debug(LTag.PUMP, "Short status: $ret") + return ret + } + + fun updatePumpDescription() { + aapsLogger.debug(LTag.PUMP, "Updating pump description") + pumpDescription.maxTempAbsolute = pump.maxBasal + pumpDescription.basalMaximumRate = pump.maxBasal + pumpDescription.maxBolusSize = pump.maxBolus + } + + @Synchronized + override fun loadTDDs(): PumpEnactResult { + val ret = instantiator.providePumpEnactResult() + if (!isInitialized()) { + return ret.apply { + success = false + enacted = false + comment = rh.gs(R.string.error_not_ready) + } + } + + val status = service!!.getTDDs("ApexPumpPlugin-loadTDDs") + return ret.apply { + success = status + enacted = status + } + } + + @Synchronized + override fun getPumpStatus(reason: String) { + if (!isInitialized()) return + aapsLogger.debug(LTag.PUMP, "Requested pump status cause of $reason") + if (!service!!.getStatus("ApexPumpPlugin-getPumpStatus")) return + } + + @Synchronized + override fun setNewBasalProfile(profile: Profile): PumpEnactResult { + val ret = instantiator.providePumpEnactResult() + if (!isInitialized()) { + return ret.apply { + success = false + enacted = false + comment = rh.gs(R.string.error_not_ready) + } + } + + var result = service!!.updateBasalPatternIndex(ApexService.USED_BASAL_PATTERN_INDEX, "ApexPumpPlugin-setNewBasalProfile") + if (!result) { + return ret.apply { + success = false + enacted = false + comment = rh.gs(R.string.error_failed_to_switch_basal_profile_index) + } + } + + result = service!!.updateCurrentBasalPattern(profile.toApexReadableProfile(), "ApexPumpPlugin-setNewBasalProfile") + if (!result) { + return ret.apply { + success = false + enacted = false + comment = rh.gs(R.string.error_failed_to_update_basal_profile) + } + } + + return ret.apply { + success = true + enacted = true + } + } + + @Synchronized + override fun isThisProfileSet(profile: Profile): Boolean { + if (!isInitialized()) return false + val pumpBasalProfiles = service!!.getBasalProfiles("ApexPumpPlugin-isThisProfileSet") ?: return false + val pumpBasalProfile = pumpBasalProfiles[ApexService.USED_BASAL_PATTERN_INDEX] + for (i in 0..<48) { + val profileBasal = profile.getBasalTimeFromMidnight(i * 30 * 60) + val pumpBasal = pumpBasalProfile!![i] + val precision = DoseStepSize.Apex.getStepSizeForAmount(profileBasal) / 2 + if (abs(pumpBasal - profileBasal) > precision) { + aapsLogger.info(LTag.PUMP, "Profiles are not same: req $profileBasal != pump $pumpBasal, block $i, time ${i * 30 * 60}") + return false + } + } + return true + } + + override fun lastDataTime(): Long { + if (service == null) return System.currentTimeMillis() + return service!!.lastConnected + } + + @Synchronized + override fun deliverTreatment(detailedBolusInfo: DetailedBolusInfo): PumpEnactResult { + // Insulin value must be greater than 0 + require(detailedBolusInfo.carbs == 0.0) { detailedBolusInfo.toString() } + require(detailedBolusInfo.insulin > 0) { detailedBolusInfo.toString() } + val pumpEnactResult = instantiator.providePumpEnactResult() + detailedBolusInfo.insulin = constraintsChecker + .applyBolusConstraints(ConstraintObject(detailedBolusInfo.insulin, aapsLogger)) + .value() + + if (!isInitialized()) { + return pumpEnactResult.apply { + success = false + enacted = false + comment = rh.gs(R.string.error_not_ready) + } + } + + if (isSuspended()) { + return pumpEnactResult.apply { + success = false + enacted = false + comment = rh.gs(R.string.error_pump_suspended) + } + } + + val result = service!!.bolus(detailedBolusInfo, "ApexPumpPlugin-deliverTreatment") + if (!result) { + return pumpEnactResult.apply { + success = false + enacted = false + comment = rh.gs(R.string.error_bolus_start_failed) + } + } + + pump.inProgressBolus?.let { + synchronized(it) { + it.wait() + } + } + + val bolus = pump.inProgressBolus + val successful = bolus != null && !bolus.failed + return pumpEnactResult.apply { + success = successful + if (successful) { + enacted = bolus!!.currentDose >= 0.025 + bolusDelivered = bolus.currentDose + } + } + } + + @Synchronized + override fun stopBolusDelivering() { + if (!isInitialized()) return + service!!.cancelBolus("ApexPumpPlugin-stopBolusDelivering") + } + + @Synchronized + override fun setTempBasalAbsolute(absoluteRate: Double, durationInMinutes: Int, profile: Profile, enforceNew: Boolean, tbrType: PumpSync.TemporaryBasalType): PumpEnactResult { + val pumpEnactResult = instantiator.providePumpEnactResult() + val rate = constraintsChecker + .applyBasalConstraints(ConstraintObject(absoluteRate, aapsLogger), profile) + .value() + val duration = durationInMinutes - durationInMinutes % 15 + + if (!isInitialized()) { + return pumpEnactResult.apply { + success = false + enacted = false + comment = rh.gs(R.string.error_not_ready) + } + } + + if (isSuspended()) { + return pumpEnactResult.apply { + success = false + enacted = false + comment = rh.gs(R.string.error_pump_suspended) + } + } + + val status = pump.status!! + if (enforceNew && status.tbr != null) { + val result = service!!.cancelTemporaryBasal("ApexPumpPlugin-setTempBasal") + if (!result) { + return pumpEnactResult.apply { + success = false + enacted = false + comment = rh.gs(R.string.error_tbr_cancel_failed) + } + } + } + + val result = service!!.temporaryBasal(rate, duration, tbrType, "ApexPumpPlugin-setTempBasal") + if (!result) { + return pumpEnactResult.apply { + success = false + enacted = false + comment = rh.gs(R.string.error_tbr_set_failed) + } + } + + return pumpEnactResult.apply { + success = true + enacted = true + } + } + + @Synchronized + override fun setTempBasalPercent(percent: Int, durationInMinutes: Int, profile: Profile, enforceNew: Boolean, tbrType: PumpSync.TemporaryBasalType): PumpEnactResult { + return instantiator.providePumpEnactResult().apply { + success = false + enacted = false + comment = rh.gs(R.string.error_only_absolute_supported) + } + } + + @Synchronized + override fun cancelTempBasal(enforceNew: Boolean): PumpEnactResult { + val pumpEnactResult = instantiator.providePumpEnactResult() + if (!isInitialized()) { + return pumpEnactResult.apply { + success = false + enacted = false + comment = rh.gs(R.string.error_not_ready) + } + } + + if (isSuspended()) { + return pumpEnactResult.apply { + success = false + enacted = false + comment = rh.gs(R.string.error_pump_suspended) + } + } + + val status = service!!.cancelTemporaryBasal("ApexPumpPlugin-cancelTempBasal") + if (!status) { + return pumpEnactResult.apply { + success = false + enacted = false + comment = rh.gs(R.string.error_tbr_cancel_failed) + } + } + + return pumpEnactResult.apply { + success = true + enacted = true + } + } + + @Synchronized + override fun setExtendedBolus(insulin: Double, durationInMinutes: Int): PumpEnactResult { + // Not yet supported + return instantiator.providePumpEnactResult().apply { + success = false + enacted = false + comment = rh.gs(R.string.error_not_ready) + } + } + + @Synchronized + override fun cancelExtendedBolus(): PumpEnactResult { + // Not yet supported + return instantiator.providePumpEnactResult().apply { + success = false + enacted = false + comment = rh.gs(R.string.error_not_ready) + } + } + + @Synchronized + override fun timezoneOrDSTChanged(timeChangeType: TimeChangeType) { + if (!isInitialized()) return + service!!.syncDateTime("ApexService-timezoneOrDSTChanged") + } + + override fun addPreferenceScreen(preferenceManager: PreferenceManager, parent: PreferenceScreen, context: Context, requiredKey: String?) { + if (requiredKey != null) return + val category = PreferenceCategory(context) + parent.addPreference(category) + category.apply { + key = "apex_settings" + title = rh.gs(R.string.apex_settings) + initialExpandedChildrenCount = 0 + addPreference(AdaptiveStringPreference( + ctx = context, + stringKey = ApexStringKey.SerialNumber, + title = R.string.setting_serial_number, + )) + addPreference(AdaptiveListPreference( + ctx = context, + stringKey = ApexStringKey.AlarmSoundLength, + title = R.string.setting_alarm_length, + entries = arrayOf(rh.gs(R.string.setting_alarm_length_long), rh.gs(R.string.setting_alarm_length_medium), rh.gs(R.string.setting_alarm_length_short)), + entryValues = arrayOf(AlarmLength.Long.name, AlarmLength.Medium.name, AlarmLength.Short.name) + )) + addPreference(AdaptiveDoublePreference( + ctx = context, + doubleKey = ApexDoubleKey.MaxBasal, + title = R.string.setting_max_basal, + )) + addPreference(AdaptiveDoublePreference( + ctx = context, + doubleKey = ApexDoubleKey.MaxBolus, + title = R.string.setting_max_bolus, + )) + } + } +} diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/ApexService.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/ApexService.kt new file mode 100644 index 00000000000..7cc959fa14d --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/ApexService.kt @@ -0,0 +1,1210 @@ +package app.aaps.pump.apex + +import app.aaps.pump.apex.interfaces.ApexBluetoothCallback +import android.content.Intent +import android.os.Binder +import android.os.IBinder +import android.os.SystemClock +import app.aaps.core.data.model.BS +import app.aaps.core.data.model.TE +import app.aaps.core.data.pump.defs.PumpType +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.notifications.Notification +import app.aaps.core.interfaces.pump.DetailedBolusInfo +import app.aaps.core.interfaces.pump.PumpSync +import app.aaps.core.interfaces.queue.Command +import app.aaps.core.interfaces.queue.CommandQueue +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.core.interfaces.rx.bus.RxBus +import app.aaps.core.interfaces.rx.events.EventDismissNotification +import app.aaps.core.interfaces.rx.events.EventOverviewBolusProgress +import app.aaps.core.interfaces.rx.events.EventPreferenceChange +import app.aaps.core.interfaces.rx.events.EventProfileSwitchChanged +import app.aaps.core.interfaces.rx.events.EventPumpStatusChanged +import app.aaps.core.interfaces.ui.UiInteraction +import app.aaps.core.interfaces.utils.fabric.FabricPrivacy +import app.aaps.core.keys.Preferences +import app.aaps.core.utils.notifyAll +import app.aaps.core.utils.waitMillis +import app.aaps.pump.apex.connectivity.ApexBluetooth +import app.aaps.pump.apex.connectivity.ProtocolVersion +import app.aaps.pump.apex.connectivity.commands.device.Bolus +import app.aaps.pump.apex.connectivity.commands.device.CancelBolus +import app.aaps.pump.apex.connectivity.commands.device.CancelTemporaryBasal +import app.aaps.pump.apex.connectivity.commands.device.DeviceCommand +import app.aaps.pump.apex.connectivity.commands.device.ExtendedBolus +import app.aaps.pump.apex.connectivity.commands.device.GetValue +import app.aaps.pump.apex.connectivity.commands.device.NotifyAboutConnection +import app.aaps.pump.apex.connectivity.commands.device.SyncDateTime +import app.aaps.pump.apex.connectivity.commands.device.TemporaryBasal +import app.aaps.pump.apex.connectivity.commands.device.UpdateBasalProfileRates +import app.aaps.pump.apex.connectivity.commands.device.UpdateSystemState +import app.aaps.pump.apex.connectivity.commands.device.UpdateUsedBasalProfile +import app.aaps.pump.apex.connectivity.commands.pump.Alarm +import app.aaps.pump.apex.connectivity.commands.pump.AlarmLength +import app.aaps.pump.apex.connectivity.commands.pump.AlarmObject +import app.aaps.pump.apex.connectivity.commands.pump.BasalProfile +import app.aaps.pump.apex.connectivity.commands.pump.BolusEntry +import app.aaps.pump.apex.connectivity.commands.pump.CommandResponse +import app.aaps.pump.apex.connectivity.commands.pump.Heartbeat +import app.aaps.pump.apex.connectivity.commands.pump.PumpCommand +import app.aaps.pump.apex.connectivity.commands.pump.PumpObject +import app.aaps.pump.apex.connectivity.commands.pump.PumpObjectModel +import app.aaps.pump.apex.connectivity.commands.pump.StatusV1 +import app.aaps.pump.apex.connectivity.commands.pump.StatusV2 +import app.aaps.pump.apex.connectivity.commands.pump.TDDEntry +import app.aaps.pump.apex.connectivity.commands.pump.Version +import app.aaps.pump.apex.events.EventApexPumpDataChanged +import app.aaps.pump.apex.interfaces.ApexDeviceInfo +import app.aaps.pump.apex.utils.keys.ApexBooleanKey +import app.aaps.pump.apex.utils.keys.ApexDoubleKey +import app.aaps.pump.apex.utils.keys.ApexStringKey +import dagger.android.DaggerService +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import kotlinx.coroutines.sync.Mutex +import org.joda.time.DateTime +import java.util.Timer +import java.util.TimerTask +import javax.inject.Inject +import kotlin.concurrent.schedule +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * @author Roman Rikhter (teledurak@gmail.com) + */ +class ApexService: DaggerService(), ApexBluetoothCallback { + @Inject lateinit var aapsLogger: AAPSLogger + @Inject lateinit var aapsSchedulers: AapsSchedulers + @Inject lateinit var preferences: Preferences + @Inject lateinit var rxBus: RxBus + @Inject lateinit var apexBluetooth: ApexBluetooth + @Inject lateinit var apexDeviceInfo: ApexDeviceInfo + @Inject lateinit var apexPumpPlugin: ApexPumpPlugin + @Inject lateinit var uiInteraction: UiInteraction + @Inject lateinit var rh: ResourceHelper + @Inject lateinit var commandQueue: CommandQueue + @Inject lateinit var pumpSync: PumpSync + @Inject lateinit var fabricPrivacy: FabricPrivacy + @Inject lateinit var pump: ApexPump + + companion object { + const val USED_BASAL_PATTERN_INDEX = 7 + val FIRST_SUPPORTED_PROTO = ProtocolVersion.PROTO_4_10 + val LAST_SUPPORTED_PROTO = ProtocolVersion.PROTO_4_11 + } + + private data class InCommandResponse( + var response: CommandResponse? = null, + var waiting: Boolean = false, + ) { + fun clear(notify: Boolean = false) { + response = null + waiting = true + if (notify) this.notifyAll() + } + } + + private data class InGetValueResult( + var isSingleObject: Boolean = false, + var targetObject: PumpObject? = null, + var waiting: Boolean = false, + var response: ArrayList? = null, + ) { + fun add(data: PumpObjectModel) { + if (response == null) response = arrayListOf() + response!!.add(data) + } + + fun clear(notify: Boolean = false) { + response = null + targetObject = null + isSingleObject = false + waiting = true + if (notify) this.notifyAll() + } + } + + private val commandLock = Mutex() + private val disposable = CompositeDisposable() + + private val getValueResult = InGetValueResult() + private val commandResponse = InCommandResponse() + + private var timer = Timer("ApexService-timer") + + private var getValueLastTaskTimestamp: Long = 0 + private var unreachableTimerTask: TimerTask? = null + + private var lastBolusDateTime = DateTime(0) + private var lastConnectedTimestamp = System.currentTimeMillis() + + private var manualDisconnect = false + private var doNotReconnect = false + private var connectionFinished = false + + val isBusy: Boolean + get() = commandLock.isLocked + + val lastConnected: Long + get() = if (connectionStatus != ApexBluetooth.Status.CONNECTED) { + lastConnectedTimestamp + } else System.currentTimeMillis() + + private fun intGetValue(value: GetValue.Value): List? = synchronized(getValueResult) { + if (connectionStatus != ApexBluetooth.Status.CONNECTED) { + aapsLogger.debug(LTag.PUMPCOMM, "Get ${value.name} | Error - pump is disconnected") + return null + } + + getValueResult.clear() + getValueResult.targetObject = when (value) { + GetValue.Value.StatusV1 -> PumpObject.StatusV1 + GetValue.Value.StatusV2 -> PumpObject.StatusV2 + GetValue.Value.TDDs -> PumpObject.TDDEntry + GetValue.Value.Alarms -> PumpObject.AlarmEntry + GetValue.Value.BasalProfiles -> PumpObject.BasalProfile + GetValue.Value.Version -> PumpObject.FirmwareEntry + GetValue.Value.BolusHistory, GetValue.Value.LatestBoluses -> PumpObject.BolusEntry + GetValue.Value.LatestTemporaryBasals -> return null + GetValue.Value.WizardStatus -> return null + } + getValueResult.isSingleObject = when (value) { + GetValue.Value.StatusV1, GetValue.Value.StatusV2, GetValue.Value.Version -> true + else -> false + } + + apexBluetooth.send(GetValue(apexDeviceInfo, value)) + try { + aapsLogger.debug(LTag.PUMPCOMM, "Get ${value.name} | Waiting for response") + getValueResult.waitMillis(if (getValueResult.isSingleObject) 5000 else 15000) + } catch (e: InterruptedException) { + aapsLogger.error(LTag.PUMPCOMM, "Get ${value.name} | Timed out") + isGetThreadRunning = false + return null + } + + if (getValueResult.response == null) { + aapsLogger.error(LTag.PUMPCOMM, "Get ${value.name} | Timed out") + isGetThreadRunning = false + return null + } + + aapsLogger.debug(LTag.PUMPCOMM, "Get ${value.name} | Completed") + getValueResult.response + } + + private fun intExecuteWithResponse(command: DeviceCommand): CommandResponse? = synchronized(commandResponse) { + if (connectionStatus != ApexBluetooth.Status.CONNECTED) { + aapsLogger.debug(LTag.PUMPCOMM, "$command | Error - pump is disconnected") + return null + } + + commandResponse.clear() + apexBluetooth.send(command) + try { + aapsLogger.debug(LTag.PUMPCOMM, "$command | Waiting for response") + commandResponse.waitMillis(5000) + } catch (e: InterruptedException) { + aapsLogger.error(LTag.PUMPCOMM, "$command | Timed out") + commandResponse.waiting = false + return null + } + + if (commandResponse.response == null) { + aapsLogger.error(LTag.PUMPCOMM, "$command | Timed out") + commandResponse.waiting = false + return null + } + + aapsLogger.debug(LTag.PUMPCOMM, "$command | Completed") + commandResponse.response + } + + fun getValue(value: GetValue.Value): List? { + synchronized(commandLock) { + val firstTry = intGetValue(value) + if (firstTry != null || doNotReconnect || !connectionFinished) return@getValue firstTry + doNotReconnect = true + } + + disconnect(true) + if (!ensureConnected()) { + synchronized(commandLock) { doNotReconnect = false } + return null + } + + synchronized(commandLock) { + val final = intGetValue(value) + doNotReconnect = false + return@getValue final + } + } + + private fun executeWithResponse(command: DeviceCommand): CommandResponse? { + synchronized(commandLock) { + val firstTry = intExecuteWithResponse(command) + if (firstTry != null || doNotReconnect || !connectionFinished) return@executeWithResponse firstTry + doNotReconnect = true + } + + disconnect(true) + if (!ensureConnected()) { + synchronized(commandLock) { doNotReconnect = false } + return null + } + + synchronized(commandLock) { + val final = intExecuteWithResponse(command) + doNotReconnect = false + return@executeWithResponse final + } + } + + private fun ensureConnected(): Boolean { + var times = 0 + while (!connectionFinished && times < 50) { + aapsLogger.debug(LTag.PUMPCOMM, "Waiting for successful connection") + SystemClock.sleep(500) + times++ + } + return connectionFinished + } + + override fun onCreate() { + super.onCreate() + aapsLogger.debug(LTag.PUMP, "Service created") + apexBluetooth.setCallback(this) + + disposable += rxBus + .toObservable(EventPreferenceChange::class.java) + .observeOn(aapsSchedulers.io) + .subscribe({ + if (it.isChanged(ApexStringKey.SerialNumber.key)) { + onSerialChanged() + } else if (it.isChanged(ApexDoubleKey.MaxBolus.key) || it.isChanged(ApexDoubleKey.MaxBasal.key) || it.isChanged(ApexStringKey.AlarmSoundLength.key) && apexBluetooth.status == ApexBluetooth.Status.CONNECTED) { + updateSettings("ApexService-PreferencesListener") + } + }, fabricPrivacy::logException) + + pump.serialNumber = apexDeviceInfo.serialNumber + } + + override fun onDestroy() { + aapsLogger.debug(LTag.PUMP, "Service destroyed") + disposable.clear() + disconnect() + super.onDestroy() + } + + private fun onSerialChanged() { + pump.serialNumber = apexDeviceInfo.serialNumber + if (apexBluetooth.status != ApexBluetooth.Status.DISCONNECTED) disconnect() + startConnection() + } + + //////// Public methods + + fun syncDateTime(caller: String): Boolean { + aapsLogger.debug(LTag.PUMPCOMM, "syncDateTime - $caller") + val response = executeWithResponse(SyncDateTime(apexDeviceInfo, DateTime.now())) + if (response == null) { + aapsLogger.error(LTag.PUMPCOMM, "[syncDateTime caller=$caller] Timed out while trying to communicate with the pump") + return false + } + + if (response.code != CommandResponse.Code.Accepted) { + aapsLogger.error(LTag.PUMPCOMM, "[caller=$caller] Failed to sync time: ${response.code.name}") + return false + } + + return true + } + + fun notifyAboutConnection(caller: String): Boolean { + aapsLogger.debug(LTag.PUMPCOMM, "notifyAboutConnection - $caller") + val response = executeWithResponse(NotifyAboutConnection(apexDeviceInfo)) + if (response == null) { + aapsLogger.error(LTag.PUMPCOMM, "[notifyAboutConnection caller=$caller] Timed out while trying to communicate with the pump") + return false + } + + if (response.code != CommandResponse.Code.Accepted) { + aapsLogger.error(LTag.PUMPCOMM, "[caller=$caller] Failed to notify about connection: ${response.code.name}") + return false + } + + return true + } + + fun bolus(dbi: DetailedBolusInfo, caller: String): Boolean { + aapsLogger.debug(LTag.PUMPCOMM, "bolus - $caller") + if (dbi.insulin > pump.maxBolus) { + aapsLogger.error(LTag.PUMP, "[bolus caller=$caller] Requested ${dbi.insulin}U is greater than maximum set ${pump.maxBolus}") + return false + } + + val doseRaw = (dbi.insulin / 0.025).roundToInt() + + val response = executeWithResponse(Bolus(apexDeviceInfo, doseRaw)) + if (response == null) { + aapsLogger.error(LTag.PUMPCOMM, "[bolus caller=$caller] Timed out while trying to communicate with the pump") + return false + } + + if (response.code != CommandResponse.Code.Accepted) { + aapsLogger.error(LTag.PUMPCOMM, "[caller=$caller] Failed to begin bolus: ${response.code.name}") + return false + } + + val syncResult = pumpSync.addBolusWithTempId( + timestamp = dbi.timestamp, + amount = dbi.insulin, + temporaryId = dbi.timestamp, + type = dbi.bolusType, + pumpSerial = apexDeviceInfo.serialNumber, + pumpType = PumpType.APEX_TRUCARE_III, + ) + aapsLogger.debug(LTag.PUMP, "Initial bolus [${dbi.insulin}U] sync succeeded? $syncResult") + + pump.inProgressBolus = ApexPump.InProgressBolus( + requestedDose = dbi.insulin, + temporaryId = dbi.timestamp, + detailedBolusInfo = dbi, + treatment = EventOverviewBolusProgress.Treatment( + insulin = dbi.insulin, + carbs = dbi.carbs.toInt(), + isSMB = dbi.bolusType == BS.Type.SMB, + id = dbi.id + ) + ) + + getStatus("ApexService-bolus") + return true + } + + fun extendedBolus(dose: Double, durationMinutes: Int, caller: String): Boolean { + aapsLogger.debug(LTag.PUMPCOMM, "extendedBolus - $caller") + val doseRaw = (dose / 0.025).roundToInt() + + val durationRaw = durationMinutes / 15 + if (durationMinutes % 15 > 0) aapsLogger.warn(LTag.PUMPCOMM, "[extendedBolus caller=$caller] Bolus duration is not aligned to 15 minute steps! Rounded down.") + + val response = executeWithResponse(ExtendedBolus(apexDeviceInfo, doseRaw, durationRaw)) + if (response == null) { + aapsLogger.error(LTag.PUMPCOMM, "[extendedBolus caller=$caller] Timed out while trying to communicate with the pump") + return false + } + + if (response.code != CommandResponse.Code.Accepted) { + aapsLogger.error(LTag.PUMPCOMM, "[caller=$caller] Failed to begin extended bolus: ${response.code.name}") + return false + } + + return true + } + + fun temporaryBasal(dose: Double, durationMinutes: Int, type: PumpSync.TemporaryBasalType? = null, caller: String): Boolean { + aapsLogger.debug(LTag.PUMPCOMM, "temporaryBasal - $caller") + if (dose > pump.maxBasal) { + aapsLogger.error(LTag.PUMP, "[temporaryBasal caller=$caller] Requested ${dose}U is greater than maximum set ${pump.maxBasal}U") + return false + } + + val doseRaw = (dose / 0.025).roundToInt() + + val durationRaw = durationMinutes / 15 + if (durationMinutes % 15 > 0) aapsLogger.warn(LTag.PUMPCOMM, "[temporaryBasal caller=$caller] Bolus duration is not aligned to 15 minute steps! Rounded down.") + + val response = executeWithResponse(TemporaryBasal(apexDeviceInfo, true, durationRaw, doseRaw)) + if (response == null) { + aapsLogger.error(LTag.PUMPCOMM, "[temporaryBasal caller=$caller] Timed out while trying to communicate with the pump") + return false + } + + if (response.code != CommandResponse.Code.Accepted) { + aapsLogger.error(LTag.PUMPCOMM, "[caller=$caller] Failed to start temporary basal: ${response.code.name}") + return false + } + + val id = System.currentTimeMillis() + pumpSync.syncTemporaryBasalWithPumpId( + timestamp = id, + pumpId = id, + pumpType = PumpType.APEX_TRUCARE_III, + pumpSerial = apexDeviceInfo.serialNumber, + rate = dose, + duration = durationMinutes.toLong() * 60 * 1000, + isAbsolute = true, + type = type, + ) + + aapsLogger.debug(LTag.PUMP, "Started TBR ${dose}U for ${durationMinutes}min by $caller") + getStatus("ApexService-temporaryBasal") + return true + } + + fun cancelBolus(caller: String): Boolean { + aapsLogger.debug(LTag.PUMPCOMM, "cancelBolus - $caller") + val response = executeWithResponse(CancelBolus(apexDeviceInfo)) + if (response == null) { + aapsLogger.error(LTag.PUMPCOMM, "[cancelBolus caller=$caller] Timed out while trying to communicate with the pump") + return false + } + + if (response.code != CommandResponse.Code.Accepted) { + aapsLogger.error(LTag.PUMPCOMM, "[caller=$caller] Failed to cancel bolus: ${response.code.name}") + return false + } + + onBolusFailed(true) + getStatus("ApexService-cancelBolus") + return true + } + + fun cancelTemporaryBasal(caller: String): Boolean { + aapsLogger.debug(LTag.PUMPCOMM, "cancelTemporaryBasal - $caller") + val response = executeWithResponse(CancelTemporaryBasal(apexDeviceInfo)) + if (response == null) { + aapsLogger.error(LTag.PUMPCOMM, "[cancelTemporaryBasal caller=$caller] Timed out while trying to communicate with the pump") + return false + } + + if (response.code != CommandResponse.Code.Accepted) { + aapsLogger.error(LTag.PUMPCOMM, "[caller=$caller] Failed to cancel temporary basal: ${response.code.name}") + return false + } + + val stop = System.currentTimeMillis() + pumpSync.syncStopTemporaryBasalWithPumpId( + timestamp = stop, + endPumpId = stop, + pumpType = PumpType.APEX_TRUCARE_III, + pumpSerial = apexDeviceInfo.serialNumber, + ) + + getStatus("ApexService-cancelTBR") + return true + } + + fun updateSettings(caller: String): Boolean { + aapsLogger.debug(LTag.PUMPCOMM, "updateSettings - $caller") + val response = executeWithResponse( + pump.lastV1!!.toUpdateSettingsV1( + apexDeviceInfo, + AlarmLength.valueOf(preferences.get(ApexStringKey.AlarmSoundLength)), + maxSingleBolus = (preferences.get(ApexDoubleKey.MaxBolus) / 0.025).roundToInt(), + maxBasalRate = (preferences.get(ApexDoubleKey.MaxBasal) / 0.025).roundToInt(), + enableAdvancedBolus = false, + ) + ) + if (response == null) { + aapsLogger.error(LTag.PUMPCOMM, "[updateSettings caller=$caller] Timed out while trying to communicate with the pump") + return false + } + + if (response.code != CommandResponse.Code.Accepted) { + aapsLogger.error(LTag.PUMPCOMM, "[caller=$caller] Failed to update settings: ${response.code.name}") + return false + } + + return true + } + + fun updateSystemState(suspend: Boolean, caller: String): Boolean { + aapsLogger.debug(LTag.PUMPCOMM, "updateSystemState - $caller") + val response = executeWithResponse(UpdateSystemState(apexDeviceInfo, suspend)) + if (response == null) { + aapsLogger.error(LTag.PUMPCOMM, "[updateSystemState caller=$caller] Timed out while trying to communicate with the pump") + return false + } + + if (response.code != CommandResponse.Code.Accepted) { + aapsLogger.error(LTag.PUMPCOMM, "[caller=$caller] Failed to update system state: ${response.code.name}") + return false + } + + return true + } + + fun updateBasalPatternIndex(id: Int, caller: String): Boolean { + aapsLogger.debug(LTag.PUMPCOMM, "updateBasalPatternIndex - $caller") + val response = executeWithResponse(UpdateUsedBasalProfile(apexDeviceInfo, id)) + if (response == null) { + aapsLogger.error(LTag.PUMPCOMM, "[updateBasalPatternIndex caller=$caller] Timed out while trying to communicate with the pump") + return false + } + + if (response.code != CommandResponse.Code.Accepted) { + aapsLogger.error(LTag.PUMPCOMM, "[caller=$caller] Failed to update basal pattern index: ${response.code.name}") + return false + } + + if (!commandQueue.isRunning(Command.CommandType.BASAL_PROFILE)) rxBus.send(EventProfileSwitchChanged()) + return true + } + + fun updateCurrentBasalPattern(doses: List, caller: String): Boolean { + require(doses.size == 48) + + aapsLogger.debug(LTag.PUMPCOMM, "updateCurrentBasalPattern - $caller") + + val response = executeWithResponse(UpdateBasalProfileRates( + apexDeviceInfo, + doses.map { (it / 0.025).roundToInt() } + )) + if (response == null) { + aapsLogger.error(LTag.PUMPCOMM, "[updateBasalPatternIndex caller=$caller] Timed out while trying to communicate with the pump") + return false + } + + if (response.code != CommandResponse.Code.Accepted) { + aapsLogger.error(LTag.PUMPCOMM, "[caller=$caller] Failed to update basal pattern index: ${response.code.name}") + return false + } + + return true + } + + fun getTDDs(caller: String): Boolean { + aapsLogger.debug(LTag.PUMPCOMM, "getTDDs - $caller") + val response = getValue(GetValue.Value.TDDs) + if (response == null) { + aapsLogger.error(LTag.PUMPCOMM, "[getTDDs caller=$caller] Timed out while trying to communicate with the pump") + return false + } + + return true + } + + fun getBoluses(caller: String, isFullHistory: Boolean = false): Boolean { + rxBus.send(EventPumpStatusChanged(rh.gs(R.string.getting_boluses))) + + aapsLogger.debug(LTag.PUMPCOMM, "getBoluses - $caller") + val response = getValue(if (isFullHistory) GetValue.Value.BolusHistory else GetValue.Value.LatestBoluses) + if (response == null) { + aapsLogger.error(LTag.PUMPCOMM, "[getBoluses full=$isFullHistory caller=$caller] Timed out while trying to communicate with the pump") + return false + } + + return true + } + + fun getStatus(caller: String): Boolean { + rxBus.send(EventPumpStatusChanged(rh.gs(R.string.getting_pump_status))) + aapsLogger.debug(LTag.PUMPCOMM, "getStatus - $caller") + val responseV1 = getValue(GetValue.Value.StatusV1) + if (responseV1 == null) { + aapsLogger.error(LTag.PUMPCOMM, "[getStatus caller=$caller] V1 | Timed out while trying to communicate with the pump") + return false + } + + if ((pump.firmwareVersion?.protocolMinor ?: 0) >= 11) { + val responseV2 = getValue(GetValue.Value.StatusV2) + if (responseV2 == null) { + aapsLogger.error(LTag.PUMPCOMM, "[getStatus caller=$caller] V2 | Timed out while trying to communicate with the pump") + return false + } + } + + return true + } + + fun getBasalProfiles(caller: String): Map>? { + val ret = mutableMapOf>() + + aapsLogger.debug(LTag.PUMPCOMM, "getBasalProfiles - $caller") + + val response = getValue(GetValue.Value.BasalProfiles) + if (response == null) { + aapsLogger.error(LTag.PUMPCOMM, "[getBasalProfiles caller=$caller] Timed out while trying to communicate with the pump") + return null + } + + for (i in response) { + require(i is BasalProfile) + ret[i.index] = i.rates.map { it * 0.025 } + } + + return ret + } + + //////// Public values + + val connectionStatus: ApexBluetooth.Status + get() = apexBluetooth.status + + //////// Pump commands handlers + + private fun onBolusProgress(dose: Double) { + aapsLogger.debug(LTag.PUMPCOMM, "bolus progress $dose") + pump.inProgressBolus?.currentDose = dose + + val bolus = pump.inProgressBolus ?: return + + if (bolus.detailedBolusInfo.bolusType == BS.Type.SMB) { + rxBus.send(EventPumpStatusChanged(rh.gs(app.aaps.core.ui.R.string.smb_bolus_u, bolus.requestedDose))) + } else { + rxBus.send(EventPumpStatusChanged(rh.gs(app.aaps.core.ui.R.string.bolus_u_min, bolus.requestedDose))) + } + + rxBus.send(EventOverviewBolusProgress.apply { + t = bolus.treatment + percent = (bolus.currentDose / bolus.requestedDose * 100).roundToInt() + status = rh.gs(R.string.status_delivering, dose) + }) + } + + private fun onBolusCompleted(dose: Double) { + aapsLogger.debug(LTag.PUMPCOMM, "bolus completed") + if (pump.inProgressBolus == null) return + pump.inProgressBolus!!.currentDose = dose + + rxBus.send(EventOverviewBolusProgress.apply { + percent = 100 + status = rh.gs(R.string.status_delivered, dose) + }) + + // Request new bolus history to fixup bolus ID. + Thread { getBoluses("ApexService-onBolusCompleted") }.start() + } + + private fun onBolusFailed(cancelled: Boolean = false) { + aapsLogger.debug(LTag.PUMPCOMM, "bolus failed (cancelled? $cancelled)") + if (pump.inProgressBolus == null) return + + if (cancelled) { + pump.inProgressBolus!!.cancelled = true + rxBus.send(EventOverviewBolusProgress.apply { + status = rh.gs(R.string.status_bolus_cancelled) + }) + } + + if (pump.inProgressBolus!!.currentDose >= 0.025) { + // Request new bolus history to fixup bolus ID and delivered amount. + Thread { getBoluses("ApexService-onBolusCompleted") }.start() + } else { + aapsLogger.debug(LTag.PUMPCOMM, "bolus entirely failed!") + synchronized(pump.inProgressBolus!!) { + pump.inProgressBolus!!.failed = true + pump.inProgressBolus!!.notifyAll() + } + SystemClock.sleep(10) + pump.inProgressBolus = null + } + } + + private fun onCommandResponse(response: CommandResponse) { + aapsLogger.debug(LTag.PUMPCOMM, "got command response - ${response.code.name} / ${response.dose}") + when (response.code) { + CommandResponse.Code.Accepted, CommandResponse.Code.Invalid -> { + if (!commandResponse.waiting) return + commandResponse.response = response + commandResponse.waiting = false + synchronized(commandResponse) { + commandResponse.notifyAll() + } + } + CommandResponse.Code.StandardBolusProgress -> onBolusProgress(response.dose * 0.025) + CommandResponse.Code.ExtendedBolusProgress -> return + CommandResponse.Code.Completed -> onBolusCompleted(response.dose * 0.025) + else -> return + } + } + + private fun onAlarmsChanged(update: ApexPump.StatusUpdate) { + val prev = update.previous?.alarms + // Alarm was dismissed + if (!prev.isNullOrEmpty() && update.current.alarms.isEmpty()) { + rxBus.send(EventDismissNotification(Notification.PUMP_ERROR)) + rxBus.send(EventDismissNotification(Notification.PUMP_WARNING)) + } + + // New alarms + if (prev.isNullOrEmpty() && update.current.alarms.isNotEmpty()) { + var anyUrgent = false + + for (alarm in update.current.alarms) { + val name = when (alarm) { + Alarm.NoDosage, Alarm.NoDelivery -> rh.gs(R.string.alarm_occlusion) + Alarm.NoReservoir -> rh.gs(R.string.alarm_reservoir_empty) + Alarm.DeadBattery -> rh.gs(R.string.alarm_battery_dead) + Alarm.LowBattery -> rh.gs(R.string.alarm_w_battery_low) + Alarm.LowReservoir -> rh.gs(R.string.alarm_w_reservoir_low) + Alarm.EncoderError, Alarm.FRAMError, Alarm.ClockError, Alarm.TimeError, + Alarm.TimeAnomalyError, Alarm.MotorAbnormal, Alarm.MotorPowerAbnormal, + Alarm.MotorError -> rh.gs(R.string.alarm_hardware_fault, alarm.name) + Alarm.Unknown -> rh.gs(R.string.alarm_unknown_error) + Alarm.CheckGlucose -> rh.gs(R.string.alarm_check_bg) + else -> rh.gs(R.string.alarm_unknown_error_name, alarm.name) + } + val isUrgent = when(alarm) { + Alarm.LowBattery, Alarm.LowReservoir, Alarm.CheckGlucose -> false + else -> true + } + if (isUrgent) anyUrgent = true + + uiInteraction.addNotification( + if (isUrgent) Notification.PUMP_ERROR else Notification.PUMP_WARNING, + rh.gs(R.string.alarm_label, name), + if (isUrgent) Notification.URGENT else Notification.NORMAL, + ) + pumpSync.insertAnnouncement( + error = rh.gs(R.string.alarm_label, name), + pumpType = PumpType.APEX_TRUCARE_III, + pumpSerial = apexDeviceInfo.serialNumber, + ) + } + + if (anyUrgent && pump.isBolusing) { + // Pump sends early heartbeat while bolusing if there's an error while bolusing. + aapsLogger.error(LTag.PUMP, "Bolus has failed!") + Thread { onBolusFailed() }.start() + } + } + } + + private fun onBasalChanged(update: ApexPump.StatusUpdate) { + if (update.current.basal == null) { + uiInteraction.addNotification( + Notification.PUMP_SUSPENDED, + rh.gs(R.string.notification_pump_is_suspended), + if (pump.isBolusing) Notification.URGENT else Notification.NORMAL, + ) + commandQueue.loadEvents(null) + return + } else { + rxBus.send(EventDismissNotification(Notification.PUMP_SUSPENDED)) + } + } + + private fun onSettingsChanged(update: ApexPump.StatusUpdate) { + if (pump.settingsAreUnadvised && preferences.get(ApexDoubleKey.MaxBasal) != 0.0 && preferences.get(ApexDoubleKey.MaxBolus) != 0.0) updateSettings("ApexService-onSettingsChanged") + if (update.current.currentBasalPattern != USED_BASAL_PATTERN_INDEX) updateBasalPatternIndex(USED_BASAL_PATTERN_INDEX, "ApexService-onSettingsChanged") + } + + private fun onBatteryChanged(update: ApexPump.StatusUpdate) { + val cur = update.current + update.previous?.let { old -> + // Percentage became higher - battery was changed. + if (cur.batteryLevel.percentage - 26 > old.batteryLevel.percentage && preferences.get(ApexBooleanKey.LogBatteryChange)) { + pumpSync.insertTherapyEventIfNewWithTimestamp( + timestamp = System.currentTimeMillis(), + pumpType = PumpType.APEX_TRUCARE_III, + pumpSerial = apexDeviceInfo.serialNumber, + type = TE.Type.PUMP_BATTERY_CHANGE, + ) + aapsLogger.debug(LTag.PUMP, "Logged battery change") + } + } + } + + private fun onReservoirChanged(update: ApexPump.StatusUpdate) { + val cur = update.current + update.previous?.let { old -> + // Reservoir level became higher - insulin was changed. + if (cur.reservoirLevel - 2 > old.reservoirLevel && preferences.get(ApexBooleanKey.LogInsulinChange)) { + pumpSync.insertTherapyEventIfNewWithTimestamp( + timestamp = System.currentTimeMillis(), + pumpType = PumpType.APEX_TRUCARE_III, + pumpSerial = apexDeviceInfo.serialNumber, + type = TE.Type.INSULIN_CHANGE, + ) + aapsLogger.debug(LTag.PUMP, "Logged insulin change") + } + } + } + + private fun onTBRChanged(update: ApexPump.StatusUpdate) { + // if (update.current.tbr == null && update.previous?.tbr != null) { + // val stop = System.currentTimeMillis() + // pumpSync.syncStopTemporaryBasalWithPumpId( + // timestamp = stop, + // endPumpId = stop, + // pumpType = PumpType.APEX_TRUCARE_III, + // pumpSerial = apexDeviceInfo.serialNumber, + // ) + // aapsLogger.debug(LTag.PUMP, "Detected TBR cancellation") + // } + } + + private fun onConstraintsChanged(update: ApexPump.StatusUpdate) { + preferences.put(ApexDoubleKey.MaxBasal, update.current.maxBasal) + preferences.put(ApexDoubleKey.MaxBolus, update.current.maxBolus) + } + + private fun onStatusV1(status: StatusV1) { + val update = pump.updateFromV1(status) + aapsLogger.debug(LTag.PUMPCOMM, "Got V1 | Status updates: ${update.changes.joinToString(", ") { it.name }}") + + preferences.put(ApexDoubleKey.MaxBasal, update.current.maxBasal) + preferences.put(ApexDoubleKey.MaxBolus, update.current.maxBolus) + apexPumpPlugin.updatePumpDescription() + + onAlarmsChanged(update) + onBasalChanged(update) + onSettingsChanged(update) + onBatteryChanged(update) + onReservoirChanged(update) + onTBRChanged(update) + onConstraintsChanged(update) + rxBus.send(EventApexPumpDataChanged()) + } + + private fun onStatusV2(status: StatusV2) { + val update = pump.updateFromV2(status) + aapsLogger.debug(LTag.PUMPCOMM, "Got V2 | Status updates: ${update.changes.joinToString(", ") { it.name }}") + + //onBatteryChanged(update) + rxBus.send(EventApexPumpDataChanged()) + } + + private fun onHeartbeat() { + aapsLogger.debug(LTag.PUMPCOMM, "Got heartbeat") + + // Pump sent heartbeat => connection is established. + pump.gettingReady = false + + if (!getStatus("HeartbeatHandler")) return + if (!getBoluses("HeartbeatHandler")) return + + rxBus.send(EventPumpStatusChanged(EventPumpStatusChanged.Status.CONNECTED)) + } + + private fun onVersion(version: Version) { + aapsLogger.debug(LTag.PUMPCOMM, "Got version - $version") + } + + @Synchronized + private fun onBolusEntry(entry: BolusEntry) { + // Extended bolus entries do not have duration stored, do not use them. + if (entry.extendedDose > 0) return + + aapsLogger.debug(LTag.PUMP, "Processing bolus [${entry.standardDose * 0.025}U -> ${entry.standardPerformed * 0.025}U] on ${entry.dateTime}") + + if (entry.dateTime > lastBolusDateTime) { + lastBolusDateTime = entry.dateTime + pump.lastBolus = entry + rxBus.send(EventApexPumpDataChanged()) + } + + // Find the bolus in history and sync it. + // Pump may round up boluses, use 0.11 for failsafe. + val ipb = pump.inProgressBolus + if (ipb != null && entry.dateTime.millis - ipb.temporaryId >= -45000) { + aapsLogger.debug(LTag.PUMP, "Syncing current bolus [${entry.standardDose * 0.025}U -> ${entry.standardPerformed * 0.025}U]") + val delta = abs(entry.standardDose * 0.025 - ipb.currentDose) + if (!ipb.cancelled && delta > 0.11) { + aapsLogger.debug(LTag.PUMP, "Not this bolus: $delta > 0.11") + return + } + + val syncResult = pumpSync.syncBolusWithTempId( + timestamp = entry.dateTime.millis, + temporaryId = ipb.temporaryId, + amount = entry.standardPerformed * 0.025, + pumpId = entry.dateTime.millis, + pumpType = PumpType.APEX_TRUCARE_III, + pumpSerial = apexDeviceInfo.serialNumber, + type = ipb.detailedBolusInfo.bolusType, + ) + aapsLogger.debug(LTag.PUMP, "Final bolus [${entry.standardDose * 0.025}U -> ${entry.standardPerformed * 0.025}U] sync succeeded? $syncResult") + synchronized(pump.inProgressBolus!!) { + pump.inProgressBolus!!.notifyAll() + } + SystemClock.sleep(10) + pump.inProgressBolus = null + + getStatus("ApexService-updateAfterBolus") + return + } + if (ipb != null && entry.index < 2) return + + // Otherwise, just sync the bolus with the DB + pumpSync.syncBolusWithPumpId( + timestamp = entry.dateTime.millis, + pumpId = entry.dateTime.millis, + amount = entry.standardPerformed * 0.025, + pumpType = PumpType.APEX_TRUCARE_III, + pumpSerial = apexDeviceInfo.serialNumber, + type = null, + ) + aapsLogger.debug(LTag.PUMP, "Synced bolus ${entry.standardPerformed * 0.025}U on ${entry.dateTime}") + } + + // !! Unreliable on 6.25 firmware, TODO: think about solution + private fun onTDDEntry(entry: TDDEntry) { + pumpSync.createOrUpdateTotalDailyDose( + timestamp = entry.dateTime.millis, + pumpId = entry.dateTime.millis, + pumpType = PumpType.APEX_TRUCARE_III, + pumpSerial = apexDeviceInfo.serialNumber, + bolusAmount = entry.bolus * 0.025, + basalAmount = entry.basal * 0.025 + entry.temporaryBasal * 0.025, + totalAmount = entry.total * 0.025, + ) + aapsLogger.debug(LTag.PUMP, "Synced TDD ${entry.total * 0.025}U on ${entry.dateTime}") + } + + //////// BLE + + private fun onInitialConnection() { + preferences.put(ApexDoubleKey.MaxBasal, 0.0) + preferences.put(ApexDoubleKey.MaxBolus, 0.0) + pumpSync.connectNewPump() + } + + fun startConnection() { + if (apexDeviceInfo.serialNumber.isEmpty()) return + manualDisconnect = false + apexBluetooth.connect() + } + + fun disconnect(isReconnect: Boolean = false) { + manualDisconnect = !isReconnect + if (apexBluetooth.status != ApexBluetooth.Status.DISCONNECTED) + apexBluetooth.disconnect() + else if (isReconnect) + apexBluetooth.connect() + } + + + override fun onConnect() { + aapsLogger.debug(LTag.PUMPCOMM, "onConnect") + + val version = getValue(GetValue.Value.Version)?.firstOrNull() + if (version !is Version) { + aapsLogger.error(LTag.PUMPCOMM, "Failed to get version - disconnecting.") + return disconnect(true) + } + + aapsLogger.debug(LTag.PUMPCOMM, version.toString()) + + pump.firmwareVersion = version + + if (!version.isSupported(FIRST_SUPPORTED_PROTO, LAST_SUPPORTED_PROTO)) { + aapsLogger.error(LTag.PUMPCOMM, "Unsupported protocol v${version.protocolMajor}.${version.protocolMinor} - disconnecting.") + uiInteraction.addNotification( + Notification.PUMP_ERROR, + rh.gs(R.string.notification_pump_unsupported), + Notification.URGENT, + ) + return disconnect() + } + + onVersion(version) + + if (!syncDateTime("BLE-onConnect")) { + aapsLogger.error(LTag.PUMPCOMM, "Failed to sync date and time - disconnecting.") + return disconnect(true) + } + if (!notifyAboutConnection("BLE-onConnect")) { + aapsLogger.error(LTag.PUMPCOMM, "Failed to notify about connection - disconnecting.") + return disconnect(true) + } + + if (apexDeviceInfo.serialNumber != preferences.get(ApexStringKey.LastConnectedSerialNumber)) { + onInitialConnection() + preferences.put(ApexStringKey.LastConnectedSerialNumber, apexDeviceInfo.serialNumber) + } + + rxBus.send(EventPumpStatusChanged(EventPumpStatusChanged.Status.CONNECTED)) + if (!getStatus("BLE-onConnect")) { + aapsLogger.error(LTag.PUMPCOMM, "Failed to get status - disconnecting.") + return disconnect(true) + } + if (!getBoluses("BLE-onConnect")) { + aapsLogger.error(LTag.PUMPCOMM, "Failed to get boluses - disconnecting.") + return disconnect(true) + } + + unreachableTimerTask?.cancel() + unreachableTimerTask = null + rxBus.send(EventPumpStatusChanged(EventPumpStatusChanged.Status.CONNECTED)) + pump.gettingReady = false + connectionFinished = true + } + + private var isDisconnectLoopRunning = false + private fun spawnLoop() { + if (isDisconnectLoopRunning) return + isDisconnectLoopRunning = true + Thread { + while (connectionStatus != ApexBluetooth.Status.CONNECTED && !manualDisconnect) { + if (connectionStatus == ApexBluetooth.Status.DISCONNECTED) { + aapsLogger.debug(LTag.PUMPCOMM, "Starting connection loop") + startConnection() + } + SystemClock.sleep(100) + } + + if (manualDisconnect) { + aapsLogger.debug(LTag.PUMPCOMM, "Manual disconnect detected!") + disconnect() + } + + aapsLogger.debug(LTag.PUMPCOMM, "Exiting") + isDisconnectLoopRunning = false + }.start() + } + + override fun onDisconnect() { + aapsLogger.debug(LTag.PUMPCOMM, "onDisconnect") + connectionFinished = false + + isGetThreadRunning = false + synchronized(getValueResult) { + getValueResult.waiting = false + getValueResult.notifyAll() + } + synchronized(commandResponse) { + commandResponse.waiting = false + commandResponse.notifyAll() + } + + + lastConnectedTimestamp = System.currentTimeMillis() + + if (unreachableTimerTask == null) + unreachableTimerTask = timer.schedule(120000) { + uiInteraction.addNotification( + Notification.PUMP_UNREACHABLE, + rh.gs(R.string.error_pump_unreachable), + Notification.URGENT, + ) + aapsLogger.error(LTag.PUMP, "Pump unreachable!") + } + + if (!manualDisconnect) spawnLoop() + } + + private var isGetThreadRunning = false + override fun onPumpCommand(command: PumpCommand) { + if (command.id == null) { + aapsLogger.error(LTag.PUMPCOMM, "Invalid command with crc ${command.checksum}") + return + } + val type = PumpObject.findObject(command.id!!, command.objectData, aapsLogger) + aapsLogger.debug(LTag.PUMPCOMM, "from PUMP: ${command.id!!.name}, ${type?.name}") + + if (type == null) return + if (type == PumpObject.CommandResponse) return onCommandResponse(CommandResponse(command)) + + notifyAboutResponse(command, type) + Thread { processObject(command, type) }.start() + } + + private fun processObject(command: PumpCommand, type: PumpObject) { + when (type) { + PumpObject.StatusV1 -> onStatusV1(StatusV1(command)) + PumpObject.StatusV2 -> onStatusV2(StatusV2(command)) + PumpObject.Heartbeat -> onHeartbeat() + PumpObject.BolusEntry -> onBolusEntry(BolusEntry(command)) + PumpObject.TDDEntry -> onTDDEntry(TDDEntry(command)) + else -> {} + } + } + + @Synchronized + private fun notifyAboutResponse(command: PumpCommand, type: PumpObject) { + if (!getValueResult.waiting) { + aapsLogger.debug(LTag.PUMPCOMM, "Got pump command but not waiting for it") + return + } + if (type != getValueResult.targetObject) { + aapsLogger.debug(LTag.PUMPCOMM, "Got incorrect object type (${type.name} vs ${getValueResult.targetObject?.name})") + return + } + + // Pump may send fake bolus entry if there are no boluses in history. + // We shouldn't handle it. + if (command.objectData[2].toUInt().toInt() == 0xFF && command.objectData[3].toUInt().toInt() == 0xFF) { + aapsLogger.debug(LTag.PUMPCOMM, "Got fake bolus entry - skipping") + getValueResult.waiting = false + getValueResult.response = arrayListOf() + synchronized(getValueResult) { + getValueResult.notifyAll() + } + return + } + + getValueResult.add( + when (type) { + PumpObject.Heartbeat -> Heartbeat() + PumpObject.CommandResponse -> CommandResponse(command) + PumpObject.StatusV1 -> StatusV1(command) + PumpObject.StatusV2 -> StatusV2(command) + PumpObject.BasalProfile -> BasalProfile(command) + PumpObject.AlarmEntry -> AlarmObject(command) + PumpObject.TDDEntry -> TDDEntry(command) + PumpObject.BolusEntry -> BolusEntry(command) + PumpObject.FirmwareEntry -> Version(command) + else -> return + } + ) + + if (getValueResult.isSingleObject) { + aapsLogger.debug(LTag.PUMPCOMM, "Got single value - everything is ready") + getValueResult.waiting = false + synchronized(getValueResult) { + getValueResult.notifyAll() + } + } else { + aapsLogger.debug(LTag.PUMPCOMM, "Updating last timestamp") + getValueLastTaskTimestamp = System.currentTimeMillis() + runGetThread() + } + } + + @Synchronized + private fun runGetThread() { + if (isGetThreadRunning) return + isGetThreadRunning = true + aapsLogger.debug(LTag.PUMPCOMM, "Running GET thread") + Thread { + while (isGetThreadRunning) { + val now = System.currentTimeMillis() + if (now - getValueLastTaskTimestamp >= 500) { + break + } else { + aapsLogger.debug(LTag.PUMPCOMM, "Response is not ready yet") + } + SystemClock.sleep(100) + } + if (!isGetThreadRunning) { + aapsLogger.debug(LTag.PUMPCOMM, "GET thread killed") + return@Thread + } + isGetThreadRunning = false + + aapsLogger.debug(LTag.PUMPCOMM, "Chunked response has completed") + getValueResult.waiting = false + synchronized(getValueResult) { + getValueResult.notifyAll() + } + }.start() + // Let thread start + SystemClock.sleep(10) + } + + //////// Binder + + private val binder = LocalBinder() + override fun onBind(intent: Intent?): IBinder { + aapsLogger.debug(LTag.PUMP, "Binding service") + return binder + } + + inner class LocalBinder : Binder() { + val serviceInstance: ApexService + get() = this@ApexService + } + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + aapsLogger.debug(LTag.PUMP, "Service started") + return START_STICKY + } +} \ No newline at end of file diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/ApexBluetooth.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/ApexBluetooth.kt new file mode 100644 index 00000000000..7d54aca8cfd --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/ApexBluetooth.kt @@ -0,0 +1,311 @@ +package app.aaps.pump.apex.connectivity + +import app.aaps.pump.apex.interfaces.ApexBluetoothCallback +import android.Manifest +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothManager +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.os.ParcelUuid +import android.os.SystemClock +import androidx.core.app.ActivityCompat +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.rx.bus.RxBus +import app.aaps.core.interfaces.rx.events.EventPumpStatusChanged +import app.aaps.core.keys.Preferences +import app.aaps.core.ui.toast.ToastUtils +import app.aaps.core.utils.toHex +import app.aaps.pump.apex.R +import app.aaps.pump.apex.connectivity.commands.device.DeviceCommand +import app.aaps.pump.apex.connectivity.commands.pump.PumpCommand +import app.aaps.pump.apex.utils.keys.ApexStringKey +import kotlinx.coroutines.sync.Mutex +import java.util.UUID +import javax.inject.Inject + +class ApexBluetooth @Inject constructor( + val aapsLogger: AAPSLogger, + val preferences: Preferences, + val context: Context, + val rxBus: RxBus, +) : ScanCallback() { + companion object { + private val READ_SERVICE = ParcelUuid.fromString("0000FFE0-0000-1000-8000-00805F9B34FB") + private val WRITE_SERVICE = ParcelUuid.fromString("0000FFE5-0000-1000-8000-00805F9B34FB") + + private val READ_UUID = UUID.fromString("0000FFE4-0000-1000-8000-00805F9B34FB") + private val WRITE_UUID = UUID.fromString("0000FFE9-0000-1000-8000-00805F9B34FB") + private val CCC_UUID = UUID.fromString("00002902-0000-1000-8000-00805F9B34FB") + + private const val WRITE_DELAY_MS = 250 + } + + private val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter + private var callback: ApexBluetoothCallback? = null + + private var bluetoothDevice: BluetoothDevice? = null + private var bluetoothGatt: BluetoothGatt? = null + private var writeCharacteristic: BluetoothGattCharacteristic? = null + private var readCharacteristic: BluetoothGattCharacteristic? = null + + private var mtu: Int = 512 + + private val readMutex = Mutex() + private var lastCommand: PumpCommand? = null + private var _status: Status = Status.DISCONNECTED + + val status: Status + get() = _status + + fun setCallback(callback: ApexBluetoothCallback) { + this.callback = callback + } + + @Suppress("DEPRECATION") + @SuppressLint("MissingPermission") + @Synchronized + fun send(command: DeviceCommand) { + if (checkBT()) return + if (status != Status.CONNECTED) return + + Thread { + SystemClock.sleep(WRITE_DELAY_MS.toLong()) + val data = command.serialize() + aapsLogger.debug(LTag.PUMPBTCOMM, "DEVICE -> ${data.toHex()}") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + bluetoothGatt!!.writeCharacteristic( + writeCharacteristic!!, + data, + BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) + } else { + writeCharacteristic!!.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + writeCharacteristic!!.setValue(data) + bluetoothGatt!!.writeCharacteristic(writeCharacteristic!!) + } + }.start() + } + + @SuppressLint("MissingPermission") + @Synchronized + fun connect() { + aapsLogger.debug(LTag.PUMPBTCOMM, "Connect") + if (preferences.get(ApexStringKey.SerialNumber).isEmpty()) return + if (checkBT()) return + _status = Status.CONNECTING + if (preferences.get(ApexStringKey.BluetoothAddress).isNotEmpty()) return reconnect() + + aapsLogger.debug(LTag.PUMPBTCOMM, "Scan started") + bluetoothAdapter.bluetoothLeScanner.startScan( + listOf( + ScanFilter.Builder() + .setDeviceName("APEX${preferences.get(ApexStringKey.SerialNumber)}") + .build(), + ScanFilter.Builder() + .setServiceUuid(READ_SERVICE) + .build(), + ScanFilter.Builder() + .setServiceUuid(WRITE_SERVICE) + .build(), + ), + ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build(), this + ) + } + + @SuppressLint("MissingPermission") + @Synchronized + fun disconnect() { + aapsLogger.debug(LTag.PUMPBTCOMM, "Disconnect") + rxBus.send(EventPumpStatusChanged(EventPumpStatusChanged.Status.DISCONNECTING)) + if (checkBT()) return + when (status) { + Status.CONNECTED -> { + bluetoothGatt?.disconnect() + } + Status.CONNECTING -> { + stopScan() + SystemClock.sleep(100) + bluetoothGatt?.close() + SystemClock.sleep(100) + bluetoothGatt = null + } + else -> return + } + rxBus.send(EventPumpStatusChanged(EventPumpStatusChanged.Status.DISCONNECTED)) + } + + private fun checkBT(): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && ActivityCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) { + ToastUtils.errorToast(context, context.getString(app.aaps.core.ui.R.string.need_connect_permission)) + aapsLogger.error(LTag.PUMPBTCOMM, "No Bluetooth permission!") + return true + } + + if (bluetoothAdapter == null) { + aapsLogger.error(LTag.PUMPBTCOMM, "No Bluetooth adapter!") + return true + } + return false + } + + @Synchronized + @SuppressLint("MissingPermission") + private fun setupGatt() { + bluetoothGatt = bluetoothDevice!!.connectGatt(context, false, object : BluetoothGattCallback() { + override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) { + super.onConnectionStateChange(gatt, status, newState) + when (newState) { + BluetoothGatt.STATE_DISCONNECTED -> { + rxBus.send(EventPumpStatusChanged(EventPumpStatusChanged.Status.DISCONNECTED)) + _status = Status.DISCONNECTED + aapsLogger.debug(LTag.PUMPBTCOMM, "Disconnected") + Thread { callback?.onDisconnect() }.start() + bluetoothGatt?.close() + } + BluetoothGatt.STATE_CONNECTED -> { + bluetoothGatt?.discoverServices() + aapsLogger.debug(LTag.PUMPBTCOMM, "Connecting | Discovering services") + } + } + } + + override fun onMtuChanged(gatt: BluetoothGatt?, mtu: Int, status: Int) { + super.onMtuChanged(gatt, mtu, status) + this@ApexBluetooth.mtu = mtu + aapsLogger.debug(LTag.PUMPBTCOMM, "Connecting | Got MTU $mtu") + } + + @Suppress("DEPRECATION") + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { + if (status != BluetoothGatt.GATT_SUCCESS) return + + Thread { + gatt.requestMtu(512) + SystemClock.sleep(150) + + aapsLogger.debug(LTag.PUMPBTCOMM, "Connecting | Requesting notification") + + writeCharacteristic = gatt.getService(WRITE_SERVICE.uuid).getCharacteristic(WRITE_UUID) + readCharacteristic = gatt.getService(READ_SERVICE.uuid).getCharacteristic(READ_UUID) + gatt.setCharacteristicNotification(readCharacteristic, true) + + val ccc = readCharacteristic!!.getDescriptor(CCC_UUID) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.writeDescriptor(ccc, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) + } else { + ccc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + gatt.writeDescriptor(ccc) + } + }.start() + } + + override fun onDescriptorWrite(gatt: BluetoothGatt?, descriptor: BluetoothGattDescriptor?, status: Int) { + super.onDescriptorWrite(gatt, descriptor, status) + Thread { + aapsLogger.debug(LTag.PUMPBTCOMM, "Connected | Notification status: $status") + gatt?.setCharacteristicNotification(readCharacteristic, true) + SystemClock.sleep(10) + _status = Status.CONNECTED + callback?.onConnect() + aapsLogger.debug(LTag.PUMPBTCOMM, "Connected") + }.start() + } + + @Suppress("OVERRIDE_DEPRECATION", "DEPRECATION") + override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { + super.onCharacteristicRead(gatt, characteristic, status) + onPumpData(characteristic, characteristic.value) + } + + @Suppress("OVERRIDE_DEPRECATION", "DEPRECATION") + override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { + super.onCharacteristicChanged(gatt, characteristic) + onPumpData(characteristic, characteristic.value) + } + }, BluetoothDevice.TRANSPORT_LE) + + if (bluetoothGatt == null) { + aapsLogger.error(LTag.PUMPBTCOMM, "Connecting | Failed to set up GATT") + _status = Status.DISCONNECTED + } + } + + private fun onPumpData(characteristic: BluetoothGattCharacteristic, value: ByteArray) { + aapsLogger.debug(LTag.PUMPBTCOMM, "PUMP <- ${value.toHex()}") + when (characteristic.uuid) { + READ_UUID -> synchronized(readMutex) { + // Update command or create new one + if (lastCommand?.isCompleteCommand() == false) + lastCommand!!.update(value) + else if (value.size > PumpCommand.MIN_SIZE) + lastCommand = PumpCommand(value) + else + aapsLogger.error(LTag.PUMPBTCOMM, "Got invalid command of length ${value.size}") + + while (lastCommand != null && lastCommand!!.isCompleteCommand()) { + if (!lastCommand!!.verify()) { + aapsLogger.error(LTag.PUMPBTCOMM, "[${lastCommand!!.id?.name}] Command checksum is invalid! Expected ${lastCommand!!.checksum.toHex()}") + return + } + + callback?.onPumpCommand(lastCommand!!) + lastCommand = lastCommand!!.trailing + } + } + } + } + + @SuppressLint("MissingPermission") + @Synchronized + private fun reconnect() { + aapsLogger.debug(LTag.PUMPBTCOMM, "Connecting | Setting up GATT") + bluetoothDevice = bluetoothAdapter!!.getRemoteDevice(preferences.get(ApexStringKey.BluetoothAddress)) + setupGatt() + } + + @SuppressLint("MissingPermission") + @Synchronized + private fun stopScan() { + aapsLogger.debug(LTag.PUMPBTCOMM, "Scan stopped") + bluetoothAdapter?.bluetoothLeScanner?.stopScan(this) + } + + @SuppressLint("MissingPermission") + @Synchronized + override fun onScanResult(callbackType: Int, result: ScanResult?) { + super.onScanResult(callbackType, result) + if (result == null) { + _status = Status.DISCONNECTED + return + } + aapsLogger.debug(LTag.PUMPBTCOMM, "Found device ${result.device.name}") + stopScan() + preferences.put(ApexStringKey.BluetoothAddress, result.device.address) + reconnect() + } + + enum class Status { + DISCONNECTED, + CONNECTING, + CONNECTED; + + fun toLocalString(rh: ResourceHelper): String = when (this) { + DISCONNECTED -> rh.gs(R.string.overview_connection_status_disconnected) + CONNECTING -> rh.gs(R.string.overview_connection_status_connecting) + CONNECTED -> rh.gs(R.string.overview_connection_status_connected) + } + } +} \ No newline at end of file diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/ProtocolVersion.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/ProtocolVersion.kt new file mode 100644 index 00000000000..ec3cee1135b --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/ProtocolVersion.kt @@ -0,0 +1,15 @@ +package app.aaps.pump.apex.connectivity + +enum class ProtocolVersion( + val major: Int, + val minor: Int, +) { + /** The first publicly available protocol. + **/ + PROTO_4_10(4, 10), + + /** * Became incompatible: `UpdateSettings`, `Status` + ** * New commands: `GetLatestTemporaryBasals` + **/ + PROTO_4_11(4, 11), +} \ No newline at end of file diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/CommandId.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/CommandId.kt new file mode 100644 index 00000000000..ff1bd1163cb --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/CommandId.kt @@ -0,0 +1,7 @@ +package app.aaps.pump.apex.connectivity.commands + +enum class CommandId(val raw: Int) { + SetValue(0xA1), + GetValue(0xA3), + Heartbeat(0xA5), +} \ No newline at end of file diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/BaseValueCommand.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/BaseValueCommand.kt new file mode 100644 index 00000000000..114302b44ae --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/BaseValueCommand.kt @@ -0,0 +1,27 @@ +package app.aaps.pump.apex.connectivity.commands.device + +import app.aaps.pump.apex.connectivity.commands.CommandId +import app.aaps.pump.apex.interfaces.ApexDeviceInfo + +abstract class BaseValueCommand(info: ApexDeviceInfo) : DeviceCommand(info) { + /** Value type, default = 0x35 */ + override val type: Int = 0x35 + + /** Value ID */ + abstract val valueId: Int + + /** Does command write value? */ + abstract val isWrite: Boolean + + /** Padding value, default - AA */ + open val paddingValue: Int = 0xAA + + /** Additional data after auth block */ + open val additionalData: ByteArray = byteArrayOf() + + override val id: CommandId + get() = if (isWrite) CommandId.SetValue else CommandId.GetValue + + override val builtData: ByteArray + get() = byteArrayOf(valueId.toByte(), paddingValue.toByte()) + authBlock + additionalData +} \ No newline at end of file diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/Bolus.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/Bolus.kt new file mode 100644 index 00000000000..4cf2aa09231 --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/Bolus.kt @@ -0,0 +1,21 @@ +package app.aaps.pump.apex.connectivity.commands.device + +import app.aaps.pump.apex.interfaces.ApexDeviceInfo +import app.aaps.pump.apex.utils.asShortAsByteArray + +/** Set bolus. + * + * * [dose] - Bolus dose in 0.025U steps + */ +class Bolus( + info: ApexDeviceInfo, + val dose: Int, +) : BaseValueCommand(info) { + override val valueId = 0x12 + override val isWrite = true + + override val additionalData: ByteArray + get() = dose.asShortAsByteArray() + 0x00.toByte() // TODO: find out what does zero mean + + override fun toString(): String = "Bolus($dose)" +} \ No newline at end of file diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/CancelBolus.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/CancelBolus.kt new file mode 100644 index 00000000000..ab170d579d6 --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/CancelBolus.kt @@ -0,0 +1,17 @@ +package app.aaps.pump.apex.connectivity.commands.device + +import app.aaps.pump.apex.interfaces.ApexDeviceInfo + +/** Cancel bolus. */ +class CancelBolus( + info: ApexDeviceInfo, +) : BaseValueCommand(info) { + override val type = 0x55 + override val valueId = 0x02 + override val isWrite = true + + override val additionalData: ByteArray + get() = byteArrayOf(0, 0) // TODO: find out what does it mean + + override fun toString(): String = "CancelBolus()" +} \ No newline at end of file diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/CancelTemporaryBasal.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/CancelTemporaryBasal.kt new file mode 100644 index 00000000000..b57a63c579e --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/CancelTemporaryBasal.kt @@ -0,0 +1,11 @@ +package app.aaps.pump.apex.connectivity.commands.device + +import app.aaps.pump.apex.interfaces.ApexDeviceInfo + +/** Cancel temporary basal if set before */ +class CancelTemporaryBasal(info: ApexDeviceInfo): BaseValueCommand(info) { + override val valueId = 0x05 + override val isWrite = true + + override fun toString(): String = "CancelTemporaryBasal()" +} diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/DeviceCommand.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/DeviceCommand.kt new file mode 100644 index 00000000000..eef7dc1c7bd --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/DeviceCommand.kt @@ -0,0 +1,40 @@ +package app.aaps.pump.apex.connectivity.commands.device + +import app.aaps.pump.apex.connectivity.commands.CommandId +import app.aaps.pump.apex.connectivity.ProtocolVersion +import app.aaps.pump.apex.utils.ApexCrypto +import app.aaps.pump.apex.interfaces.ApexDeviceInfo + +abstract class DeviceCommand(val info: ApexDeviceInfo) { + /** Command type, 0x35 or 0x55. See children classes for more info. */ + open val type = 0x35 + + /** Minimum protocol version supporting this command */ + open val minProto = ProtocolVersion.PROTO_4_10 + + /** Maximum protocol version supporting this command */ + open val maxProto = ProtocolVersion.PROTO_4_11 + + /** Command ID */ + abstract val id: CommandId + + /** Constructed data by children */ + abstract val builtData: ByteArray + + /** Serialize command, ready to be sent via BLE */ + fun serialize(): ByteArray { + val cmdData = builtData + val header = byteArrayOf( + type.toByte(), + (4 + cmdData.size + 2).toByte(), + 0x00, + id.raw.toByte() + ) + val data = header + cmdData + return data + ApexCrypto.crc16(data) + } + + /** Get common auth block between multiple commands */ + protected val authBlock: ByteArray + get() = ("APEX" + info.serialNumber).toByteArray() +} diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/DualBolus.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/DualBolus.kt new file mode 100644 index 00000000000..6895fc4c403 --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/DualBolus.kt @@ -0,0 +1,25 @@ +package app.aaps.pump.apex.connectivity.commands.device + +import app.aaps.pump.apex.interfaces.ApexDeviceInfo +import app.aaps.pump.apex.utils.asShortAsByteArray + +/** Set dual wave bolus. + * + * * [firstDose] - First bolus dose in 0.025U steps + * * [secondDose] - First bolus dose in 0.025U steps + * * [interval] - Interval in 15 minute steps + */ +class DualBolus( + info: ApexDeviceInfo, + val firstDose: Int, + val secondDose: Int, + val interval: Int, +) : BaseValueCommand(info) { + override val valueId = 0x14 + override val isWrite = true + + override val additionalData: ByteArray + get() = firstDose.asShortAsByteArray() + secondDose.asShortAsByteArray() + interval.asShortAsByteArray() + + override fun toString(): String = "DualBolus(first = $firstDose, second = $secondDose, interval = $interval)" +} diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/ExtendedBolus.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/ExtendedBolus.kt new file mode 100644 index 00000000000..010be90f521 --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/ExtendedBolus.kt @@ -0,0 +1,23 @@ +package app.aaps.pump.apex.connectivity.commands.device + +import app.aaps.pump.apex.interfaces.ApexDeviceInfo +import app.aaps.pump.apex.utils.asShortAsByteArray + +/** Set extended bolus. + * + * * [dose] - Bolus dose in 0.025U steps + * * [duration] - Duration in 15 minute steps + */ +class ExtendedBolus( + info: ApexDeviceInfo, + val dose: Int, + val duration: Int, +) : BaseValueCommand(info) { + override val valueId = 0x13 + override val isWrite = true + + override val additionalData: ByteArray + get() = dose.asShortAsByteArray() + duration.asShortAsByteArray() + + override fun toString(): String = "ExtendedBolus(dose = $dose, duration = $duration)" +} \ No newline at end of file diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/GetValue.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/GetValue.kt new file mode 100644 index 00000000000..ebb85bc33b4 --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/GetValue.kt @@ -0,0 +1,56 @@ +package app.aaps.pump.apex.connectivity.commands.device + +import app.aaps.pump.apex.interfaces.ApexDeviceInfo + +/** Get pump value. + * + * [value] - Value to get + */ +class GetValue( + info: ApexDeviceInfo, + val value: Value, +) : BaseValueCommand(info) { + override val type = value.type + override val valueId = value.valueId + override val isWrite = false + + override val paddingValue: Int + get() = when (value) { + Value.LatestBoluses -> 0x01 + else -> 0xAA + } + + enum class Value(val valueId: Int, val type: Int = 0x35) { + /** Pump status and settings, proto <=4.10 */ + StatusV1(0x00), + + /** Bolus history for month? It had returned A LOT of bolus entries */ + BolusHistory(0x01, 0x55), + + /** Pump alarms, returns latest 20 ones */ + Alarms(0x03, 0x55), + + /** Latest TDDs */ + TDDs(0x06, 0x55), + + /** Pump bolus wizard status */ + WizardStatus(0x07), + + /** Pump basal profiles */ + BasalProfiles(0x08), + + /** Pump status and settings, proto >=4.11 */ + StatusV2(0x0c), + + /** Latest boluses */ + LatestBoluses(0x21), + + /** Latest temporary basals, proto >=4.11 */ + LatestTemporaryBasals(0x27), + + /** Firmware version */ + Version(0x31), + } + + override fun toString(): String = "GetValue(${value.name})" +} \ No newline at end of file diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/NotifyAboutConnection.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/NotifyAboutConnection.kt new file mode 100644 index 00000000000..ae3efaf05bf --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/NotifyAboutConnection.kt @@ -0,0 +1,16 @@ +package app.aaps.pump.apex.connectivity.commands.device + +import app.aaps.pump.apex.interfaces.ApexDeviceInfo + +/** Notify pump about connection, should be sent right after connection established. */ +class NotifyAboutConnection( + info: ApexDeviceInfo, +) : BaseValueCommand(info) { + override val valueId = 0x33 + override val isWrite = true + + override val additionalData: ByteArray + get() = byteArrayOf(0x01, 0x00) // TODO: find out what do these values mean + + override fun toString(): String = "NotifyAboutConnection()" +} \ No newline at end of file diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/SyncDateTime.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/SyncDateTime.kt new file mode 100644 index 00000000000..b334de77950 --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/SyncDateTime.kt @@ -0,0 +1,29 @@ +package app.aaps.pump.apex.connectivity.commands.device + +import app.aaps.pump.apex.interfaces.ApexDeviceInfo +import org.joda.time.DateTime + +/** Update system date and time. + * Sent after every connection and every N pump heartbeats. + * + * * [dateTime] - new date and time. + */ +class SyncDateTime( + info: ApexDeviceInfo, + val dateTime: DateTime, +) : BaseValueCommand(info) { + override val valueId = 0x31 + override val isWrite = true + + override val additionalData: ByteArray + get() = byteArrayOf( + (dateTime.year % 100).toByte(), + dateTime.monthOfYear.toByte(), + dateTime.dayOfMonth.toByte(), + dateTime.hourOfDay.toByte(), + dateTime.minuteOfHour.toByte(), + dateTime.secondOfMinute.toByte() + ) + + override fun toString(): String = "SyncDateTime($dateTime)" +} diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/TemporaryBasal.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/TemporaryBasal.kt new file mode 100644 index 00000000000..2bfcada0aea --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/TemporaryBasal.kt @@ -0,0 +1,26 @@ +package app.aaps.pump.apex.connectivity.commands.device + +import app.aaps.pump.apex.interfaces.ApexDeviceInfo +import app.aaps.pump.apex.utils.asShortAsByteArray +import app.aaps.pump.apex.utils.toByte + +/** Set temporary basal, either absolute or relative. + * + * * [isAbsolute] - Is absolute or relative temporary basal? + * * [duration] - Duration in 15 minute steps + * * [value] - Dose in 0.025U steps if absolute, percentage if relative + */ +class TemporaryBasal( + info: ApexDeviceInfo, + val isAbsolute: Boolean = true, + val duration: Int, + val value: Int, +) : BaseValueCommand(info) { + override val valueId = 0x02 + override val isWrite = true + + override val additionalData: ByteArray + get() = byteArrayOf(isAbsolute.toByte(), duration.toByte()) + value.asShortAsByteArray() + + override fun toString(): String = "TemporaryBasal(absolute = $isAbsolute, duration = $duration, value = $value)" +} \ No newline at end of file diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/UpdateBasalProfileRates.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/UpdateBasalProfileRates.kt new file mode 100644 index 00000000000..f3dff9edec8 --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/UpdateBasalProfileRates.kt @@ -0,0 +1,26 @@ +package app.aaps.pump.apex.connectivity.commands.device + +import app.aaps.pump.apex.interfaces.ApexDeviceInfo +import app.aaps.pump.apex.utils.shortLSB +import app.aaps.pump.apex.utils.shortMSB + +/** Set currently used basal profile rates. + * + * * [rates] - 48 basal rates, one per 30 minute, in 0.025U steps + */ +class UpdateBasalProfileRates( + info: ApexDeviceInfo, + val rates: List +) : BaseValueCommand(info) { + override val valueId = 0x00 + override val isWrite = true + + override val additionalData: ByteArray + get() = ByteArray(96) { + val rate = rates[it / 2] + if (it % 2 == 0) rate.shortLSB() + else rate.shortMSB() + } + + override fun toString(): String = "UpdateBasalProfileRates(${rates.joinToString(", ", "[", "]")})" +} \ No newline at end of file diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/UpdateSettingsV1.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/UpdateSettingsV1.kt new file mode 100644 index 00000000000..f8696dd290e --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/UpdateSettingsV1.kt @@ -0,0 +1,99 @@ +package app.aaps.pump.apex.connectivity.commands.device + +import app.aaps.pump.apex.connectivity.ProtocolVersion +import app.aaps.pump.apex.connectivity.commands.pump.AlarmLength +import app.aaps.pump.apex.connectivity.commands.pump.AlarmType +import app.aaps.pump.apex.connectivity.commands.pump.BolusDeliverySpeed +import app.aaps.pump.apex.connectivity.commands.pump.Language +import app.aaps.pump.apex.connectivity.commands.pump.ScreenBrightness +import app.aaps.pump.apex.interfaces.ApexDeviceInfo +import app.aaps.pump.apex.utils.asShortAsByteArray + +/** Update system settings. + * + * * [lockKeys] - Lock pump keyboard after some time + * * [limitTDD] - Limit total daily dose amount + * * [language] - System language + * * [bolusSpeed] - Bolus speed + * * [alarmType] - Pump alarms type + * * [alarmLength] - Pump alarms length + * * [screenBrightness] - Screen brightness + * * [lowReservoirThreshold] - Threshold in U for low reservoir warning + * * [lowReservoirDurationThreshold] - Threshold in steps of 30 minutes for low reservoir warning (appears when reservoir will be empty in less than N minutes) + * * [enableAdvancedBolus] - Enable Dual and Extended bolus + * * [screenDisableDuration] - Screen disable duration in 0.1 second steps + * * [maxTDD] - TDD alarm threshold + * * [maxBasalRate] - Maximum basal rate and TBR value in 0.025U steps + * * [maxSingleBolus] - Maximum single bolus in 0.025U steps + */ +class UpdateSettingsV1( + info: ApexDeviceInfo, + val lockKeys: Boolean, + val limitTDD: Boolean, + val language: Language, + val bolusSpeed: BolusDeliverySpeed, + val alarmType: AlarmType, + val alarmLength: AlarmLength, + val screenBrightness: ScreenBrightness, + val lowReservoirThreshold: Int, + val lowReservoirDurationThreshold: Int, + val enableAdvancedBolus: Boolean, + val screenDisableDuration: Int, + val maxTDD: Int, + val maxBasalRate: Int, + val maxSingleBolus: Int, + val enableGlucoseReminder: Boolean = false, + val enableAutoSuspend: Boolean = false, + val lockPump: Boolean = false, +) : BaseValueCommand(info) { + override val valueId = 0x32 + override val isWrite = true + + override val maxProto = ProtocolVersion.PROTO_4_10 + + override val additionalData: ByteArray + get() { + var functionFlags = 0 + var bolusFlags = 0 + + if (bolusSpeed == BolusDeliverySpeed.Low) functionFlags = functionFlags or FunctionFlags.LowBolusSpeed.raw + if (language == Language.English) functionFlags = functionFlags or FunctionFlags.EnglishLanguage.raw + if (limitTDD) functionFlags = functionFlags or FunctionFlags.TDDLimit.raw + if (lockKeys) functionFlags = functionFlags or FunctionFlags.KeyboardLock.raw + if (enableAutoSuspend) functionFlags = functionFlags or FunctionFlags.AutoSuspend.raw + if (lockPump) functionFlags = functionFlags or FunctionFlags.LockPump.raw + + if (enableAdvancedBolus) bolusFlags = bolusFlags or BolusFlags.AdvancedBolus.raw + if (enableGlucoseReminder) bolusFlags = bolusFlags or BolusFlags.BGReminder.raw + + return byteArrayOf( + functionFlags.toByte(), + alarmType.raw, + screenBrightness.raw, + 1, // unknown + lowReservoirThreshold.toByte(), + lowReservoirDurationThreshold.toByte(), + bolusFlags.toByte(), + alarmLength.raw + ) + screenDisableDuration.asShortAsByteArray() + + maxTDD.asShortAsByteArray() + + maxBasalRate.asShortAsByteArray() + + maxSingleBolus.asShortAsByteArray() + } + + private enum class FunctionFlags(val raw: Int) { + LowBolusSpeed(1 shl 0), + KeyboardLock(1 shl 1), + AutoSuspend(1 shl 2), + LockPump(1 shl 4), + TDDLimit(1 shl 5), + EnglishLanguage( 1 shl 6), + } + + private enum class BolusFlags(val raw: Int) { + AdvancedBolus(1 shl 0), + BGReminder(1 shl 1), + } + + override fun toString(): String = "UpdateSettingsV1(maxTDD = $maxTDD, maxBolus = $maxSingleBolus, maxBasal = $maxBasalRate, bolusSpeed = ${bolusSpeed.name}, ...)" +} \ No newline at end of file diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/UpdateSystemState.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/UpdateSystemState.kt new file mode 100644 index 00000000000..7d9e467cb3e --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/UpdateSystemState.kt @@ -0,0 +1,21 @@ +package app.aaps.pump.apex.connectivity.commands.device + +import app.aaps.pump.apex.interfaces.ApexDeviceInfo +import app.aaps.pump.apex.utils.toByte + +/** Update system state, suspend or resume. + * + * * [isSuspended] - Suspend system? + */ +class UpdateSystemState( + info: ApexDeviceInfo, + val isSuspended: Boolean = true, +) : BaseValueCommand(info) { + override val valueId = 0x21 + override val isWrite = true + + override val additionalData: ByteArray + get() = byteArrayOf(isSuspended.toByte()) + + override fun toString(): String = "UpdateSystemState(suspended = $isSuspended)" +} \ No newline at end of file diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/UpdateUsedBasalProfile.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/UpdateUsedBasalProfile.kt new file mode 100644 index 00000000000..637b48657af --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/device/UpdateUsedBasalProfile.kt @@ -0,0 +1,20 @@ +package app.aaps.pump.apex.connectivity.commands.device + +import app.aaps.pump.apex.interfaces.ApexDeviceInfo + +/** Set basal profile [index] to be used now. + * + * * [index] - Basal profile index + */ +class UpdateUsedBasalProfile( + info: ApexDeviceInfo, + val index: Int, +) : BaseValueCommand(info) { + override val valueId = 0x04 + override val isWrite = true + + override val additionalData: ByteArray + get() = byteArrayOf(index.toByte()) + + override fun toString(): String = "UpdateUsedBasalProfile(id = $index)" +} \ No newline at end of file diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/AlarmObject.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/AlarmObject.kt new file mode 100644 index 00000000000..33629bddfa2 --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/AlarmObject.kt @@ -0,0 +1,23 @@ +package app.aaps.pump.apex.connectivity.commands.pump + +import app.aaps.pump.apex.utils.getUnsignedShort +import app.aaps.pump.apex.utils.hexAsDecToDec +import org.joda.time.DateTime + +class AlarmObject(command: PumpCommand): PumpObjectModel() { + /** Alarm entry index */ + val index = command.objectData[1].toUByte().toInt() + + /** Alarm date */ + val dateTime = DateTime( + command.objectData[2].hexAsDecToDec() + 2000, // year + command.objectData[3].hexAsDecToDec(), // day + command.objectData[4].hexAsDecToDec(), // month + command.objectData[5].hexAsDecToDec(), // hour + command.objectData[6].hexAsDecToDec(), // minute + command.objectData[7].hexAsDecToDec(), // second + ) + + /** Alarm type */ + val type = Alarm.entries.find { it.raw == (getUnsignedShort(command.objectData, 8) + 0x100) } +} \ No newline at end of file diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/BasalProfile.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/BasalProfile.kt new file mode 100644 index 00000000000..9d8f12d2a2e --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/BasalProfile.kt @@ -0,0 +1,13 @@ +package app.aaps.pump.apex.connectivity.commands.pump + +import app.aaps.pump.apex.utils.getUnsignedShort + +class BasalProfile(command: PumpCommand): PumpObjectModel() { + /** Basal profile index */ + val index = command.objectData[1].toUByte().toInt() + + /** Basal profile rates, in 0.025U steps, for every 30 minutes */ + val rates: List = List(48) { + getUnsignedShort(command.objectData, 2 + it * 2) + } +} diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/BolusEntry.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/BolusEntry.kt new file mode 100644 index 00000000000..11e293a1e95 --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/BolusEntry.kt @@ -0,0 +1,43 @@ +package app.aaps.pump.apex.connectivity.commands.pump + +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.pump.apex.R +import app.aaps.pump.apex.utils.getUnsignedShort +import app.aaps.pump.apex.utils.hexAsDecToDec +import org.joda.time.DateTime + +class BolusEntry(command: PumpCommand): PumpObjectModel() { + /** Bolus entry index */ + val index = command.objectData[1].toUByte().toInt() + + /** Bolus date */ + val dateTime = DateTime( + command.objectData[2].hexAsDecToDec() + 2000, // year + command.objectData[3].hexAsDecToDec(), // day + command.objectData[4].hexAsDecToDec(), // month + command.objectData[5].hexAsDecToDec(), // hour + command.objectData[6].hexAsDecToDec(), // minute + command.objectData[7].hexAsDecToDec(), // second + ) + + /** Standard bolus requested dose */ + val standardDose = getUnsignedShort(command.objectData, 8) + + /** Standard bolus actual dose */ + val standardPerformed = getUnsignedShort(command.objectData, 10) + + /** Extended bolus requested dose */ + val extendedDose = getUnsignedShort(command.objectData, 12) + + /** Extended bolus actual dose */ + val extendedPerformed = getUnsignedShort(command.objectData, 14) + + fun toShortLocalString(rh: ResourceHelper): String { + val diff = System.currentTimeMillis() - dateTime.millis + if (diff >= 60 * 60 * 1000) { + return rh.gs(R.string.overview_pump_last_bolus_h, standardPerformed * 0.025, diff / 60 / 60 / 1000, (diff / 60 / 1000) % 60) + } else { + return rh.gs(R.string.overview_pump_last_bolus_min, standardPerformed * 0.025, diff / 60 / 1000) + } + } +} diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/CommandResponse.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/CommandResponse.kt new file mode 100644 index 00000000000..57d9ecf7cfc --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/CommandResponse.kt @@ -0,0 +1,20 @@ +package app.aaps.pump.apex.connectivity.commands.pump + +import app.aaps.pump.apex.utils.getUnsignedShort + +class CommandResponse(command: PumpCommand): PumpObjectModel() { + /** Command response code */ + val code = Code.entries.find { it.raw.toByte() == command.objectData[0] } ?: Code.Unknown + + /** Bolus dose if present */ + val dose = getUnsignedShort(command.objectData, 2) + + enum class Code(val raw: Int) { + Accepted(0x55), + Invalid(0xA5), + Completed(0xAA), + StandardBolusProgress(0xA0), + ExtendedBolusProgress(0xA1), + Unknown(0xBADC0DE) + } +} diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/Heartbeat.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/Heartbeat.kt new file mode 100644 index 00000000000..6f362c5621d --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/Heartbeat.kt @@ -0,0 +1,3 @@ +package app.aaps.pump.apex.connectivity.commands.pump + +class Heartbeat: PumpObjectModel() diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/PumpCommand.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/PumpCommand.kt new file mode 100644 index 00000000000..f9e19a97a89 --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/PumpCommand.kt @@ -0,0 +1,66 @@ +package app.aaps.pump.apex.connectivity.commands.pump + +import app.aaps.pump.apex.connectivity.commands.CommandId +import androidx.annotation.VisibleForTesting +import app.aaps.core.utils.toHex +import app.aaps.pump.apex.utils.ApexCrypto + +// Read-only commands which we get from the pump. +// Format is: +// AA [UByte length*] [UByte flags] [UByte id] [data...] [UShort crc] +// * - heartbeat (a5) has incorrect length, 06 expected vs 08 actual. +class PumpCommand(private var data: ByteArray) { + companion object { + const val MIN_SIZE = 6 + } + + // Use getters here cause data may be changed + val type: Int get() = data[0].toUByte().toInt() // Should always be AA + val length: Int get() = data[1].toUByte().toInt() // May be greater or less than packet length! + + val objectType: Int get() = data[2].toUByte().toInt() + val id: CommandId? get() = CommandId.entries.find { it.raw.toUByte() == data[3].toUByte() } + val objectData: ByteArray get() = data.copyOfRange(4, realLength() - 2) + + val checksum: ByteArray + get() = data.copyOfRange(data.size - 2, data.size) + + private fun realLength(): Int = + if (id == CommandId.Heartbeat) + length + 2 + else length + + @VisibleForTesting + fun calculatedChecksum(): ByteArray { + return ApexCrypto.crc16(data, realLength() - 2) + } + + /** Verify checksum */ + fun verify(): Boolean { + val calc = calculatedChecksum() + return calc.contentEquals(checksum) + } + + /** Is command complete? */ + fun isCompleteCommand(): Boolean { + return data.size >= realLength() + } + + /** Returns the next trailing command if present. */ + val trailing: PumpCommand? + get() { + // Trailing is present only on GetValue + if (id != CommandId.GetValue) return null + if (data.size <= length) return null + if (data.size - length < MIN_SIZE) return null + return PumpCommand(data.copyOfRange(length, data.size)) + } + + /** Add remaining data to the command. */ + fun update(remainingData: ByteArray): Boolean { + data += remainingData + return isCompleteCommand() + } + + override fun toString(): String = "PumpCommand(type=0x${type.toString(16)}, objType=0x${objectType.toString(16)}, data=${objectData.toHex()})" +} \ No newline at end of file diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/PumpObjects.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/PumpObjects.kt new file mode 100644 index 00000000000..9cb963a727d --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/PumpObjects.kt @@ -0,0 +1,110 @@ +package app.aaps.pump.apex.connectivity.commands.pump + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.pump.apex.connectivity.commands.CommandId + +enum class PumpObject( + val commandId: CommandId = CommandId.GetValue, + //val objectId: Int? = null, + val valueId: List? = null, +) { + Heartbeat(commandId = CommandId.Heartbeat), + CommandResponse(commandId = CommandId.SetValue), + StatusV1(valueId = listOf(0x00)), + StatusV2(valueId = listOf(0x0C)), + WizardStatus(valueId = listOf(0x07)), + BasalProfile(valueId = listOf(0x08)), + AlarmEntry(valueId = listOf(0x03)), + TDDEntry(valueId = listOf(0x06)), + BolusEntry(valueId = listOf(0x21, 0x01)), + FirmwareEntry(valueId = listOf(0x31)); + + companion object { + fun findObject(commandId: CommandId, objectData: ByteArray, aapsLogger: AAPSLogger? = null): PumpObject? { + val valueId = objectData[0].toInt() + for (e in entries) { + if (commandId != e.commandId) continue + //if (e.objectId == null) return e + //if (objectId != e.objectId) continue + if (e.valueId == null) return e + if (!e.valueId.contains(valueId)) continue + return e + } + aapsLogger?.debug(LTag.PUMPBTCOMM, "Object [0x${commandId.name}:0x${valueId.toString(16)}] not found") + return null + } + } +} + +abstract class PumpObjectModel + +enum class BatteryLevel(val raw: Byte, val approximatePercentage: Int) { + Dead(0, 0), + Low(1, 25), + Medium(2, 50), + High(3, 75), + Full(4, 100), +} + +enum class Language(val raw: Byte) { + Russian(0), + English(1), +} + +enum class ScreenBrightness(val raw: Byte) { + P10(0), + P30(1), + P50(2), + P60(3), + P80(4), + P100(5), +} + +enum class AlarmType(val raw: Byte) { + Sound(0), + Vibration(1), + VibrationAndSound(2), +} + +enum class AlarmLength(val raw: Byte) { + Long(0), + Medium(1), + Short(2), +} + +enum class BolusDeliverySpeed(val raw: Byte) { + Standard(0), + Low(1), +} + +enum class Alarm(val raw: Int) { + Unknown(0xBADC0DE), + NoError(0x100), + LowBattery(0x101), + CheckGlucose(0x102), + ButtonError(0x103), + LowReservoir(0x104), + DeadBattery(0x105), + BatteryError(0x106), + TimeError(0x107), + NoDelivery(0x108), + ResetError(0x109), + CommunicationError(0x10a), + MotorError(0x10b), + EncoderError(0x10c), + NoDosage(0x10d), + TDDLimitTriggered(0x10e), + NoReservoir(0x10f), + ScreenError(0x201), + FRAMError(0x202), + TimeAnomalyError(0x203), + ClockError(0x204), + Reserved1(0x205), + MotorAbnormal(0x206), + MotorPowerAbnormal(0x207), + BolusOrBasalDoseAbnormal(0x208), + ConnectionAnomaly(0x209), + Reserved2(0x20a), + PressureAbnormal(0x20b), +} diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/StatusV1.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/StatusV1.kt new file mode 100644 index 00000000000..0a089a9474d --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/StatusV1.kt @@ -0,0 +1,190 @@ +package app.aaps.pump.apex.connectivity.commands.pump + +import app.aaps.pump.apex.connectivity.commands.device.UpdateSettingsV1 +import app.aaps.pump.apex.interfaces.ApexDeviceInfo +import app.aaps.pump.apex.utils.getUnsignedInt +import app.aaps.pump.apex.utils.getUnsignedShort +import app.aaps.pump.apex.utils.hexAsDecToDec +import app.aaps.pump.apex.utils.toBoolean +import org.joda.time.DateTime + +class StatusV1(command: PumpCommand): PumpObjectModel() { + /** Pump approximate battery level */ + val batteryLevel = BatteryLevel.entries.find { it.raw == command.objectData[2] } + + /** Alarm type */ + val alarmType = AlarmType.entries.find { it.raw == command.objectData[3] } + + /** Bolus delivery speed */ + val deliverySpeed = BolusDeliverySpeed.entries.find { it.raw == command.objectData[4] } + + /** Screen brightness */ + val brightness = ScreenBrightness.entries.find { it.raw == command.objectData[5] } + + private val bolusFlags = command.objectData[6].toUByte().toInt() + private enum class BolusFlags(val raw: Int) { + AdvancedBolusEnabled(1 shl 1), + BGReminderEnabled(1 shl 2), + } + + /** Are dual and extended bolus types enabled? */ + val advancedBolusEnabled = (bolusFlags and BolusFlags.AdvancedBolusEnabled.raw) == 1 + + /** Is BG reminder alarm enabled? */ + val bgReminderEnabled = (bolusFlags and BolusFlags.BGReminderEnabled.raw) == 1 + + /** Keys lock enabled? */ + val keyboardLockEnabled = command.objectData[7].toBoolean() + + /** Pump auto-suspend enabled? */ + val autoSuspendEnabled = command.objectData[8].toBoolean() + + /** Time for auto-suspend to trigger, in 30 minute steps */ + val autoSuspendDuration = command.objectData[9].toUByte().toInt() + + /** Low reservoir alarm threshold in 1U steps */ + val lowReservoirThreshold = command.objectData[10].toUByte().toInt() + + /** Low reservoir alarm (triggered by time left) threshold in 30 minute steps */ + val lowReservoirTimeLeftThreshold = command.objectData[11].toUByte().toInt() + + /** Is using preset basal pattern? */ + val isDefaultBasal = command.objectData[12].toBoolean() + + /** Is pump locked? */ + val isLocked = command.objectData[12].toBoolean() + + /** Current basal pattern index */ + val currentBasalPattern = command.objectData[14].toUByte().toInt() + + /** Is TDD limit enabled? */ + val totalDailyDoseLimitEnabled = command.objectData[15].toBoolean() + + /** Screen disable timeout, in 0.1s steps */ + val screenTimeout = getUnsignedShort(command.objectData, 16) + + /** Current TDD */ + val totalDailyDose = getUnsignedInt(command.objectData, 18) + + /** TDD alarm threshold */ + val maxTDD = getUnsignedInt(command.objectData, 22) + + /** Maximum basal rate in 0.025U steps */ + val maxBasal = getUnsignedShort(command.objectData, 26) + + /** Maximum bolus in 0.025U steps */ + val maxBolus = getUnsignedShort(command.objectData, 28) + + /** Bolus preset: Breakfast A 5:00-7:00 */ + val presetBreakfastA = getUnsignedShort(command.objectData, 30) + + /** Bolus preset: Breakfast B 7:00-10:00 */ + val presetBreakfastB = getUnsignedShort(command.objectData, 32) + + /** Bolus preset: Dinner A 10:00-12:00 */ + val presetDinnerA = getUnsignedShort(command.objectData, 34) + + /** Bolus preset: Dinner B 12:00-15:00 */ + val presetDinnerB = getUnsignedShort(command.objectData, 36) + + /** Bolus preset: Supper A 15:00-18:00 */ + val presetSupperA = getUnsignedShort(command.objectData, 38) + + /** Bolus preset: Supper B 18:00-22:00 */ + val presetSupperB = getUnsignedShort(command.objectData, 40) + + /** Bolus preset: Night A 22:00-0:00 */ + val presetNightA = getUnsignedShort(command.objectData, 42) + + /** Bolus preset: Night B 0:00-5:00 */ + val presetNightB = getUnsignedShort(command.objectData, 44) + + /** System date and time */ + val dateTime = DateTime( + command.objectData[46].toUByte().toInt() + 2000, // year + command.objectData[47].toUByte().toInt(), // month + command.objectData[48].toUByte().toInt(), // day + command.objectData[49].toUByte().toInt(), // hour + command.objectData[50].toUByte().toInt(), // minute + command.objectData[51].toUByte().toInt(), // second + ) + + /** System language */ + val language = Language.entries.find { it.raw == command.objectData[52] } + + /** Is temporary basal active? */ + val isTemporaryBasalActive = command.objectData[53].toBoolean() + + /** Reservoir level, last 3 numbers are decimals */ + val reservoir = getUnsignedInt(command.objectData, 54) + + /** Current alarms list */ + val alarms = buildList { + for (i in 0..<9) { + val raw = getUnsignedShort(command.objectData, 58 + 2 * i) + if (raw != 0) add(Alarm.entries.find { it.raw == raw } ?: Alarm.Unknown) + } + } + + /** Current basal rate in 0.025U steps */ + val currentBasalRate = getUnsignedShort(command.objectData, 78) + + /** Current basal rate end time */ + val currentBasalEndHour = command.objectData[80].toUByte().toInt() + /** Current basal rate end time */ + val currentBasalEndMinute = command.objectData[81].toUByte().toInt() + + /** TBR if present */ + val temporaryBasalRate = getUnsignedShort(command.objectData, 82) + + /** Is TBR absolute? */ + val temporaryBasalRateIsAbsolute = command.objectData[84].toBoolean() + + /** TBR duration, in 1 minute steps */ + val temporaryBasalRateDuration = getUnsignedShort(command.objectData, 86) + + /** TBR elapsed time, in 1 minute steps */ + val temporaryBasalRateElapsed = getUnsignedShort(command.objectData, 88) + + fun toUpdateSettingsV1( + info: ApexDeviceInfo, + alarmLength: AlarmLength, + lockKeys: Boolean? = null, + limitTDD: Boolean? = null, + language: Language? = null, + bolusSpeed: BolusDeliverySpeed? = null, + alarmType: AlarmType? = null, + screenBrightness: ScreenBrightness? = null, + lowReservoirThreshold: Int? = null, + lowReservoirDurationThreshold: Int? = null, + enableAdvancedBolus: Boolean? = null, + screenDisableDuration: Int? = null, + maxTDD: Int? = null, + maxBasalRate: Int? = null, + maxSingleBolus: Int? = null, + enableGlucoseReminder: Boolean? = null, + enableAutoSuspend: Boolean? = null, + lockPump: Boolean? = null, + ): UpdateSettingsV1 { + return UpdateSettingsV1( + info, + lockKeys ?: keyboardLockEnabled, + limitTDD ?: totalDailyDoseLimitEnabled, + language ?: this.language!!, + bolusSpeed ?: deliverySpeed!!, + alarmType ?: this.alarmType!!, + alarmLength, + screenBrightness ?: this.brightness!!, + lowReservoirThreshold ?: this.lowReservoirThreshold, + lowReservoirDurationThreshold ?: this.lowReservoirTimeLeftThreshold, + enableAdvancedBolus ?: this.advancedBolusEnabled, + screenDisableDuration ?: this.screenTimeout, + maxTDD ?: this.maxTDD, + maxBasalRate ?: this.maxBasal, + maxSingleBolus ?: this.maxBolus, + enableGlucoseReminder ?: this.bgReminderEnabled, + enableAutoSuspend ?: this.autoSuspendEnabled, + lockPump ?: this.isLocked, + ) + } +} \ No newline at end of file diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/StatusV2.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/StatusV2.kt new file mode 100644 index 00000000000..43760503a43 --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/StatusV2.kt @@ -0,0 +1,6 @@ +package app.aaps.pump.apex.connectivity.commands.pump + +class StatusV2(command: PumpCommand): PumpObjectModel() { + /** Pump battery voltage */ + val batteryVoltage = command.objectData[4].toUByte().toInt() / 100.0 +} diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/TDDEntry.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/TDDEntry.kt new file mode 100644 index 00000000000..c033126f397 --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/TDDEntry.kt @@ -0,0 +1,30 @@ +package app.aaps.pump.apex.connectivity.commands.pump + +import app.aaps.pump.apex.utils.getUnsignedShort +import app.aaps.pump.apex.utils.hexAsDecToDec +import org.joda.time.DateTime + +class TDDEntry(command: PumpCommand): PumpObjectModel() { + /** TDD entry index */ + val index = command.objectData[1].toUByte().toInt() + + /** Bolus part of TDD */ + val bolus = getUnsignedShort(command.objectData, 2) + + /** Basal part of TDD */ + val basal = getUnsignedShort(command.objectData, 4) + + /** Temporary basal part of TDD */ + val temporaryBasal = getUnsignedShort(command.objectData, 6) + + /** TDD */ + val total = bolus + basal + temporaryBasal + + /** TDD entry date */ + val dateTime = DateTime( + command.objectData[8].hexAsDecToDec() + 2000, + command.objectData[9].hexAsDecToDec(), + command.objectData[10].hexAsDecToDec(), + 0, 0 + ) +} diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/Version.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/Version.kt new file mode 100644 index 00000000000..13174a9eafe --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/connectivity/commands/pump/Version.kt @@ -0,0 +1,36 @@ +package app.aaps.pump.apex.connectivity.commands.pump + +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.pump.apex.R +import app.aaps.pump.apex.connectivity.ProtocolVersion + +class Version(command: PumpCommand): PumpObjectModel() { + /** Firmware major part of version */ + val firmwareMajor = command.objectData[6].toUByte().toInt() + + /** Firmware minor part of version */ + val firmwareMinor = command.objectData[7].toUByte().toInt() + + /** Protocol major part of version */ + val protocolMajor = command.objectData[8].toUByte().toInt() + + /** Protocol minor part of version */ + val protocolMinor = command.objectData[9].toUByte().toInt() + + fun toLocalString(rh: ResourceHelper): String { + return rh.gs(R.string.overview_pump_fw, firmwareMajor, firmwareMinor, protocolMajor, protocolMinor) + } + + fun atleastProto(proto: ProtocolVersion): Boolean { + return protocolMajor >= proto.major && protocolMinor >= proto.minor + } + + fun isSupported(min: ProtocolVersion, max: ProtocolVersion): Boolean { + if (min.major > protocolMajor || max.major < protocolMajor) return false + if (max.major > protocolMajor) return true + if (max.minor < protocolMinor) return false + return true + } + + override fun toString(): String = "Version(fw = $firmwareMajor.$firmwareMinor, proto = $protocolMajor.$protocolMinor)" +} diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/di/ApexModule.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/di/ApexModule.kt new file mode 100644 index 00000000000..964af320f9f --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/di/ApexModule.kt @@ -0,0 +1,9 @@ +package app.aaps.pump.apex.di + +import dagger.Module + +@Module(includes = [ + ApexUiModule::class, + ApexServicesModule::class, +]) +open class ApexModule diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/di/ApexServicesModule.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/di/ApexServicesModule.kt new file mode 100644 index 00000000000..9289616cea9 --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/di/ApexServicesModule.kt @@ -0,0 +1,18 @@ +package app.aaps.pump.apex.di + +import app.aaps.pump.apex.ApexService +import app.aaps.pump.apex.connectivity.ApexBluetooth +import app.aaps.pump.apex.interfaces.ApexDeviceInfo +import app.aaps.pump.apex.misc.ApexDeviceInfoImpl +import dagger.Binds +import dagger.Module +import dagger.android.ContributesAndroidInjector + +@Module +@Suppress("unused") +abstract class ApexServicesModule { + @Binds abstract fun contributesApexDeviceInfo(apexDeviceInfoImpl: ApexDeviceInfoImpl): ApexDeviceInfo + @ContributesAndroidInjector abstract fun contributesApexBluetooth(): ApexBluetooth + @ContributesAndroidInjector abstract fun contributesApexService(): ApexService +} + diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/di/ApexUiModule.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/di/ApexUiModule.kt new file mode 100644 index 00000000000..bcb4ac92cd2 --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/di/ApexUiModule.kt @@ -0,0 +1,11 @@ +package app.aaps.pump.apex.di + +import app.aaps.pump.apex.ui.ApexFragment +import dagger.Module +import dagger.android.ContributesAndroidInjector + +@Module +@Suppress("unused") +abstract class ApexUiModule { + @ContributesAndroidInjector abstract fun contributesApexFragment(): ApexFragment +} diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/events/EventApexPumpDataChanged.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/events/EventApexPumpDataChanged.kt new file mode 100644 index 00000000000..632946439a5 --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/events/EventApexPumpDataChanged.kt @@ -0,0 +1,5 @@ +package app.aaps.pump.apex.events + +import app.aaps.core.interfaces.rx.events.Event + +class EventApexPumpDataChanged : Event() diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/interfaces/ApexBluetoothCallback.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/interfaces/ApexBluetoothCallback.kt new file mode 100644 index 00000000000..edb5477ebf5 --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/interfaces/ApexBluetoothCallback.kt @@ -0,0 +1,9 @@ +package app.aaps.pump.apex.interfaces + +import app.aaps.pump.apex.connectivity.commands.pump.PumpCommand + +interface ApexBluetoothCallback { + fun onConnect() + fun onDisconnect() + fun onPumpCommand(command: PumpCommand) +} diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/interfaces/ApexDeviceInfo.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/interfaces/ApexDeviceInfo.kt new file mode 100644 index 00000000000..ea2d3636388 --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/interfaces/ApexDeviceInfo.kt @@ -0,0 +1,5 @@ +package app.aaps.pump.apex.interfaces + +interface ApexDeviceInfo { + var serialNumber: String +} diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/misc/ApexDeviceInfoImpl.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/misc/ApexDeviceInfoImpl.kt new file mode 100644 index 00000000000..a2e5118566f --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/misc/ApexDeviceInfoImpl.kt @@ -0,0 +1,16 @@ +package app.aaps.pump.apex.misc + +import app.aaps.core.keys.Preferences +import app.aaps.pump.apex.interfaces.ApexDeviceInfo +import app.aaps.pump.apex.utils.keys.ApexStringKey +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ApexDeviceInfoImpl @Inject constructor( + val preferences: Preferences +): ApexDeviceInfo { + override var serialNumber: String + get() = preferences.get(ApexStringKey.SerialNumber) + set(s) = preferences.put(ApexStringKey.SerialNumber, s) +} \ No newline at end of file diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/ui/ApexFragment.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/ui/ApexFragment.kt new file mode 100644 index 00000000000..093302659e8 --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/ui/ApexFragment.kt @@ -0,0 +1,114 @@ +package app.aaps.pump.apex.ui + +import android.os.Bundle +import android.os.Handler +import android.os.HandlerThread +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import app.aaps.core.data.time.T +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.plugin.ActivePlugin +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.core.interfaces.rx.bus.RxBus +import app.aaps.core.interfaces.rx.events.EventInitializationChanged +import app.aaps.core.interfaces.rx.events.EventPreferenceChange +import app.aaps.core.interfaces.rx.events.EventPumpStatusChanged +import app.aaps.core.interfaces.rx.events.EventQueueChanged +import app.aaps.core.interfaces.rx.events.EventTempBasalChange +import app.aaps.core.interfaces.utils.fabric.FabricPrivacy +import app.aaps.pump.apex.ApexPump +import app.aaps.pump.apex.R +import app.aaps.pump.apex.databinding.ApexFragmentBinding +import app.aaps.pump.apex.events.EventApexPumpDataChanged +import dagger.android.support.DaggerFragment +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import javax.inject.Inject + +class ApexFragment : DaggerFragment() { + @Inject lateinit var activePlugin: ActivePlugin + @Inject lateinit var pump: ApexPump + @Inject lateinit var rh: ResourceHelper + @Inject lateinit var aapsLogger: AAPSLogger + @Inject lateinit var aapsSchedulers: AapsSchedulers + @Inject lateinit var rxBus: RxBus + @Inject lateinit var fabricPrivacy: FabricPrivacy + + private val disposable = CompositeDisposable() + private val handler = Handler(HandlerThread(this::class.simpleName + "Handler").also { it.start() }.looper) + private var refreshLoop: Runnable = Runnable { + activity?.runOnUiThread { updateGUI() } + } + + private var _binding: ApexFragmentBinding? = null + val binding: ApexFragmentBinding + get() = _binding!! + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + _binding = ApexFragmentBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } + + override fun onResume() { + super.onResume() + + disposable += rxBus + .toObservable(EventPumpStatusChanged::class.java) + .observeOn(aapsSchedulers.io) + .subscribe({ updateGUI() }, fabricPrivacy::logException) + disposable += rxBus + .toObservable(EventApexPumpDataChanged::class.java) + .observeOn(aapsSchedulers.io) + .subscribe({ updateGUI() }, fabricPrivacy::logException) + for (clazz in listOf( + EventInitializationChanged::class.java, + EventPumpStatusChanged::class.java, + EventApexPumpDataChanged::class.java, + EventPreferenceChange::class.java, + EventTempBasalChange::class.java, + EventQueueChanged::class.java, + )) { + disposable += rxBus + .toObservable(clazz) + .observeOn(aapsSchedulers.io) + .subscribe({ updateGUI() }, fabricPrivacy::logException) + } + + updateGUI() + handler.postDelayed(refreshLoop, T.mins(1).msecs()) + } + + override fun onPause() { + disposable.clear() + handler.removeCallbacks(refreshLoop) + super.onPause() + } + + private fun updateGUI() { + aapsLogger.error(LTag.UI, "updateGUI") + val status = pump.status + if (status == null) aapsLogger.error(LTag.UI, "No status available!") + + binding.connectionStatus.text = when { + activePlugin.activePump.isConnected() -> rh.gs(R.string.overview_connection_status_connected) + activePlugin.activePump.isConnecting() -> rh.gs(R.string.overview_connection_status_connecting) + else -> rh.gs(R.string.overview_connection_status_disconnected) + } + binding.serialNumber.text = pump.serialNumber + binding.pumpStatus.text = status?.getPumpStatus(rh) ?: "?" + binding.battery.text = status?.getBatteryLevel(rh) ?: "?" + binding.reservoir.text = status?.getReservoirLevel(rh) ?: "?" + binding.tempbasal.text = status?.getTBR(rh) ?: "?" + binding.baseBasalRate.text = status?.getBasal(rh) ?: "?" + binding.firmwareVersion.text = pump.firmwareVersion?.toLocalString(rh) ?: "?" + binding.lastBolus.text = pump.lastBolus?.toShortLocalString(rh) ?: "?" + } +} diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/utils/ApexCrypto.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/utils/ApexCrypto.kt new file mode 100644 index 00000000000..d3a9b4af034 --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/utils/ApexCrypto.kt @@ -0,0 +1,22 @@ +package app.aaps.pump.apex.utils + +import app.aaps.core.utils.toHex +import kotlin.math.min + +class ApexCrypto { + companion object { + // CRC16-MODBUS + fun crc16(data: ByteArray, length: Int = Int.MAX_VALUE, offset: Int = 0): ByteArray { + var c = 0xFFFF + for (p in offset.. = List(48) { getBasalTimeFromMidnight(it * 30 * 60) } diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/utils/keys/ApexBooleanKey.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/utils/keys/ApexBooleanKey.kt new file mode 100644 index 00000000000..7ec687a9eb9 --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/utils/keys/ApexBooleanKey.kt @@ -0,0 +1,20 @@ +package app.aaps.pump.apex.utils.keys + +import app.aaps.core.keys.BooleanPreferenceKey + +enum class ApexBooleanKey( + override val key: String, + override val defaultValue: Boolean, + override val calculatedDefaultValue: Boolean = false, + override val engineeringModeOnly: Boolean = false, + override val defaultedBySM: Boolean = false, + override val showInApsMode: Boolean = true, + override val showInNsClientMode: Boolean = true, + override val showInPumpControlMode: Boolean = true, + override val dependency: BooleanPreferenceKey? = null, + override val negativeDependency: BooleanPreferenceKey? = null, + override val hideParentScreenIfHidden: Boolean = false +) : BooleanPreferenceKey { + LogInsulinChange("apex_log_insulin_change", true), + LogBatteryChange("apex_log_battery_change", true), +} diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/utils/keys/ApexDoubleKey.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/utils/keys/ApexDoubleKey.kt new file mode 100644 index 00000000000..ec4679590ac --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/utils/keys/ApexDoubleKey.kt @@ -0,0 +1,23 @@ +package app.aaps.pump.apex.utils.keys + +import app.aaps.core.keys.BooleanPreferenceKey +import app.aaps.core.keys.DoublePreferenceKey + +enum class ApexDoubleKey( + override val key: String, + override val defaultValue: Double, + override val min: Double, + override val max: Double, + override val defaultedBySM: Boolean = false, + override val calculatedBySM: Boolean = false, + override val showInApsMode: Boolean = true, + override val showInNsClientMode: Boolean = true, + override val showInPumpControlMode: Boolean = true, + override val dependency: BooleanPreferenceKey? = null, + override val negativeDependency: BooleanPreferenceKey? = null, + override val hideParentScreenIfHidden: Boolean = false, +): DoublePreferenceKey { + // 0 == uninitialized + MaxBasal("apex_max_basal", 0.0, 0.0, 25.0), + MaxBolus("apex_max_bolus", 0.0, 0.0, 25.0), +} \ No newline at end of file diff --git a/pump/apex/src/main/kotlin/app/aaps/pump/apex/utils/keys/ApexStringKey.kt b/pump/apex/src/main/kotlin/app/aaps/pump/apex/utils/keys/ApexStringKey.kt new file mode 100644 index 00000000000..8a3bbb90edf --- /dev/null +++ b/pump/apex/src/main/kotlin/app/aaps/pump/apex/utils/keys/ApexStringKey.kt @@ -0,0 +1,24 @@ +package app.aaps.pump.apex.utils.keys + +import app.aaps.core.keys.BooleanPreferenceKey +import app.aaps.core.keys.StringPreferenceKey +import app.aaps.pump.apex.connectivity.commands.pump.AlarmLength + +enum class ApexStringKey( + override val key: String, + override val defaultValue: String, + override val defaultedBySM: Boolean = false, + override val showInApsMode: Boolean = true, + override val showInNsClientMode: Boolean = true, + override val showInPumpControlMode: Boolean = true, + override val dependency: BooleanPreferenceKey? = null, + override val negativeDependency: BooleanPreferenceKey? = null, + override val hideParentScreenIfHidden: Boolean = false, + override val isPassword: Boolean = false, + override val isPin: Boolean = false +) : StringPreferenceKey { + SerialNumber("apex_serial_number", ""), + LastConnectedSerialNumber("apex_last_connected_serial_number", ""), + BluetoothAddress("apex_bt_address", ""), + AlarmSoundLength("apex_alarm_length", AlarmLength.Short.name) +} diff --git a/pump/apex/src/main/res/drawable/ic_apex.xml b/pump/apex/src/main/res/drawable/ic_apex.xml new file mode 100644 index 00000000000..b51b8546b03 --- /dev/null +++ b/pump/apex/src/main/res/drawable/ic_apex.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pump/apex/src/main/res/drawable/ic_apex_detailed.xml b/pump/apex/src/main/res/drawable/ic_apex_detailed.xml new file mode 100644 index 00000000000..26688aa37aa --- /dev/null +++ b/pump/apex/src/main/res/drawable/ic_apex_detailed.xml @@ -0,0 +1,10864 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pump/apex/src/main/res/layout/apex_fragment.xml b/pump/apex/src/main/res/layout/apex_fragment.xml new file mode 100644 index 00000000000..a9559ac23c7 --- /dev/null +++ b/pump/apex/src/main/res/layout/apex_fragment.xml @@ -0,0 +1,413 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pump/apex/src/main/res/values/strings.xml b/pump/apex/src/main/res/values/strings.xml new file mode 100644 index 00000000000..b223f7a9843 --- /dev/null +++ b/pump/apex/src/main/res/values/strings.xml @@ -0,0 +1,89 @@ + + + APEX TruCare III + APEX + Native integration with APEX TruCare III insulin pump + APEX Pump Settings + + Alarm length + Short + Medium + Long + + Maximum bolus [U] + Maximum basal rate [U/h] + + Pump serial number + + Disconnected + Connecting + Connected + + Normal + Suspended + Alarm + + %1$d%% + %1$d%% (%2$d mV) + %1$.3fU + %1$.3fU/h + %1$.3fU/h (%2$d min left) + %1$d%% (%2$d min left) + %1$.3fU/h (%2$d h %3$d min left) + %1$d%% (%2$d h %3$d min left) + %1$.3fU (%2$d min ago) + %1$.3fU (%2$d h ago) + v%1$d.%2$d (v%3$d.%4$d) + + Pump status + BT status + Last bolus + Insulin + Battery + TBR + Base basal + + Current action + Idle + Updating pump status + Updating TDDs + Updating alarms + Getting basal profiles + Getting version + Updating boluses + Setting bolus %1$.3fU + + Pump is suspended + Pump has unsupported firmware + + Pump alarm: %1$s + Occlusion + Low battery + Low reservoir level + Battery is dead + Reservoir is empty + Hardware error (%1$s) + Check blood glucose + Unknown error %1$s, report to developers + Unknown error + + Delivering %1$.3fU + Delivered %1$.3fU successfully + Bolus was cancelled + + Pump is not ready + Failed to switch basal profile index + Failed to update basal profile + Pump unreachable + Pump is suspended + Failed to start bolus + Failed to cancel TBR + Failed to set TBR + Only absolute TBRs are supported + Firmware version + Connection status + Pump status + GET STATUS + GET BOLUS + Suspend + diff --git a/pump/apex/src/test/kotlin/app/aaps/pump/apex/CommandsTest.kt b/pump/apex/src/test/kotlin/app/aaps/pump/apex/CommandsTest.kt new file mode 100644 index 00000000000..511cfadd44f --- /dev/null +++ b/pump/apex/src/test/kotlin/app/aaps/pump/apex/CommandsTest.kt @@ -0,0 +1,145 @@ +package app.aaps.pump.apex + +import app.aaps.pump.apex.connectivity.commands.device.Bolus +import app.aaps.pump.apex.connectivity.commands.pump.AlarmType +import app.aaps.pump.apex.connectivity.commands.pump.BasalProfile +import app.aaps.pump.apex.connectivity.commands.pump.BolusDeliverySpeed +import app.aaps.pump.apex.connectivity.commands.pump.BolusEntry +import app.aaps.pump.apex.connectivity.commands.pump.CommandResponse +import app.aaps.pump.apex.connectivity.commands.pump.PumpCommand +import app.aaps.pump.apex.connectivity.commands.pump.PumpObject +import app.aaps.pump.apex.connectivity.commands.pump.ScreenBrightness +import app.aaps.pump.apex.connectivity.commands.pump.StatusV1 +import app.aaps.pump.apex.connectivity.commands.pump.TDDEntry +import app.aaps.pump.apex.connectivity.commands.pump.Version +import app.aaps.pump.apex.interfaces.ApexDeviceInfo +import app.aaps.shared.tests.TestBase +import org.joda.time.DateTime +import org.junit.jupiter.api.Test + +class CommandsTest : TestBase() { + private val info = object : ApexDeviceInfo { + override var serialNumber = "12345678" + } + + private fun deserialiseHead(expectedType: PumpObject, data: ByteArray): PumpCommand { + val command = PumpCommand(data) + assert(command.isCompleteCommand()) + assert(command.verify()) + assert(PumpObject.findObject(command.id!!, command.objectData) == expectedType) + println("Processed $command") + return command + } + + @Test + fun pump_commandResponse() { + val command = deserialiseHead(PumpObject.CommandResponse, ubyteArrayOf(0xaau, 0x0au, 0x00u, 0xa1u, 0xa0u, 0xaau, 0x0cu, 0x00u, 0xdau, 0xf5u).toByteArray()) + val data = CommandResponse(command) + assert(data.code == CommandResponse.Code.StandardBolusProgress) + assert(data.dose == 12) + + assert(command.trailing == null) + } + + @Test + fun pump_bolusEntry() { + val command = deserialiseHead(PumpObject.BolusEntry, ubyteArrayOf(0xaau, 0x16u, 0x80u, 0xa3u, 0x21u, 0x00u, 0x25u, 0x01u, 0x25u, 0x17u, 0x52u, 0x59u, 0x09u, 0x00u, 0x09u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x78u).toByteArray()) + val data = BolusEntry(command) + assert(data.dateTime == DateTime(2025, 1, 25, 17, 52, 59)) + assert(data.extendedDose == 0) + assert(data.standardDose == 9) + + assert(command.trailing == null) + } + + @Test + fun pump_tddEntry() { + val command = deserialiseHead(PumpObject.TDDEntry, ubyteArrayOf(0xaau, 0x12u, 0x5cu, 0xa3u, 0x06u, 0x00u, 0x88u, 0x00u, 0x56u, 0x01u, 0x0cu, 0x00u, 0x25u, 0x01u, 0x26u, 0x00u, 0x50u, 0xa0u, 0xaau, 0x12u, 0x5cu, 0xa3u, 0x06u, 0x00u, 0x88u, 0x00u, 0x56u, 0x01u, 0x0cu, 0x00u, 0x25u, 0x01u, 0x26u, 0x00u, 0x50u, 0xa0u).toByteArray()) + val data = TDDEntry(command) + assert(data.dateTime == DateTime(2025, 1, 26, 0, 0)) + assert(data.bolus == 136) { data.bolus } + assert(data.basal == 342) { data.basal } + assert(data.temporaryBasal == 12) { data.temporaryBasal } + + val trailing = command.trailing + assert(trailing != null) + assert(trailing!!.isCompleteCommand()) + assert(trailing.verify()) + assert(PumpObject.findObject(trailing.id!!, trailing.objectData) == PumpObject.TDDEntry) + } + + @Test + fun pump_basalPattern() { + val command = deserialiseHead(PumpObject.BasalProfile, ubyteArrayOf(0xaau, 0x68u, 0x08u, 0xa3u, 0x08u, 0x00u, 0x24u, 0x00u, 0x24u, 0x00u, 0x27u, 0x00u, 0x27u, 0x00u, 0x27u, 0x00u, 0x27u, 0x00u, 0x28u, 0x00u, 0x28u, 0x00u, 0x28u, 0x00u, 0x28u, 0x00u, 0x26u, 0x00u, 0x26u, 0x00u, 0x26u, 0x00u, 0x26u, 0x00u, 0x24u, 0x00u, 0x24u, 0x00u, 0x23u, 0x00u, 0x23u, 0x00u, 0x22u, 0x00u, 0x22u, 0x00u, 0x22u, 0x00u, 0x22u, 0x00u, 0x22u, 0x00u, 0x22u, 0x00u, 0x24u, 0x00u, 0x24u, 0x00u, 0x24u, 0x00u, 0x24u, 0x00u, 0x24u, 0x00u, 0x24u, 0x00u, 0x24u, 0x00u, 0x24u, 0x00u, 0x24u, 0x00u, 0x24u, 0x00u, 0x24u, 0x00u, 0x24u, 0x00u, 0x24u, 0x00u, 0x24u, 0x00u, 0x24u, 0x00u, 0x24u, 0x00u, 0x24u, 0x00u, 0x24u, 0x00u, 0x24u, 0x00u, 0x24u, 0x00u, 0x24u, 0x00u, 0x24u, 0x00u, 0x24u, 0x00u, 0x24u, 0x00u, 0x40u, 0x6au).toByteArray()) + val data = BasalProfile(command) + val rates = listOf( + 36, 36, + 39, 39, + 39, 39, + 40, 40, + 40, 40, + 38, 38, + 38, 38, + 36, 36, + 35, 35, + 34, 34, + 34, 34, + 34, 34, + 36, 36, + 36, 36, + 36, 36, + 36, 36, + 36, 36, + 36, 36, + 36, 36, + 36, 36, + 36, 36, + 36, 36, + 36, 36, + 36, 36, + ) + assert(data.rates == rates) + assert(data.index == 0) + } + + @Test + fun pump_statusV1() { + val command = deserialiseHead(PumpObject.StatusV1, ubyteArrayOf(0xaau, 0x60u, 0x01u, 0xa3u, 0x00u, 0xaau, 0x04u, 0x01u, 0x00u, 0x00u, 0x06u, 0x01u, 0x00u, 0x01u, 0x14u, 0x04u, 0x00u, 0x00u, 0x00u, 0x00u, 0x2cu, 0x01u, 0xb7u, 0x05u, 0x00u, 0x00u, 0x96u, 0x00u, 0x00u, 0x00u, 0xa0u, 0x00u, 0x0cu, 0x03u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x19u, 0x01u, 0x1cu, 0x15u, 0x0eu, 0x00u, 0x00u, 0x00u, 0x15u, 0x35u, 0x01u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x24u, 0x00u, 0x0du, 0x0eu, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0xc5u, 0xb3u).toByteArray()) + val data = StatusV1(command) + assert(data.batteryLevel!!.approximatePercentage == 100) + assert(data.alarmType == AlarmType.Vibration) + assert(data.deliverySpeed == BolusDeliverySpeed.Standard) + assert(data.brightness == ScreenBrightness.P10) + assert(data.keyboardLockEnabled) + assert(!data.autoSuspendEnabled) + assert(data.autoSuspendDuration == 1) + assert(data.lowReservoirThreshold == 20) + assert(data.lowReservoirTimeLeftThreshold == 4) + assert(!data.totalDailyDoseLimitEnabled) + assert(data.screenTimeout == 300) + assert(data.currentBasalRate == 36) + } + + @Test + fun pump_heartbeat() { + deserialiseHead(PumpObject.Heartbeat, ubyteArrayOf(0xaau, 0x06u, 0x00u, 0xa5u, 0x01u, 0x00u, 0x81u, 0xa2u).toByteArray()) + } + + @Test + fun pump_version() { + val command = deserialiseHead(PumpObject.FirmwareEntry, ubyteArrayOf(0xaau, 0x10u, 0x00u, 0xa3u, 0x31u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x06u, 0x19u, 0x04u, 0x0au, 0x30u, 0xcfu).toByteArray()) + val data = Version(command) + + assert(data.firmwareMajor == 6) + assert(data.firmwareMinor == 25) + assert(data.protocolMajor == 4) + assert(data.protocolMinor == 10) + } + + @Test + fun device_bolus() { + val expected = ubyteArrayOf(0x35u, 0x17u, 0x00u, 0xa1u, 0x12u, 0xaau, 0x41u, 0x50u, 0x45u, 0x58u, 0x31u, 0x32u, 0x33u, 0x34u, 0x35u, 0x36u, 0x37u, 0x38u, 0x3cu, 0x00u, 0x00u, 0x6fu, 0x65u).toByteArray() + val command = Bolus(info, 60) + assert(expected.contentEquals(command.serialize())) + } +} diff --git a/settings.gradle b/settings.gradle index 8de57d1b7c8..8b295e2e0d5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -24,6 +24,7 @@ include ':plugins:sensitivity' include ':plugins:smoothing' include ':plugins:source' include ':plugins:sync' +include ':pump:apex' include ':pump:combov2' include ':pump:combov2:comboctl' include ':pump:dana'