diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminPlugin.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminPlugin.kt index 0b28af0e94..5d20fd3c70 100644 --- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminPlugin.kt +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminPlugin.kt @@ -94,6 +94,8 @@ class GarminPlugin @Inject constructor( server?.close() server = HttpServer(aapsLogger, port).apply { registerEndpoint("/get", ::onGetBloodGlucose) + registerEndpoint("/carbs", ::onPostCarbs) + registerEndpoint("/connect", ::onConnectPump) } } else if (server != null) { aapsLogger.info(LTag.GARMIN, "stopping HTTP server") @@ -243,4 +245,36 @@ class GarminPlugin @Inject constructor( aapsLogger.warn(LTag.GARMIN, "Skip saving invalid HR $avg $samplingStart..$samplingEnd") } } + + /** Handles carb notification from the device. */ + @VisibleForTesting + @Suppress("UNUSED_PARAMETER") + fun onPostCarbs(caller: SocketAddress, uri: URI, requestBody: String?): CharSequence { + aapsLogger.info(LTag.GARMIN, "carbs from $caller, req: $uri") + postCarbs(getQueryParameter(uri, "carbs", 0L).toInt()) + return "" + } + + private fun postCarbs(carbs: Int) { + if (carbs > 0) { + loopHub.postCarbs(carbs) + } + } + + /** Handles pump connected notification that the user entered on the Garmin device. */ + @VisibleForTesting + @Suppress("UNUSED_PARAMETER") + fun onConnectPump(caller: SocketAddress, uri: URI, requestBody: String?): CharSequence { + aapsLogger.info(LTag.GARMIN, "connect from $caller, req: $uri") + val minutes = getQueryParameter(uri, "disconnectMinutes", 0L).toInt() + if (minutes > 0) { + loopHub.disconnectPump(minutes) + } else { + loopHub.connectPump() + } + + val jo = JsonObject() + jo.addProperty("connected", loopHub.isConnected) + return jo.toString() + } } diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/LoopHub.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/LoopHub.kt index 69f2f44a62..4b420d21f5 100644 --- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/LoopHub.kt +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/LoopHub.kt @@ -29,9 +29,19 @@ interface LoopHub { /** Returns the factor by which the basal rate is currently raised (> 1) or lowered (< 1). */ val temporaryBasal: Double + /** Tells the loop algorithm that the pump is physically connected. */ + fun connectPump() + + /** Tells the loop algorithm that the pump will be physically disconnected + * for the given number of minutes. */ + fun disconnectPump(minutes: Int) + /** Retrieves the glucose values starting at from. */ fun getGlucoseValues(from: Instant, ascending: Boolean): List + /** Notifies the system that carbs were eaten and stores the value. */ + fun postCarbs(carbohydrates: Int) + /** Stores hear rate readings that a taken and averaged of the given interval. */ fun storeHeartRate( samplingStart: Instant, samplingEnd: Instant, diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/LoopHubImpl.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/LoopHubImpl.kt index e762d27b49..dfcf9dab37 100644 --- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/LoopHubImpl.kt +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/LoopHubImpl.kt @@ -2,15 +2,26 @@ package app.aaps.plugins.sync.garmin import androidx.annotation.VisibleForTesting import app.aaps.core.interfaces.aps.Loop +import app.aaps.core.interfaces.constraints.ConstraintsChecker import app.aaps.core.interfaces.db.GlucoseUnit import app.aaps.core.interfaces.iob.IobCobCalculator +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.logging.UserEntryLogger import app.aaps.core.interfaces.profile.Profile import app.aaps.core.interfaces.profile.ProfileFunction +import app.aaps.core.interfaces.pump.DetailedBolusInfo +import app.aaps.core.interfaces.queue.CommandQueue +import app.aaps.core.interfaces.sharedPreferences.SP import app.aaps.database.ValueWrapper import app.aaps.database.entities.EffectiveProfileSwitch import app.aaps.database.entities.GlucoseValue import app.aaps.database.entities.HeartRate +import app.aaps.database.entities.OfflineEvent +import app.aaps.database.entities.UserEntry +import app.aaps.database.entities.ValueWithUnit import app.aaps.database.impl.AppRepository +import app.aaps.database.impl.transactions.CancelCurrentOfflineEventIfAnyTransaction import app.aaps.database.impl.transactions.InsertOrUpdateHeartRateTransaction import java.time.Clock import java.time.Instant @@ -22,10 +33,15 @@ import javax.inject.Singleton * Interface to the functionality of the looping algorithm and storage systems. */ class LoopHubImpl @Inject constructor( + private val aapsLogger: AAPSLogger, + private val commandQueue: CommandQueue, + private val constraintChecker: ConstraintsChecker, private val iobCobCalculator: IobCobCalculator, private val loop: Loop, private val profileFunction: ProfileFunction, private val repo: AppRepository, + private val userEntryLogger: UserEntryLogger, + private val sp: SP, ) : LoopHub { @VisibleForTesting @@ -40,7 +56,9 @@ class LoopHubImpl @Inject constructor( /** Returns the glucose unit (mg/dl or mmol/l) as selected by the user. */ override val glucoseUnit: GlucoseUnit - get() = profileFunction.getProfile()?.units ?: GlucoseUnit.MGDL + get() = GlucoseUnit.fromText(sp.getString( + app.aaps.core.utils.R.string.key_units, + GlucoseUnit.MGDL.asText)) /** Returns the remaining bolus insulin on board. */ override val insulinOnboard: Double @@ -65,12 +83,51 @@ class LoopHubImpl @Inject constructor( return if (apsResult == null) Double.NaN else apsResult.percent / 100.0 } + /** Tells the loop algorithm that the pump is physicallly connected. */ + override fun connectPump() { + repo.runTransaction( + CancelCurrentOfflineEventIfAnyTransaction(clock.millis()) + ).subscribe() + commandQueue.cancelTempBasal(true, null) + userEntryLogger.log(UserEntry.Action.RECONNECT, UserEntry.Sources.GarminDevice) + } + + /** Tells the loop algorithm that the pump will be physically disconnected + * for the given number of minutes. */ + override fun disconnectPump(minutes: Int) { + currentProfile?.let { p -> + loop.goToZeroTemp(minutes, p, OfflineEvent.Reason.DISCONNECT_PUMP) + userEntryLogger.log( + UserEntry.Action.DISCONNECT, + UserEntry.Sources.GarminDevice, + ValueWithUnit.Minute(minutes) + ) + } + } + /** Retrieves the glucose values starting at from. */ override fun getGlucoseValues(from: Instant, ascending: Boolean): List { return repo.compatGetBgReadingsDataFromTime(from.toEpochMilli(), ascending) .blockingGet() } + /** Notifies the system that carbs were eaten and stores the value. */ + override fun postCarbs(carbohydrates: Int) { + aapsLogger.info(LTag.GARMIN, "post $carbohydrates g carbohydrates") + val carbsAfterConstraints = + carbohydrates.coerceAtMost(constraintChecker.getMaxCarbsAllowed().value()) + userEntryLogger.log( + UserEntry.Action.CARBS, + UserEntry.Sources.GarminDevice, + ValueWithUnit.Gram(carbsAfterConstraints) + ) + val detailedBolusInfo = DetailedBolusInfo().apply { + eventType = DetailedBolusInfo.EventType.CARBS_CORRECTION + carbs = carbsAfterConstraints.toDouble() + } + commandQueue.bolus(detailedBolusInfo, null) + } + /** Stores hear rate readings that a taken and averaged of the given interval. */ override fun storeHeartRate( samplingStart: Instant, samplingEnd: Instant, diff --git a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminPluginTest.kt b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminPluginTest.kt index c0af17edfa..18c772877e 100644 --- a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminPluginTest.kt +++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminPluginTest.kt @@ -1,5 +1,6 @@ package app.aaps.plugins.sync.garmin +import app.aaps.core.interfaces.db.GlucoseUnit import app.aaps.core.interfaces.resources.ResourceHelper import app.aaps.core.interfaces.rx.events.EventNewBG import app.aaps.core.interfaces.sharedPreferences.SP @@ -9,14 +10,18 @@ import dagger.android.AndroidInjector import dagger.android.HasAndroidInjector import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.anyLong import org.mockito.Mock import org.mockito.Mockito.atMost import org.mockito.Mockito.mock +import org.mockito.Mockito.times import org.mockito.Mockito.verify import org.mockito.Mockito.verifyNoMoreInteractions import org.mockito.Mockito.`when` +import java.net.SocketAddress import java.net.URI import java.time.Clock import java.time.Instant @@ -113,4 +118,91 @@ class GarminPluginTest: TestBase() { verify(gp.newValue).signalAll() verify(loopHub).getGlucoseValues(from, true) } + + @Test + fun testOnGetBloodGlucose() { + `when`(loopHub.isConnected).thenReturn(true) + `when`(loopHub.insulinOnboard).thenReturn(3.14) + `when`(loopHub.temporaryBasal).thenReturn(0.8) + val from = getGlucoseValuesFrom + `when`(loopHub.getGlucoseValues(from, true)).thenReturn( + listOf(createGlucoseValue(Instant.ofEpochSecond(1_000)))) + val hr = createHeartRate(99) + val uri = createUri(hr) + val result = gp.onGetBloodGlucose(mock(SocketAddress::class.java), uri, null) + assertEquals( + "{\"encodedGlucose\":\"0A+6AQ==\"," + + "\"remainingInsulin\":3.14," + + "\"glucoseUnit\":\"mmoll\",\"temporaryBasalRate\":0.8," + + "\"profile\":\"D\",\"connected\":true}", + result.toString()) + verify(loopHub).getGlucoseValues(from, true) + verify(loopHub).insulinOnboard + verify(loopHub).temporaryBasal + verify(loopHub).isConnected + verify(loopHub).glucoseUnit + verify(loopHub).storeHeartRate( + Instant.ofEpochSecond(hr["hrStart"] as Long), + Instant.ofEpochSecond(hr["hrEnd"] as Long), + 99, + hr["device"] as String) + } + + @Test + fun testOnGetBloodGlucose_Wait() { + `when`(loopHub.isConnected).thenReturn(true) + `when`(loopHub.insulinOnboard).thenReturn(3.14) + `when`(loopHub.temporaryBasal).thenReturn(0.8) + `when`(loopHub.glucoseUnit).thenReturn(GlucoseUnit.MMOL) + val from = getGlucoseValuesFrom + `when`(loopHub.getGlucoseValues(from, true)).thenReturn( + listOf(createGlucoseValue(clock.instant().minusSeconds(330)))) + val params = createHeartRate(99).toMutableMap() + params["wait"] = 10 + val uri = createUri(params) + gp.newValue = mock(Condition::class.java) + val result = gp.onGetBloodGlucose(mock(SocketAddress::class.java), uri, null) + assertEquals( + "{\"encodedGlucose\":\"/wS6AQ==\"," + + "\"remainingInsulin\":3.14," + + "\"glucoseUnit\":\"mmoll\",\"temporaryBasalRate\":0.8," + + "\"profile\":\"D\",\"connected\":true}", + result.toString()) + verify(gp.newValue).awaitNanos(anyLong()) + verify(loopHub, times(2)).getGlucoseValues(from, true) + verify(loopHub).insulinOnboard + verify(loopHub).temporaryBasal + verify(loopHub).isConnected + verify(loopHub).glucoseUnit + verify(loopHub).storeHeartRate( + Instant.ofEpochSecond(params["hrStart"] as Long), + Instant.ofEpochSecond(params["hrEnd"] as Long), + 99, + params["device"] as String) + } + + @Test + fun testOnPostCarbs() { + val uri = createUri(mapOf("carbs" to "12")) + assertEquals("", gp.onPostCarbs(mock(SocketAddress::class.java), uri, null)) + verify(loopHub).postCarbs(12) + } + + @Test + fun testOnConnectPump_Disconnect() { + val uri = createUri(mapOf("disconnectMinutes" to "20")) + `when`(loopHub.isConnected).thenReturn(false) + assertEquals("{\"connected\":false}", gp.onConnectPump(mock(SocketAddress::class.java), uri, null)) + verify(loopHub).disconnectPump(20) + verify(loopHub).isConnected + } + + @Test + fun testOnConnectPump_Connect() { + val uri = createUri(mapOf("disconnectMinutes" to "0")) + `when`(loopHub.isConnected).thenReturn(true) + assertEquals("{\"connected\":true}", gp.onConnectPump(mock(SocketAddress::class.java), uri, null)) + verify(loopHub).connectPump() + verify(loopHub).isConnected + } } diff --git a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/LoopHubTest.kt b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/LoopHubTest.kt index 6f1cccad86..a88facfa05 100644 --- a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/LoopHubTest.kt +++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/LoopHubTest.kt @@ -1,8 +1,8 @@ package app.aaps.plugins.sync.garmin - import app.aaps.core.interfaces.aps.APSResult import app.aaps.core.interfaces.aps.Loop +import app.aaps.core.interfaces.constraints.Constraint import app.aaps.core.interfaces.constraints.ConstraintsChecker import app.aaps.core.interfaces.db.GlucoseUnit import app.aaps.core.interfaces.iob.IobCobCalculator @@ -10,13 +10,19 @@ import app.aaps.core.interfaces.iob.IobTotal import app.aaps.core.interfaces.logging.UserEntryLogger import app.aaps.core.interfaces.profile.Profile import app.aaps.core.interfaces.profile.ProfileFunction +import app.aaps.core.interfaces.pump.DetailedBolusInfo import app.aaps.core.interfaces.queue.CommandQueue +import app.aaps.core.interfaces.sharedPreferences.SP import app.aaps.database.ValueWrapper import app.aaps.database.entities.EffectiveProfileSwitch import app.aaps.database.entities.GlucoseValue import app.aaps.database.entities.HeartRate +import app.aaps.database.entities.OfflineEvent +import app.aaps.database.entities.UserEntry +import app.aaps.database.entities.ValueWithUnit import app.aaps.database.entities.embedments.InsulinConfiguration import app.aaps.database.impl.AppRepository +import app.aaps.database.impl.transactions.CancelCurrentOfflineEventIfAnyTransaction import app.aaps.database.impl.transactions.InsertOrUpdateHeartRateTransaction import app.aaps.shared.tests.TestBase import io.reactivex.rxjava3.core.Completable @@ -27,6 +33,8 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.argThat +import org.mockito.ArgumentMatchers.isNull import org.mockito.Mock import org.mockito.Mockito.mock import org.mockito.Mockito.times @@ -45,13 +53,17 @@ class LoopHubTest: TestBase() { @Mock lateinit var profileFunction: ProfileFunction @Mock lateinit var repo: AppRepository @Mock lateinit var userEntryLogger: UserEntryLogger + @Mock lateinit var sp: SP private lateinit var loopHub: LoopHubImpl private val clock = Clock.fixed(Instant.ofEpochMilli(10_000), ZoneId.of("UTC")) @BeforeEach fun setup() { - loopHub = LoopHubImpl(iobCobCalculator, loop, profileFunction, repo) + loopHub = LoopHubImpl( + aapsLogger, commandQueue, constraints, iobCobCalculator, loop, + profileFunction, repo, userEntryLogger, sp + ) loopHub.clock = clock } @@ -83,18 +95,10 @@ class LoopHubTest: TestBase() { @Test fun testGlucoseUnit() { - val profile = mock(Profile::class.java) - `when`(profile.units).thenReturn(GlucoseUnit.MMOL) - `when`(profileFunction.getProfile()).thenReturn(profile) - assertEquals(GlucoseUnit.MMOL, loopHub.glucoseUnit) - verify(profileFunction, times(1)).getProfile() - } - - @Test - fun testGlucoseUnitNullProfile() { - `when`(profileFunction.getProfile()).thenReturn(null) + `when`(sp.getString(app.aaps.core.utils.R.string.key_units, GlucoseUnit.MGDL.asText)).thenReturn("mg/dl") assertEquals(GlucoseUnit.MGDL, loopHub.glucoseUnit) - verify(profileFunction, times(1)).getProfile() + `when`(sp.getString(app.aaps.core.utils.R.string.key_units, GlucoseUnit.MGDL.asText)).thenReturn("mmol") + assertEquals(GlucoseUnit.MMOL, loopHub.glucoseUnit) } @Test @@ -165,6 +169,32 @@ class LoopHubTest: TestBase() { verify(loop, times(1)).lastRun } + @Test + fun testConnectPump() { + val c = mock(Completable::class.java) + val dummy = CancelCurrentOfflineEventIfAnyTransaction(0) + val matcher = { + argThat { t -> t.timestamp == clock.millis() }} + `when`(repo.runTransaction(matcher() ?: dummy)).thenReturn(c) + loopHub.connectPump() + verify(repo).runTransaction(matcher() ?: dummy) + verify(commandQueue).cancelTempBasal(true, null) + verify(userEntryLogger).log(UserEntry.Action.RECONNECT, UserEntry.Sources.GarminDevice) + } + + @Test + fun testDisconnectPump() { + val profile = mock(Profile::class.java) + `when`(profileFunction.getProfile()).thenReturn(profile) + loopHub.disconnectPump(23) + verify(profileFunction).getProfile() + verify(loop).goToZeroTemp(23, profile, OfflineEvent.Reason.DISCONNECT_PUMP) + verify(userEntryLogger).log( + UserEntry.Action.DISCONNECT, + UserEntry.Sources.GarminDevice, + ValueWithUnit.Minute(23)) + } + @Test fun testGetGlucoseValues() { val glucoseValues = listOf( @@ -180,6 +210,25 @@ class LoopHubTest: TestBase() { verify(repo).compatGetBgReadingsDataFromTime(1001_000, false) } + @Test + fun testPostCarbs() { + @Suppress("unchecked_cast") + val constraint = mock(Constraint::class.java) as Constraint + `when`(constraint.value()).thenReturn(99) + `when`(constraints.getMaxCarbsAllowed()).thenReturn(constraint) + loopHub.postCarbs(100) + verify(constraints).getMaxCarbsAllowed() + verify(userEntryLogger).log( + UserEntry.Action.CARBS, + UserEntry.Sources.GarminDevice, + ValueWithUnit.Gram(99)) + verify(commandQueue).bolus( + argThat { b -> + b!!.eventType == DetailedBolusInfo.EventType.CARBS_CORRECTION && + b.carbs == 99.0 }?: DetailedBolusInfo() , + isNull()) + } + @Test fun testStoreHeartRate() { val samplingStart = Instant.ofEpochMilli(1_001_000)