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 5d20fd3c70..81b03f98c5 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 @@ -14,10 +14,14 @@ import app.aaps.core.interfaces.rx.events.EventPreferenceChange import app.aaps.core.interfaces.sharedPreferences.SP import app.aaps.database.entities.GlucoseValue import app.aaps.plugins.sync.R +import com.google.gson.JsonArray import com.google.gson.JsonObject import dagger.android.HasAndroidInjector import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.schedulers.Schedulers +import java.math.BigDecimal +import java.math.MathContext +import java.math.RoundingMode import java.net.SocketAddress import java.net.URI import java.time.Clock @@ -96,6 +100,7 @@ class GarminPlugin @Inject constructor( registerEndpoint("/get", ::onGetBloodGlucose) registerEndpoint("/carbs", ::onPostCarbs) registerEndpoint("/connect", ::onConnectPump) + registerEndpoint("/sgv.json", ::onSgv) } } else if (server != null) { aapsLogger.info(LTag.GARMIN, "stopping HTTP server") @@ -277,4 +282,54 @@ class GarminPlugin @Inject constructor( jo.addProperty("connected", loopHub.isConnected) return jo.toString() } + + private fun glucoseSlopeMgDlPerMilli(glucose1: GlucoseValue, glucose2: GlucoseValue): Double { + return (glucose2.value - glucose1.value) / (glucose2.timestamp - glucose1.timestamp) + } + + /** Returns glucose values in Nightscout/Xdrip format. */ + @VisibleForTesting + @Suppress("UNUSED_PARAMETER") + fun onSgv(call: SocketAddress, uri: URI, requestBody: String?): CharSequence { + val count = getQueryParameter(uri,"count", 24L) + .toInt().coerceAtMost(1000).coerceAtLeast(1) + val briefMode = getQueryParameter(uri, "brief_mode", false) + + // Guess a start time to get [count+1] readings. This is a heuristic that only works if we get readings + // every 5 minutes and we're not missing readings. We truncate in case we get more readings but we'll + // get less, e.g., in case we're missing readings for the last half hour. We get one extra reading, + // to compute the glucose delta. + val from = clock.instant().minus(Duration.ofMinutes(5L * (count + 1))) + val glucoseValues = loopHub.getGlucoseValues(from, false) + val joa = JsonArray() + for (i in 0 until count.coerceAtMost(glucoseValues.size)) { + val jo = JsonObject() + val glucose = glucoseValues[i] + if (!briefMode) { + jo.addProperty("_id", glucose.id.toString()) + jo.addProperty("device", glucose.sourceSensor.toString()) + val timestamp = Instant.ofEpochMilli(glucose.timestamp) + jo.addProperty("deviceString", timestamp.toString()) + jo.addProperty("sysTime", timestamp.toString()) + glucose.raw?.let { raw -> jo.addProperty("unfiltered", raw) } + } + jo.addProperty("date", glucose.timestamp) + jo.addProperty("sgv", glucose.value.roundToInt()) + if (i + 1 < glucoseValues.size) { + // Compute the 5 minute delta. + val delta = 300_000.0 * glucoseSlopeMgDlPerMilli(glucoseValues[i + 1], glucose) + jo.addProperty("delta", BigDecimal(delta, MathContext(3, RoundingMode.HALF_UP))) + } + jo.addProperty("direction", glucose.trendArrow.text) + glucose.noise?.let { n -> jo.addProperty("noise", n) } + if (i == 0) { + when (loopHub.glucoseUnit) { + GlucoseUnit.MGDL -> jo.addProperty("units_hint", "mgdl") + GlucoseUnit.MMOL -> jo.addProperty("units_hint", "mmol") + } + } + joa.add(jo) + } + return joa.toString() + } } 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 18c772877e..6913ad4609 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 @@ -21,6 +21,10 @@ import org.mockito.Mockito.times import org.mockito.Mockito.verify import org.mockito.Mockito.verifyNoMoreInteractions import org.mockito.Mockito.`when` +import org.mockito.kotlin.any +import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.eq +import org.mockito.kotlin.whenever import java.net.SocketAddress import java.net.URI import java.time.Clock @@ -28,6 +32,7 @@ import java.time.Instant import java.time.ZoneId import java.time.temporal.ChronoUnit import java.util.concurrent.locks.Condition +import kotlin.ranges.LongProgression.Companion.fromClosedRange class GarminPluginTest: TestBase() { private lateinit var gp: GarminPlugin @@ -71,8 +76,9 @@ class GarminPluginTest: TestBase() { "device" to "Test_Device") private fun createGlucoseValue(timestamp: Instant, value: Double = 93.0) = GlucoseValue( + id = 10 * timestamp.toEpochMilli(), timestamp = timestamp.toEpochMilli(), raw = 90.0, value = value, - trendArrow = GlucoseValue.TrendArrow.FLAT, noise = null, + trendArrow = GlucoseValue.TrendArrow.FLAT, noise = 4.5, sourceSensor = GlucoseValue.SourceSensor.RANDOM ) @@ -205,4 +211,56 @@ class GarminPluginTest: TestBase() { verify(loopHub).connectPump() verify(loopHub).isConnected } + + @Test + fun onSgv_NoGlucose() { + whenever(loopHub.glucoseUnit).thenReturn(GlucoseUnit.MMOL) + whenever(loopHub.getGlucoseValues(any(), eq(false))).thenReturn(emptyList()) + assertEquals("[]", gp.onSgv(mock(), createUri(mapOf()), null)) + verify(loopHub).getGlucoseValues(clock.instant().minusSeconds(25L * 300L), false) + } + + @Test + fun onSgv_NoDelta() { + whenever(loopHub.glucoseUnit).thenReturn(GlucoseUnit.MMOL) + whenever(loopHub.getGlucoseValues(any(), eq(false))).thenReturn( + listOf(createGlucoseValue( + clock.instant().minusSeconds(100L), 99.3))) + assertEquals( + """[{"_id":"-900000","device":"RANDOM","deviceString":"1969-12-31T23:58:30Z","sysTime":"1969-12-31T23:58:30Z","unfiltered":90.0,"date":-90000,"sgv":99,"direction":"Flat","noise":4.5,"units_hint":"mmol"}]""", + gp.onSgv(mock(), createUri(mapOf()), null)) + verify(loopHub).getGlucoseValues(clock.instant().minusSeconds(25L * 300L), false) + verify(loopHub).glucoseUnit + } + + @Test + fun onSgv() { + whenever(loopHub.glucoseUnit).thenReturn(GlucoseUnit.MMOL) + whenever(loopHub.getGlucoseValues(any(), eq(false))).thenAnswer { i -> + val from = i.getArgument(0) + fromClosedRange(from.toEpochMilli(), clock.instant().toEpochMilli(), 300_000L) + .map(Instant::ofEpochMilli) + .mapIndexed { idx, ts -> createGlucoseValue(ts, 100.0+(10 * idx)) }.reversed()} + assertEquals( + """[{"_id":"100000","device":"RANDOM","deviceString":"1970-01-01T00:00:10Z","sysTime":"1970-01-01T00:00:10Z","unfiltered":90.0,"date":10000,"sgv":120,"delta":10,"direction":"Flat","noise":4.5,"units_hint":"mmol"}]""", + gp.onSgv(mock(), createUri(mapOf("count" to "1")), null)) + verify(loopHub).getGlucoseValues( + clock.instant().minusSeconds(600L), false) + + assertEquals( + """[{"_id":"100000","device":"RANDOM","deviceString":"1970-01-01T00:00:10Z","sysTime":"1970-01-01T00:00:10Z","unfiltered":90.0,"date":10000,"sgv":130,"delta":10,"direction":"Flat","noise":4.5,"units_hint":"mmol"},""" + + """{"_id":"-2900000","device":"RANDOM","deviceString":"1969-12-31T23:55:10Z","sysTime":"1969-12-31T23:55:10Z","unfiltered":90.0,"date":-290000,"sgv":120,"delta":10,"direction":"Flat","noise":4.5}]""", + gp.onSgv(mock(), createUri(mapOf("count" to "2")), null)) + verify(loopHub).getGlucoseValues( + clock.instant().minusSeconds(900L), false) + + assertEquals( + """[{"date":10000,"sgv":130,"delta":10,"direction":"Flat","noise":4.5,"units_hint":"mmol"},""" + + """{"date":-290000,"sgv":120,"delta":10,"direction":"Flat","noise":4.5}]""", + gp.onSgv(mock(), createUri(mapOf("count" to "2", "brief_mode" to "true")), null)) + verify(loopHub, times(2)).getGlucoseValues( + clock.instant().minusSeconds(900L), false) + + verify(loopHub, atLeastOnce()).glucoseUnit + } }