From 5a17a05ee0ce2eb84f256272b099179870ec846a Mon Sep 17 00:00:00 2001 From: Robert Buessow Date: Thu, 12 Oct 2023 21:34:18 +0200 Subject: [PATCH] Local http server communication to integrate Garmin devices. --- .../aaps/activities/MyPreferenceFragment.kt | 3 + .../kotlin/app/aaps/di/PluginsListModule.kt | 7 + .../app/aaps/core/interfaces/logging/LTag.kt | 1 + .../app/aaps/database/entities/UserEntry.kt | 2 + .../InsertOrUpdateHeartRateTransaction.kt | 11 + .../UserEntryPresentationHelperImpl.kt | 1 + .../app/aaps/plugins/main/di/PluginsModule.kt | 4 +- .../general/garmin/DeltaVarEncodedList.kt | 187 +++++++++++++ .../main/general/garmin/GarminModule.kt | 10 + .../main/general/garmin/GarminPlugin.kt | 245 +++++++++++++++++ .../plugins/main/general/garmin/HttpServer.kt | 257 ++++++++++++++++++ .../plugins/main/general/garmin/LoopHub.kt | 41 +++ .../main/general/garmin/LoopHubImpl.kt | 88 ++++++ plugins/main/src/main/res/values/strings.xml | 3 + plugins/main/src/main/res/xml/pref_garmin.xml | 23 ++ .../general/garmin/DeltaVarEncodedListTest.kt | 192 +++++++++++++ .../main/general/garmin/GarminPluginTest.kt | 116 ++++++++ .../main/general/garmin/HttpServerTest.kt | 99 +++++++ .../main/general/garmin/LoopHubTest.kt | 201 ++++++++++++++ 19 files changed, 1490 insertions(+), 1 deletion(-) create mode 100644 plugins/main/src/main/kotlin/app/aaps/plugins/main/general/garmin/DeltaVarEncodedList.kt create mode 100644 plugins/main/src/main/kotlin/app/aaps/plugins/main/general/garmin/GarminModule.kt create mode 100644 plugins/main/src/main/kotlin/app/aaps/plugins/main/general/garmin/GarminPlugin.kt create mode 100644 plugins/main/src/main/kotlin/app/aaps/plugins/main/general/garmin/HttpServer.kt create mode 100644 plugins/main/src/main/kotlin/app/aaps/plugins/main/general/garmin/LoopHub.kt create mode 100644 plugins/main/src/main/kotlin/app/aaps/plugins/main/general/garmin/LoopHubImpl.kt create mode 100644 plugins/main/src/main/res/xml/pref_garmin.xml create mode 100644 plugins/main/src/test/kotlin/app/aaps/plugins/main/general/garmin/DeltaVarEncodedListTest.kt create mode 100644 plugins/main/src/test/kotlin/app/aaps/plugins/main/general/garmin/GarminPluginTest.kt create mode 100644 plugins/main/src/test/kotlin/app/aaps/plugins/main/general/garmin/HttpServerTest.kt create mode 100644 plugins/main/src/test/kotlin/app/aaps/plugins/main/general/garmin/LoopHubTest.kt diff --git a/app/src/main/kotlin/app/aaps/activities/MyPreferenceFragment.kt b/app/src/main/kotlin/app/aaps/activities/MyPreferenceFragment.kt index 530b990657..0d46f6a925 100644 --- a/app/src/main/kotlin/app/aaps/activities/MyPreferenceFragment.kt +++ b/app/src/main/kotlin/app/aaps/activities/MyPreferenceFragment.kt @@ -43,6 +43,7 @@ import app.aaps.plugins.configuration.maintenance.MaintenancePlugin import app.aaps.plugins.constraints.safety.SafetyPlugin import app.aaps.plugins.insulin.InsulinOrefFreePeakPlugin import app.aaps.plugins.main.general.smsCommunicator.SmsCommunicatorPlugin +import app.aaps.plugins.main.general.garmin.GarminPlugin import app.aaps.plugins.main.general.wear.WearPlugin import app.aaps.plugins.sensitivity.SensitivityAAPSPlugin import app.aaps.plugins.sensitivity.SensitivityOref1Plugin @@ -128,6 +129,7 @@ class MyPreferenceFragment : PreferenceFragmentCompat(), OnSharedPreferenceChang @Inject lateinit var nsSettingStatus: NSSettingsStatus @Inject lateinit var openHumansUploaderPlugin: OpenHumansUploaderPlugin @Inject lateinit var diaconnG8Plugin: DiaconnG8Plugin + @Inject lateinit var garminPlugin: GarminPlugin override fun onAttach(context: Context) { AndroidSupportInjection.inject(this) @@ -229,6 +231,7 @@ class MyPreferenceFragment : PreferenceFragmentCompat(), OnSharedPreferenceChang addPreferencesFromResource(app.aaps.plugins.configuration.R.xml.pref_datachoices, rootKey) addPreferencesFromResourceIfEnabled(maintenancePlugin, rootKey) addPreferencesFromResourceIfEnabled(openHumansUploaderPlugin, rootKey) + addPreferencesFromResourceIfEnabled(garminPlugin, rootKey) } initSummary(preferenceScreen, pluginId != -1) preprocessPreferences() diff --git a/app/src/main/kotlin/app/aaps/di/PluginsListModule.kt b/app/src/main/kotlin/app/aaps/di/PluginsListModule.kt index 10b296c758..8430e9eab3 100644 --- a/app/src/main/kotlin/app/aaps/di/PluginsListModule.kt +++ b/app/src/main/kotlin/app/aaps/di/PluginsListModule.kt @@ -22,6 +22,7 @@ import app.aaps.plugins.insulin.InsulinOrefRapidActingPlugin import app.aaps.plugins.insulin.InsulinOrefUltraRapidActingPlugin import app.aaps.plugins.main.general.actions.ActionsPlugin import app.aaps.plugins.main.general.food.FoodPlugin +import app.aaps.plugins.main.general.garmin.GarminPlugin import app.aaps.plugins.main.general.overview.OverviewPlugin import app.aaps.plugins.main.general.persistentNotification.PersistentNotificationPlugin import app.aaps.plugins.main.general.smsCommunicator.SmsCommunicatorPlugin @@ -465,6 +466,12 @@ abstract class PluginsListModule { @IntKey(610) abstract fun bindAvgSmoothingPlugin(plugin: AvgSmoothingPlugin): PluginBase + @Binds + @AllConfigs + @IntoMap + @IntKey(623) + abstract fun bindGarminPlugin(plugin: GarminPlugin): PluginBase + @Qualifier annotation class AllConfigs diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/logging/LTag.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/logging/LTag.kt index d516a7822f..69a38331b7 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/logging/LTag.kt +++ b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/logging/LTag.kt @@ -12,6 +12,7 @@ enum class LTag(val tag: String, val defaultValue: Boolean = true, val requiresR DATABASE("DATABASE"), DATATREATMENTS("DATATREATMENTS"), EVENTS("EVENTS", defaultValue = false, requiresRestart = true), + GARMIN("GARMIN"), GLUCOSE("GLUCOSE", defaultValue = false), HTTP("HTTP"), LOCATION("LOCATION"), diff --git a/database/entities/src/main/kotlin/app/aaps/database/entities/UserEntry.kt b/database/entities/src/main/kotlin/app/aaps/database/entities/UserEntry.kt index 98b04d65ac..d336cbfd11 100644 --- a/database/entities/src/main/kotlin/app/aaps/database/entities/UserEntry.kt +++ b/database/entities/src/main/kotlin/app/aaps/database/entities/UserEntry.kt @@ -187,7 +187,9 @@ data class UserEntry( Overview, //From OverViewPlugin Stats, //From Stat Activity Aaps, // MainApp + GarminDevice, Unknown //if necessary + , ; companion object { diff --git a/database/impl/src/main/java/app/aaps/database/impl/transactions/InsertOrUpdateHeartRateTransaction.kt b/database/impl/src/main/java/app/aaps/database/impl/transactions/InsertOrUpdateHeartRateTransaction.kt index c9c56975b6..14b58ea1d5 100644 --- a/database/impl/src/main/java/app/aaps/database/impl/transactions/InsertOrUpdateHeartRateTransaction.kt +++ b/database/impl/src/main/java/app/aaps/database/impl/transactions/InsertOrUpdateHeartRateTransaction.kt @@ -17,5 +17,16 @@ class InsertOrUpdateHeartRateTransaction(private val heartRate: HeartRate) : } } + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as InsertOrUpdateHeartRateTransaction + return heartRate == other.heartRate + } + + override fun hashCode(): Int { + return heartRate.hashCode() + } + data class TransactionResult(val inserted: List, val updated: List) } diff --git a/implementation/src/main/kotlin/app/aaps/implementation/userEntry/UserEntryPresentationHelperImpl.kt b/implementation/src/main/kotlin/app/aaps/implementation/userEntry/UserEntryPresentationHelperImpl.kt index 46b4ca5f74..35c42443a3 100644 --- a/implementation/src/main/kotlin/app/aaps/implementation/userEntry/UserEntryPresentationHelperImpl.kt +++ b/implementation/src/main/kotlin/app/aaps/implementation/userEntry/UserEntryPresentationHelperImpl.kt @@ -108,6 +108,7 @@ class UserEntryPresentationHelperImpl @Inject constructor( Sources.ConfigBuilder -> app.aaps.core.ui.R.drawable.ic_cogs Sources.Overview -> app.aaps.core.ui.R.drawable.ic_home Sources.Aaps -> R.drawable.ic_aaps + Sources.GarminDevice -> app.aaps.core.ui.R.drawable.ic_generic_icon Sources.Unknown -> app.aaps.core.ui.R.drawable.ic_generic_icon } diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/di/PluginsModule.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/di/PluginsModule.kt index 5f6a389b3b..bb02312d79 100644 --- a/plugins/main/src/main/kotlin/app/aaps/plugins/main/di/PluginsModule.kt +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/di/PluginsModule.kt @@ -2,6 +2,7 @@ package app.aaps.plugins.main.di import app.aaps.core.interfaces.iob.IobCobCalculator import app.aaps.core.interfaces.smsCommunicator.SmsCommunicator +import app.aaps.plugins.main.general.garmin.GarminModule import app.aaps.plugins.main.general.persistentNotification.DummyService import app.aaps.plugins.main.general.smsCommunicator.SmsCommunicatorPlugin import app.aaps.plugins.main.general.wear.WearFragment @@ -22,7 +23,8 @@ import dagger.android.ContributesAndroidInjector SkinsUiModule::class, ActionsModule::class, WearModule::class, - OverviewModule::class + OverviewModule::class, + GarminModule::class, ] ) diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/garmin/DeltaVarEncodedList.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/garmin/DeltaVarEncodedList.kt new file mode 100644 index 0000000000..9934bd0215 --- /dev/null +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/garmin/DeltaVarEncodedList.kt @@ -0,0 +1,187 @@ +package app.aaps.plugins.main.general.garmin + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.IntBuffer +import java.nio.LongBuffer +import java.util.Base64 + +/** Efficient encoding for glucose/timestamp pairs. + * + * Garmin devices don't have much memory when deserializing received JSON messages. + * In particular older devices my kill our app when we send 2h of glucose values. Therefore, we + * encode the values efficiently. + * We use [var encoding](https://en.wikipedia.org/wiki/Variable-width_encoding). In order to + * keep timestamps small, we encode the difference to the previous pair and to encode negative values + * efficiently, we use [zig-zag encoding](https://en.wikipedia.org/wiki/Variable-length_quantity). + */ +class DeltaVarEncodedList { + private var lastValues: IntArray + private var data: ByteArray + private val start: Int = 0 + private var end: Int = 0 + + val byteSize: Int get() = end - start + var size: Int = 0 + private set + + /** Creates a new list of given size. + * + * @param byteSize How large the internal buffer should be. The buffer doesn't grow + * automatically, so you need to set it large enough. + * @param entrySize Size of each entry (e.g. 2 for glucose+timestamp). Delta is computed on each + * entrySize value. + */ + constructor(byteSize: Int, entrySize: Int) { + data = ByteArray(toLongBoundary(byteSize)) + lastValues = IntArray(entrySize) + } + + /** Creates a list from encoded values. + * + * @param lastValues the last values of the list. Needs to be entrySize long. + * @param byteBuffer the encoded data + */ + constructor(lastValues: IntArray, byteBuffer: ByteBuffer) { + this.lastValues = lastValues + data = ByteArray(byteBuffer.limit()) + byteBuffer.position(0) + byteBuffer.get(data) + end = data.size + val it = DeltaIterator() + while (it.next()) { + size++ + } + } + + /** Gets the encoded data. */ + fun encodedData(): List { + val byteBuffer: ByteBuffer = ByteBuffer.wrap(data) + byteBuffer.order(ByteOrder.LITTLE_ENDIAN) + byteBuffer.limit(toLongBoundary(end)) + val buffer: LongBuffer = byteBuffer.asLongBuffer() + val encodedData: MutableList = ArrayList(buffer.limit()) + while (buffer.position() < buffer.limit()) { + encodedData.add(buffer.get()) + } + return encodedData + } + + fun encodedBase64(): String { + val byteBuffer: ByteBuffer = ByteBuffer.wrap(data, start, end) + byteBuffer.order(ByteOrder.LITTLE_ENDIAN) + return String(Base64.getEncoder().encode(byteBuffer).array()) + } + + private fun addVarEncoded(value: Int) { + var remaining: Int = value + do { + // Grow data if needed (double size). + if (end == data.size) { + val newData = ByteArray(2 * end) + System.arraycopy(data, 0, newData, 0, end) + data = newData + } + if ((remaining and 0x7f.inv()) != 0) { + data[end++] = ((remaining and 0x7f) or 0x80).toByte() + } else { + data[end++] = remaining.toByte() + } + remaining = remaining ushr 7 + } while (remaining != 0) + } + + private fun addI(value: Int, idx: Int) { + val delta: Int = value - lastValues[idx] + addVarEncoded(zigzagEncode(delta)) + lastValues[idx] = value + } + + /** Adds an entry to the buffer. + * + * [values] length must be the same as entrySize provided in the constructor. */ + fun add(vararg values: Int) { + if (values.size != lastValues.size) { + throw IllegalArgumentException() + } + for (idx in values.indices) { + addI(values[idx], idx) + } + size++ + } + + fun toArray(): IntArray { + val values: IntBuffer = IntBuffer.allocate(lastValues.size * size) + val it = DeltaIterator() + while (it.next()) { + values.put(it.current()) + } + val next: IntArray = lastValues.copyOf(lastValues.size) + var nextIdx: Int = next.size - 1 + for (valueIdx in values.position() - 1 downTo 0) { + val value: Int = values.get(valueIdx) + values.put(valueIdx, next[nextIdx]) + next[nextIdx] -= value + nextIdx = (nextIdx + 1) % next.size + } + return values.array() + } + + private inner class DeltaIterator { + + private val buffer: ByteBuffer = ByteBuffer.wrap(data) + private val currentValues: IntArray = IntArray(lastValues.size) + private var more: Boolean = false + fun current(): IntArray { + return currentValues + } + + private fun readNext(): Int { + var v = 0 + var offset = 0 + var b: Int + do { + if (!buffer.hasRemaining()) { + more = false + return 0 + } + b = buffer.get().toInt() + v = v or ((b and 0x7f) shl offset) + offset += 7 + } while ((b and 0x80) != 0) + return zigzagDecode(v) + } + + operator fun next(): Boolean { + if (!buffer.hasRemaining()) return false + more = true + var i = 0 + while (i < currentValues.size && more) { + currentValues[i] = readNext() + i++ + } + return more + } + + init { + buffer.position(start) + buffer.limit(end) + buffer.order(ByteOrder.LITTLE_ENDIAN) + } + } + + companion object { + + private fun toLongBoundary(i: Int): Int { + return 8 * ((i + 7) / 8) + } + + private fun zigzagEncode(i: Int): Int { + return (i shr 31) xor (i shl 1) + } + + private fun zigzagDecode(i: Int): Int { + return (i ushr 1) xor -(i and 1) + } + } +} \ No newline at end of file diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/garmin/GarminModule.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/garmin/GarminModule.kt new file mode 100644 index 0000000000..255ceceadb --- /dev/null +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/garmin/GarminModule.kt @@ -0,0 +1,10 @@ +package app.aaps.plugins.main.general.garmin + +import dagger.Binds +import dagger.Module + +@Module +abstract class GarminModule { + @Suppress("unused") + @Binds abstract fun bindLoopHub(loopHub: LoopHubImpl): LoopHub +} \ No newline at end of file diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/garmin/GarminPlugin.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/garmin/GarminPlugin.kt new file mode 100644 index 0000000000..6a87777301 --- /dev/null +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/garmin/GarminPlugin.kt @@ -0,0 +1,245 @@ +package app.aaps.plugins.main.general.garmin + +import androidx.annotation.VisibleForTesting +import app.aaps.core.interfaces.db.GlucoseUnit +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.plugin.PluginBase +import app.aaps.core.interfaces.plugin.PluginDescription +import app.aaps.core.interfaces.plugin.PluginType +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.rx.bus.RxBus +import app.aaps.core.interfaces.rx.events.EventNewBG +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.main.R +import com.google.gson.JsonObject +import dagger.android.HasAndroidInjector +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.schedulers.Schedulers +import java.net.SocketAddress +import java.net.URI +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.util.* +import java.util.concurrent.locks.Condition +import java.util.concurrent.locks.ReentrantLock +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.concurrent.withLock +import kotlin.math.roundToInt + +/** Support communication with Garmin devices. + * + * This plugin supports sending glucose values to Garmin devices and receiving + * carbs, heart rate and pump disconnect events from the device. It communicates + * via HTTP on localhost or Garmin's native CIQ library. + */ +@Singleton +class GarminPlugin @Inject constructor( + injector: HasAndroidInjector, + aapsLogger: AAPSLogger, + resourceHelper: ResourceHelper, + private val loopHub: LoopHub, + private val rxBus: RxBus, + private val sp: SP, +) : PluginBase( + PluginDescription() + .mainType(PluginType.GENERAL) + .pluginName(R.string.garmin) + .shortName(R.string.garmin) + .description(R.string.garmin_description) + .preferencesId(R.xml.pref_garmin), + aapsLogger, resourceHelper, injector +) { + /** HTTP Server for local HTTP server communication (device app requests values) .*/ + private var server: HttpServer? = null + + private val disposable = CompositeDisposable() + + @VisibleForTesting + var clock: Clock = Clock.systemUTC() + + private val valueLock = ReentrantLock() + @VisibleForTesting + var newValue: Condition = valueLock.newCondition() + private var lastGlucoseValueTimestamp: Long? = null + private val glucoseUnitStr get() = if (loopHub.glucoseUnit == GlucoseUnit.MGDL) "mgdl" else "mmoll" + + private fun onPreferenceChange(event: EventPreferenceChange) { + aapsLogger.info(LTag.GARMIN, "preferences change ${event.changedKey}") + setupHttpServer() + } + + override fun onStart() { + super.onStart() + aapsLogger.info(LTag.GARMIN, "start") + disposable.add( + rxBus + .toObservable(EventPreferenceChange::class.java) + .observeOn(Schedulers.io()) + .subscribe(::onPreferenceChange) + ) + setupHttpServer() + } + + private fun setupHttpServer() { + if (sp.getBoolean("communication_http", false)) { + val port = sp.getInt("communication_http_port", 28891) + if (server != null && server?.port == port) return + aapsLogger.info(LTag.GARMIN, "starting HTTP server on $port") + server?.close() + server = HttpServer(aapsLogger, port).apply { + registerEndpoint("/get", ::onGetBloodGlucose) + } + } else if (server != null) { + aapsLogger.info(LTag.GARMIN, "stopping HTTP server") + server?.close() + server = null + } + } + + override fun onStop() { + disposable.clear() + aapsLogger.info(LTag.GARMIN, "Stop") + server?.close() + server = null + super.onStop() + } + + /** Receive new blood glucose events. + * + * Stores new blood glucose values in lastGlucoseValue to make sure we return + * these values immediately when values are requested by Garmin device. + * Sends a message to the Garmin devices via the ciqMessenger. */ + @VisibleForTesting + fun onNewBloodGlucose(event: EventNewBG) { + val timestamp = event.glucoseValueTimestamp ?: return + aapsLogger.info(LTag.GARMIN, "onNewBloodGlucose ${Date(timestamp)}") + valueLock.withLock { + if ((lastGlucoseValueTimestamp?: 0) >= timestamp) return + lastGlucoseValueTimestamp = timestamp + newValue.signalAll() + } + } + + /** Gets the last 2+ hours of glucose values. */ + @VisibleForTesting + fun getGlucoseValues(): List { + val from = clock.instant().minus(Duration.ofHours(2).plusMinutes(9)) + return loopHub.getGlucoseValues(from, true) + } + + /** Get the last 2+ hours of glucose values and waits in case a new value should arrive soon. */ + private fun getGlucoseValues(maxWait: Duration): List { + val glucoseFrequency = Duration.ofMinutes(5) + val glucoseValues = getGlucoseValues() + val last = glucoseValues.lastOrNull() ?: return emptyList() + val delay = Duration.ofMillis(clock.millis() - last.timestamp) + return if (!maxWait.isZero + && delay > glucoseFrequency + && delay < glucoseFrequency.plusMinutes(1)) { + valueLock.withLock { + aapsLogger.debug(LTag.GARMIN, "waiting for new glucose (delay=$delay)") + newValue.awaitNanos(maxWait.toNanos()) + } + getGlucoseValues() + } else { + glucoseValues + } + } + + private fun encodedGlucose(glucoseValues: List): String { + val encodedGlucose = DeltaVarEncodedList(glucoseValues.size * 16, 2) + for (glucose: GlucoseValue in glucoseValues) { + val timeSec: Int = (glucose.timestamp / 1000).toInt() + val glucoseMgDl: Int = glucose.value.roundToInt() + encodedGlucose.add(timeSec, glucoseMgDl) + } + aapsLogger.info( + LTag.GARMIN, + "retrieved ${glucoseValues.size} last ${Date(glucoseValues.lastOrNull()?.timestamp ?: 0L)} ${encodedGlucose.size}" + ) + return encodedGlucose.encodedBase64() + } + + /** Responses to get glucose value request by the device. + * + * Also, gets the heart rate readings from the device. + */ + @VisibleForTesting + @Suppress("UNUSED_PARAMETER") + fun onGetBloodGlucose(caller: SocketAddress, uri: URI, requestBody: String?): CharSequence { + aapsLogger.info(LTag.GARMIN, "get from $caller resp , req: $uri") + receiveHeartRate(uri) + val profileName = loopHub.currentProfileName + val waitSec = getQueryParameter(uri, "wait", 0L) + val glucoseValues = getGlucoseValues(Duration.ofSeconds(waitSec)) + val jo = JsonObject() + jo.addProperty("encodedGlucose", encodedGlucose(glucoseValues)) + jo.addProperty("remainingInsulin", loopHub.insulinOnboard) + jo.addProperty("glucoseUnit", glucoseUnitStr) + loopHub.temporaryBasal.also { + if (!it.isNaN()) jo.addProperty("temporaryBasalRate", it) + } + jo.addProperty("profile", profileName.first().toString()) + jo.addProperty("connected", loopHub.isConnected) + return jo.toString().also { + aapsLogger.info(LTag.GARMIN, "get from $caller resp , req: $uri, result: $it") + } + } + + private fun getQueryParameter(uri: URI, name: String) = (uri.query ?: "") + .split("&") + .map { kv -> kv.split("=") } + .firstOrNull { kv -> kv.size == 2 && kv[0] == name }?.get(1) + + private fun getQueryParameter( + uri: URI, + @Suppress("SameParameterValue") name: String, + @Suppress("SameParameterValue") defaultValue: Boolean): Boolean { + return when (getQueryParameter(uri, name)?.lowercase()) { + "true" -> true + "false" -> false + else -> defaultValue + } + } + + private fun getQueryParameter( + uri: URI, name: String, + @Suppress("SameParameterValue") defaultValue: Long + ): Long { + val value = getQueryParameter(uri, name) + return try { + if (value.isNullOrEmpty()) defaultValue else value.toLong() + } catch (e: NumberFormatException) { + aapsLogger.error(LTag.GARMIN, "invalid $name value '$value'") + defaultValue + } + } + + @VisibleForTesting + fun receiveHeartRate(uri: URI) { + val avg: Int = getQueryParameter(uri, "hr", 0L).toInt() + val samplingStartSec: Long = getQueryParameter(uri, "hrStart", 0L) + val samplingEndSec: Long = getQueryParameter(uri, "hrEnd", 0L) + val device: String? = getQueryParameter(uri, "device") + receiveHeartRate( + Instant.ofEpochSecond(samplingStartSec), Instant.ofEpochSecond(samplingEndSec), + avg, device, getQueryParameter(uri, "test", false)) + } + + private fun receiveHeartRate( + samplingStart: Instant, samplingEnd: Instant, + avg: Int, device: String?, test: Boolean) { + aapsLogger.info(LTag.GARMIN, "average heart rate $avg BPM test=$test") + if (test) return + if (avg > 10 && samplingStart > Instant.ofEpochMilli(0L) && samplingEnd > samplingStart) { + loopHub.storeHeartRate(samplingStart, samplingEnd, avg, device) + } else { + aapsLogger.warn(LTag.GARMIN, "Skip saving invalid HR $avg $samplingStart..$samplingEnd") + } + } +} diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/garmin/HttpServer.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/garmin/HttpServer.kt new file mode 100644 index 0000000000..fa44d60597 --- /dev/null +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/garmin/HttpServer.kt @@ -0,0 +1,257 @@ +package app.aaps.plugins.main.general.garmin + +import android.os.StrictMode +import androidx.annotation.VisibleForTesting +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import java.io.* +import java.lang.Thread.UncaughtExceptionHandler +import java.net.* +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import java.time.Duration +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.concurrent.locks.ReentrantLock +import java.util.regex.Pattern +import kotlin.concurrent.withLock + +/** Basic HTTP server to communicate with Garmin device via localhost. */ +class HttpServer internal constructor(private var aapsLogger: AAPSLogger, val port: Int): Closeable { + private val serverThread: Thread + private val workerExecutor: Executor = Executors.newCachedThreadPool() + private val endpoints: MutableMapCharSequence> = + ConcurrentHashMap() + private var serverSocket: ServerSocket? = null + private val readyLock = ReentrantLock() + private val readyCond = readyLock.newCondition() + + init { + serverThread = Thread { runServer() } + serverThread.name = "GarminHttpServer" + serverThread.isDaemon = true + serverThread.uncaughtExceptionHandler = UncaughtExceptionHandler { _, e -> + e.printStackTrace() + aapsLogger.error(LTag.GARMIN, "uncaught in HTTP server", e) + serverSocket?.use {} + } + serverThread.start() + } + + override fun close() { + try { + serverSocket?.close() + serverSocket = null + } catch (_: IOException) { + } + try { + serverThread.join(10_000L) + } catch (_: InterruptedException) { + } + } + + /** Wait for the server to start listing to requests. */ + fun awaitReady(wait: Duration): Boolean { + var waitNanos = wait.toNanos() + readyLock.withLock { + while (serverSocket?.isBound != true && waitNanos > 0L) { + waitNanos = readyCond.awaitNanos(waitNanos) + } + } + return serverSocket?.isBound ?: false + } + + /** Register an endpoint (path) to handle requests. */ + fun registerEndpoint(path: String, endpoint: (SocketAddress, URI, String?)->CharSequence) { + aapsLogger.info(LTag.GARMIN,"Register: '$path'") + endpoints[path] = endpoint + } + + + // @Suppress("all") + private fun respond( + @Suppress("SameParameterValue") code: Int, + body: CharSequence, + @Suppress("SameParameterValue") contentType: String, + out: OutputStream) { + respond(code, body.toString().toByteArray(Charset.forName("UTF8")), contentType, out) + } + + private fun respond(code: Int, out: OutputStream) { + respond(code, null as ByteArray?, null, out) + } + + private fun respond(code: Int, body: ByteArray?, contentType: String?, out: OutputStream) { + val header = StringBuilder() + header.append("HTTP/1.1 ").append(code).append(" OK\r\n") + if (body != null) { + appendHeader("Content-Length", "" + body.size, header) + } + if (contentType != null) { + appendHeader("Content-Type", contentType, header) + } + header.append("\r\n") + val bout = BufferedOutputStream(out) + bout.write(header.toString().toByteArray(StandardCharsets.US_ASCII)) + if (body != null) { + bout.write(body) + } + bout.flush() + } + + private fun handleRequest(s: Socket) { + val out = s.getOutputStream() + try { + val (uri, reqBody) = parseRequest(s.getInputStream()) + if ("favicon.ico" == uri.path) { + respond(HttpURLConnection.HTTP_NOT_FOUND, out) + return + } + val endpoint = endpoints[uri.path ?: ""] + if (endpoint == null) { + aapsLogger.error(LTag.GARMIN, "request path not found '" + uri.path + "'") + respond(HttpURLConnection.HTTP_NOT_FOUND, out) + } else { + try { + val body = endpoint(s.remoteSocketAddress, uri, reqBody) + respond(HttpURLConnection.HTTP_OK, body, "application/json", out) + } catch (e: Exception) { + aapsLogger.error(LTag.GARMIN, "endpoint " + uri.path + " failed", e) + respond(HttpURLConnection.HTTP_INTERNAL_ERROR, out) + } + } + } catch (e: SocketTimeoutException) { + // Client may just connect without sending anything. + aapsLogger.debug(LTag.GARMIN, "socket timeout: " + e.message) + return + } catch (e: IOException) { + aapsLogger.error(LTag.GARMIN, "Invalid request", e) + respond(HttpURLConnection.HTTP_BAD_REQUEST, out) + return + } + } + + private fun runServer() = try { + // Policy won't work in unit tests, so ignore NULL builder. + @Suppress("UNNECESSARY_SAFE_CALL") + val policy = StrictMode.ThreadPolicy.Builder()?.permitAll()?.build() + if (policy != null) StrictMode.setThreadPolicy(policy) + readyLock.withLock { + serverSocket = ServerSocket() + serverSocket!!.bind( + // Garmin will only connect to IP4 localhost. Therefore, we need to explicitly listen + // on that loopback interface and cannot use InetAddress.getLoopbackAddress(). That + // gives ::1 (IP6 localhost). + InetSocketAddress(Inet4Address.getByAddress(byteArrayOf(127, 0, 0, 1)), port)) + readyCond.signalAll() } + aapsLogger.info(LTag.GARMIN,"accept connections on " + serverSocket!!.localSocketAddress) + while (true) { + val socket = serverSocket!!.accept() + aapsLogger.info(LTag.GARMIN,"accept " + socket.remoteSocketAddress) + workerExecutor.execute { + Thread.currentThread().name = "worker" + Thread.currentThread().id + try { + socket.use { s -> + s.soTimeout = 10_000 + handleRequest(s) + } + } catch (e: Exception) { + aapsLogger.error(LTag.GARMIN, "response failed", e) + } + } + } + } catch (e: IOException) { + aapsLogger.error("Server crashed", e) + } finally { + try { + serverSocket?.close() + serverSocket = null + } catch (e: IOException) { + aapsLogger.error(LTag.GARMIN, "Socked close failed", e) + } + } + + companion object { + private val REQUEST_HEADER = Pattern.compile("(GET|POST) (\\S*) HTTP/1.1") + private val HEADER_LINE = Pattern.compile("([A-Za-z-]+)\\s*:\\s*(.*)") + + private fun readLine(input: InputStream, charset: Charset): String { + val buffer = ByteArrayOutputStream(input.available()) + loop@while (true) { + when (val c = input.read()) { + '\r'.code -> {} + -1 -> break@loop + '\n'.code -> break@loop + else -> buffer.write(c) + } + } + return String(buffer.toByteArray(), charset) + } + + @VisibleForTesting + internal fun readBody(input: InputStream, length: Int): String { + var remaining = length + val buffer = ByteArrayOutputStream(input.available()) + var c: Int = -1 + while (remaining-- > 0 && (input.read().also { c = it }) != -1) { + buffer.write(c) + } + return buffer.toString("UTF8") + } + + /** Parses a requests and returns the URI and the request body. */ + @VisibleForTesting + internal fun parseRequest(input: InputStream): Pair { + val headerLine = readLine(input, Charset.forName("ASCII")) + val p = REQUEST_HEADER.matcher(headerLine) + if (!p.matches()) { + throw IOException("invalid HTTP header '$headerLine'") + } + val post = ("POST" == p.group(1)) + var uri = URI(p.group(2)) + val headers: MutableMap = HashMap() + while (true) { + val line = readLine(input, Charset.forName("ASCII")) + if (line.isEmpty()) { + break + } + val m = HEADER_LINE.matcher(line) + if (!m.matches()) { + throw IOException("invalid header line '$line'") + } + headers[m.group(1)!!] = m.group(2) + } + var body: String? + if (post) { + var contentLength = Int.MAX_VALUE + if (headers.containsKey("Content-Length")) { + contentLength = headers["Content-Length"]!!.toInt() + } + val keepAlive = ("Keep-Alive" == headers["Connection"]) + val contentType = headers["Content-Type"] + if (keepAlive && contentLength == Int.MAX_VALUE) { + throw IOException("keep-alive without content-length for $uri") + } + body = readBody(input, contentLength) + if (("application/x-www-form-urlencoded" == contentType)) { + uri = URI(uri.scheme, uri.userInfo, uri.host, uri.port, uri.path, body, null) + // uri.encodedQuery(body) + body = null + } else if ("application/json" != contentType && body.isNotBlank()) { + body = null + } + } else { + body = null + } + return Pair(uri, body?.takeUnless(String::isBlank)) + } + + private fun appendHeader(name: String, value: String, header: StringBuilder) { + header.append(name) + header.append(": ") + header.append(value) + header.append("\r\n") + } + } +} diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/garmin/LoopHub.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/garmin/LoopHub.kt new file mode 100644 index 0000000000..6397049377 --- /dev/null +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/garmin/LoopHub.kt @@ -0,0 +1,41 @@ +package app.aaps.plugins.main.general.garmin + +import app.aaps.core.interfaces.db.GlucoseUnit +import app.aaps.core.interfaces.profile.Profile +import app.aaps.database.entities.GlucoseValue +import java.time.Instant + +/** Abstraction from all the functionality we need from the AAPS app. */ +interface LoopHub { + + /** Returns the active insulin profile. */ + val currentProfile: Profile? + + /** Returns the name of the active insulin profile. */ + val currentProfileName: String + + /** Returns the glucose unit (mg/dl or mmol/l) as selected by the user. */ + val glucoseUnit: GlucoseUnit + + /** Returns the remaining bolus insulin on board. */ + val insulinOnboard: Double + + /** Returns true if the pump is connected. */ + val isConnected: Boolean + + /** Returns true if the current profile is set of a limited amount of time. */ + val isTemporaryProfile: Boolean + + /** Returns the factor by which the basal rate is currently raised (> 1) or lowered (< 1). */ + val temporaryBasal: Double + + /** Retrieves the glucose values starting at from. */ + fun getGlucoseValues(from: Instant, ascending: Boolean): List + + /** Stores hear rate readings that a taken and averaged of the given interval. */ + fun storeHeartRate( + samplingStart: Instant, samplingEnd: Instant, + avgHeartRate: Int, + device: String? + ) +} \ No newline at end of file diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/garmin/LoopHubImpl.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/garmin/LoopHubImpl.kt new file mode 100644 index 0000000000..672b5a86c3 --- /dev/null +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/garmin/LoopHubImpl.kt @@ -0,0 +1,88 @@ +package app.aaps.plugins.main.general.garmin + +import androidx.annotation.VisibleForTesting +import app.aaps.core.interfaces.aps.Loop +import app.aaps.core.interfaces.db.GlucoseUnit +import app.aaps.core.interfaces.iob.IobCobCalculator +import app.aaps.core.interfaces.profile.Profile +import app.aaps.core.interfaces.profile.ProfileFunction +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.impl.AppRepository +import app.aaps.database.impl.transactions.InsertOrUpdateHeartRateTransaction +import java.time.Clock +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +/** + * Interface to the functionality of the looping algorithm and storage systems. + */ +class LoopHubImpl @Inject constructor( + private val iobCobCalculator: IobCobCalculator, + private val loop: Loop, + private val profileFunction: ProfileFunction, + private val repo: AppRepository, +) : LoopHub { + + @VisibleForTesting + var clock: Clock = Clock.systemUTC() + + /** Returns the active insulin profile. */ + override val currentProfile: Profile? get() = profileFunction.getProfile() + + /** Returns the name of the active insulin profile. */ + override val currentProfileName: String + get() = profileFunction.getProfileName() + + /** Returns the glucose unit (mg/dl or mmol/l) as selected by the user. */ + override val glucoseUnit: GlucoseUnit + get() = profileFunction.getProfile()?.units ?: GlucoseUnit.MGDL + + /** Returns the remaining bolus insulin on board. */ + override val insulinOnboard: Double + get() = iobCobCalculator.calculateIobFromBolus().iob + + /** Returns true if the pump is connected. */ + override val isConnected: Boolean get() = !loop.isDisconnected + + /** Returns true if the current profile is set of a limited amount of time. */ + override val isTemporaryProfile: Boolean + get() { + val resp = repo.getEffectiveProfileSwitchActiveAt(clock.millis()) + val ps: EffectiveProfileSwitch? = + (resp.blockingGet() as? ValueWrapper.Existing)?.value + return ps != null && ps.originalDuration > 0 + } + + /** Returns the factor by which the basal rate is currently raised (> 1) or lowered (< 1). */ + override val temporaryBasal: Double + get() { + val apsResult = loop.lastRun?.constraintsProcessed + return if (apsResult == null) Double.NaN else apsResult.percent / 100.0 + } + + /** Retrieves the glucose values starting at from. */ + override fun getGlucoseValues(from: Instant, ascending: Boolean): List { + return repo.compatGetBgReadingsDataFromTime(from.toEpochMilli(), ascending) + .blockingGet() + } + + /** Stores hear rate readings that a taken and averaged of the given interval. */ + override fun storeHeartRate( + samplingStart: Instant, samplingEnd: Instant, + avgHeartRate: Int, + device: String?) { + val hr = HeartRate( + timestamp = samplingStart.toEpochMilli(), + duration = samplingEnd.toEpochMilli() - samplingStart.toEpochMilli(), + dateCreated = clock.millis(), + beatsPerMinute = avgHeartRate.toDouble(), + device = device ?: "Garmin", + ) + repo.runTransaction(InsertOrUpdateHeartRateTransaction(hr)).blockingAwait() + } +} \ No newline at end of file diff --git a/plugins/main/src/main/res/values/strings.xml b/plugins/main/src/main/res/values/strings.xml index 9f1b4c6556..18af69163d 100644 --- a/plugins/main/src/main/res/values/strings.xml +++ b/plugins/main/src/main/res/values/strings.xml @@ -401,5 +401,8 @@ DEFAULT RANGE target Rate: %1$.2fU/h (%2$.2f%%) \nDuration %3$d min + Garmin + Connection to Garmin device (Fenix, Edge, …) + Garmin settings diff --git a/plugins/main/src/main/res/xml/pref_garmin.xml b/plugins/main/src/main/res/xml/pref_garmin.xml new file mode 100644 index 0000000000..1301693ec7 --- /dev/null +++ b/plugins/main/src/main/res/xml/pref_garmin.xml @@ -0,0 +1,23 @@ + + + + + + + + + + diff --git a/plugins/main/src/test/kotlin/app/aaps/plugins/main/general/garmin/DeltaVarEncodedListTest.kt b/plugins/main/src/test/kotlin/app/aaps/plugins/main/general/garmin/DeltaVarEncodedListTest.kt new file mode 100644 index 0000000000..56e410c943 --- /dev/null +++ b/plugins/main/src/test/kotlin/app/aaps/plugins/main/general/garmin/DeltaVarEncodedListTest.kt @@ -0,0 +1,192 @@ +package app.aaps.plugins.main.general.garmin + + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder + +internal class DeltaVarEncodedListTest { + + @Test fun empty() { + val l = DeltaVarEncodedList(100, 2) + assertArrayEquals(IntArray(0), l.toArray()) + } + + @Test fun add1() { + val l = DeltaVarEncodedList(100, 2) + l.add(10, 12) + assertArrayEquals(intArrayOf(10, 12), l.toArray()) + } + + @Test fun add2() { + val l = DeltaVarEncodedList(100, 2) + l.add(10, 16) + l.add(17, 9) + assertArrayEquals(intArrayOf(10, 16, 17, 9), l.toArray()) + } + + @Test fun add3() { + val l = DeltaVarEncodedList(100, 2) + l.add(10, 16) + l.add(17, 9) + l.add(-4, 5) + assertArrayEquals(intArrayOf(10, 16, 17, 9, -4, 5), l.toArray()) + } + + @Test fun decode() { + val bytes = ByteBuffer.allocate(6) + bytes.order(ByteOrder.LITTLE_ENDIAN) + bytes.putChar(65044.toChar()) + bytes.putChar(33026.toChar()) + bytes.putChar(4355.toChar()) + val l = DeltaVarEncodedList(intArrayOf(-1), bytes) + assertEquals(4, l.size.toLong()) + assertArrayEquals(intArrayOf(10, 201, 8, -1), l.toArray()) + } + + @Test fun decodeUneven() { + val bytes = ByteBuffer.allocate(8) + bytes.order(ByteOrder.LITTLE_ENDIAN) + bytes.putChar(65044.toChar()) + bytes.putChar(33026.toChar()) + bytes.putChar(59395.toChar()) + bytes.putChar(10.toChar()) + val l = DeltaVarEncodedList(intArrayOf(700), ByteBuffer.wrap(bytes.array(), 0, 7)) + assertEquals(4, l.size.toLong()) + assertArrayEquals(intArrayOf(10, 201, 8, 700), l.toArray()) + } + + @Test fun decodeInt() { + val bytes = ByteBuffer.allocate(8) + bytes.order(ByteOrder.LITTLE_ENDIAN) + bytes.putInt(-2130510316).putInt(714755) + val l = DeltaVarEncodedList(intArrayOf(700), ByteBuffer.wrap(bytes.array(), 0, 7)) + assertEquals(4, l.size.toLong()) + assertArrayEquals(intArrayOf(10, 201, 8, 700), l.toArray()) + } + + @Test fun decodeInt1() { + val bytes = ByteBuffer.allocate(3 * 4) + bytes.order(ByteOrder.LITTLE_ENDIAN) + bytes.putInt(-2019904035).putInt(335708683).putInt(529409) + val l = DeltaVarEncodedList(intArrayOf(1483884930, 132), ByteBuffer.wrap(bytes.array(), 0, 11)) + assertEquals(3, l.size.toLong()) + assertArrayEquals(intArrayOf(1483884910, 129, 1483884920, 128, 1483884930, 132), l.toArray()) + } + + @Test fun decodeInt2() { + val bytes = ByteBuffer.allocate(100) + bytes.order(ByteOrder.LITTLE_ENDIAN) + bytes + .putInt(-1761405951) + .putInt(335977999) + .putInt(335746050) + .putInt(336008197) + .putInt(335680514) + .putInt(335746053) + .putInt(-1761405949) + val l = DeltaVarEncodedList(intArrayOf(1483880370, 127), ByteBuffer.wrap(bytes.array(), 0, 28)) + assertEquals(12, l.size.toLong()) + assertArrayEquals( + intArrayOf( + 1483879986, + 999, + 1483879984, + 27, + 1483880383, + 37, + 1483880384, + 47, + 1483880382, + 57, + 1483880379, + 67, + 1483880375, + 77, + 1483880376, + 87, + 1483880377, + 97, + 1483880374, + 107, + 1483880372, + 117, + 1483880370, + 127 + ), + l.toArray() + ) + } + + @Test fun decodeInt3() { + val bytes = ByteBuffer.allocate(2 * 4) + bytes.order(ByteOrder.LITTLE_ENDIAN) + bytes.putInt(-2020427796).putInt(166411) + val l = DeltaVarEncodedList(intArrayOf(1483886070, 133), ByteBuffer.wrap(bytes.array(), 0, 7)) + assertEquals(1, l.size.toLong()) + assertArrayEquals(intArrayOf(1483886070, 133), l.toArray()) + } + + @Test fun decodePairs() { + val bytes = ByteBuffer.allocate(10) + bytes.order(ByteOrder.LITTLE_ENDIAN) + bytes.putChar(51220.toChar()) + bytes.putChar(65025.toChar()) + bytes.putChar(514.toChar()) + bytes.putChar(897.toChar()) + bytes.putChar(437.toChar()) + val l = DeltaVarEncodedList(intArrayOf(8, 10), bytes) + assertEquals(3, l.size.toLong()) + assertArrayEquals(intArrayOf(10, 100, 201, 101, 8, 10), l.toArray()) + } + + @Test fun encoding() { + val l = DeltaVarEncodedList(100, 2) + l.add(10, 16) + l.add(17, 9) + l.add(-4, 5) + val dataList = l.encodedData() + val byteBuffer = ByteBuffer.allocate(dataList.size * 8) + byteBuffer.order(ByteOrder.LITTLE_ENDIAN) + val longBuffer = byteBuffer.asLongBuffer() + for (i in dataList.indices) { + longBuffer.put(dataList[i]) + } + byteBuffer.rewind() + byteBuffer.limit(l.byteSize) + val l2 = DeltaVarEncodedList(intArrayOf(-4, 5), byteBuffer) + assertArrayEquals(intArrayOf(10, 16, 17, 9, -4, 5), l2.toArray()) + } + + @Test fun encoding2() { + val l = DeltaVarEncodedList(100, 2) + val values = intArrayOf( + 1511636926, 137, 1511637226, 138, 1511637526, 138, 1511637826, 137, 1511638126, 136, + 1511638426, 135, 1511638726, 134, 1511639026, 132, 1511639326, 130, 1511639626, 128, + 1511639926, 126, 1511640226, 124, 1511640526, 121, 1511640826, 118, 1511641127, 117, + 1511641427, 116, 1511641726, 115, 1511642027, 113, 1511642326, 111, 1511642627, 109, + 1511642927, 107, 1511643227, 107, 1511643527, 107, 1511643827, 106, 1511644127, 105, + 1511644427, 104, 1511644727, 104, 1511645027, 104, 1511645327, 104, 1511645626, 104, + 1511645926, 104, 1511646226, 105, 1511646526, 106, 1511646826, 107, 1511647126, 109, + 1511647426, 108 + ) + + for(i in values.indices step 2) { + l.add(values[i], values[i + 1]) + } + assertArrayEquals(values, l.toArray()) + val dataList = l.encodedData() + val byteBuffer = ByteBuffer.allocate(dataList.size * 8) + byteBuffer.order(ByteOrder.LITTLE_ENDIAN) + val longBuffer = byteBuffer.asLongBuffer() + for (i in dataList.indices) { + longBuffer.put(dataList[i]) + } + byteBuffer.rewind() + byteBuffer.limit(l.byteSize) + val l2 = DeltaVarEncodedList(intArrayOf(1511647426, 108), byteBuffer) + assertArrayEquals(values, l2.toArray()) + } +} diff --git a/plugins/main/src/test/kotlin/app/aaps/plugins/main/general/garmin/GarminPluginTest.kt b/plugins/main/src/test/kotlin/app/aaps/plugins/main/general/garmin/GarminPluginTest.kt new file mode 100644 index 0000000000..31aae82f97 --- /dev/null +++ b/plugins/main/src/test/kotlin/app/aaps/plugins/main/general/garmin/GarminPluginTest.kt @@ -0,0 +1,116 @@ +package app.aaps.plugins.main.general.garmin + +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.rx.events.EventNewBG +import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.database.entities.GlucoseValue +import app.aaps.shared.tests.TestBase +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.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mock +import org.mockito.Mockito.atMost +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoMoreInteractions +import org.mockito.Mockito.`when` +import java.net.URI +import java.time.Clock +import java.time.Instant +import java.time.ZoneId +import java.time.temporal.ChronoUnit +import java.util.concurrent.locks.Condition + +class GarminPluginTest: TestBase() { + private lateinit var gp: GarminPlugin + + @Mock private lateinit var rh: ResourceHelper + @Mock private lateinit var sp: SP + @Mock private lateinit var loopHub: LoopHub + private val clock = Clock.fixed(Instant.ofEpochMilli(10_000), ZoneId.of("UTC")) + + private var injector: HasAndroidInjector = HasAndroidInjector { + AndroidInjector { + } + } + + @BeforeEach + fun setup() { + gp = GarminPlugin(injector, aapsLogger, rh, loopHub, rxBus, sp) + gp.clock = clock + `when`(loopHub.currentProfileName).thenReturn("Default") + } + + @AfterEach + fun verifyNoFurtherInteractions() { + verify(loopHub, atMost(2)).currentProfileName + verifyNoMoreInteractions(loopHub) + } + + private val getGlucoseValuesFrom = clock.instant() + .minus(2, ChronoUnit.HOURS) + .minus(9, ChronoUnit.MINUTES) + + private fun createUri(params: Map): URI { + return URI("http://foo?" + params.entries.joinToString(separator = "&") { (k, v) -> + "$k=$v"}) + } + + private fun createHeartRate(@Suppress("SameParameterValue") heartRate: Int) = mapOf( + "hr" to heartRate, + "hrStart" to 1001L, + "hrEnd" to 2001L, + "device" to "Test_Device") + + private fun createGlucoseValue(timestamp: Instant, value: Double = 93.0) = GlucoseValue( + timestamp = timestamp.toEpochMilli(), raw = 90.0, value = value, + trendArrow = GlucoseValue.TrendArrow.FLAT, noise = null, + sourceSensor = GlucoseValue.SourceSensor.RANDOM + ) + + @Test + fun testReceiveHeartRateUri() { + val hr = createHeartRate(99) + val uri = createUri(hr) + gp.receiveHeartRate(uri) + verify(loopHub).storeHeartRate( + Instant.ofEpochSecond(hr["hrStart"] as Long), + Instant.ofEpochSecond(hr["hrEnd"] as Long), + 99, + hr["device"] as String) + } + + @Test + fun testReceiveHeartRate_UriTestIsTrue() { + val params = createHeartRate(99).toMutableMap() + params["test"] = true + val uri = createUri(params) + gp.receiveHeartRate(uri) + } + + @Test + fun testGetGlucoseValues_NoLast() { + val from = getGlucoseValuesFrom + val prev = createGlucoseValue(clock.instant().minusSeconds(310)) + `when`(loopHub.getGlucoseValues(from, true)).thenReturn(listOf(prev)) + assertArrayEquals(arrayOf(prev), gp.getGlucoseValues().toTypedArray()) + verify(loopHub).getGlucoseValues(from, true) + } + + @Test + fun testGetGlucoseValues_NoNewLast() { + val from = getGlucoseValuesFrom + val lastTimesteamp = clock.instant() + val prev = createGlucoseValue(clock.instant()) + gp.newValue = mock(Condition::class.java) + `when`(loopHub.getGlucoseValues(from, true)).thenReturn(listOf(prev)) + gp.onNewBloodGlucose(EventNewBG(lastTimesteamp.toEpochMilli())) + assertArrayEquals(arrayOf(prev), gp.getGlucoseValues().toTypedArray()) + + verify(gp.newValue).signalAll() + verify(loopHub).getGlucoseValues(from, true) + } +} diff --git a/plugins/main/src/test/kotlin/app/aaps/plugins/main/general/garmin/HttpServerTest.kt b/plugins/main/src/test/kotlin/app/aaps/plugins/main/general/garmin/HttpServerTest.kt new file mode 100644 index 0000000000..8219e476cb --- /dev/null +++ b/plugins/main/src/test/kotlin/app/aaps/plugins/main/general/garmin/HttpServerTest.kt @@ -0,0 +1,99 @@ +package app.aaps.plugins.main.general.garmin + +import app.aaps.shared.tests.TestBase +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.ByteArrayInputStream +import java.io.InputStream +import java.net.HttpURLConnection +import java.net.SocketAddress +import java.net.URI +import java.nio.charset.Charset +import java.time.Duration + +internal class HttpServerTest: TestBase() { + + private fun toInputStream(s: String): InputStream { + return ByteArrayInputStream(s.toByteArray(Charset.forName("ASCII"))) + } + + @Test fun testReadBody() { + val input = toInputStream("Test") + assertEquals("Test", HttpServer.readBody(input, 100)) + } + + @Test fun testReadBody_MoreContentThanLength() { + val input = toInputStream("Test") + assertEquals("Tes", HttpServer.readBody(input, 3)) + } + + @Test fun testParseRequest_Get() { + val req = """ + GET http://foo HTTP/1.1 + """.trimIndent() + assertEquals( + URI("http://foo") to null, + HttpServer.parseRequest(toInputStream(req))) + } + + @Test fun testParseRequest_PostEmptyBody() { + val req = """ + POST http://foo HTTP/1.1 + """.trimIndent() + assertEquals( + URI("http://foo") to null, + HttpServer.parseRequest(toInputStream(req))) + } + + @Test fun testParseRequest_PostBody() { + val req = """ + POST http://foo HTTP/1.1 + Content-Type: application/x-www-form-urlencoded + + a=1&b=2 + """.trimIndent() + assertEquals( + URI("http://foo?a=1&b=2") to null, + HttpServer.parseRequest(toInputStream(req))) + } + + @Test fun testParseRequest_PostBodyContentLength() { + val req = """ + POST http://foo HTTP/1.1 + Content-Type: application/x-www-form-urlencoded + Content-Length: 3 + + a=1&b=2 + """.trimIndent() + assertEquals( + URI("http://foo?a=1") to null, + HttpServer.parseRequest(toInputStream(req))) + } + + @Test fun testRequest() { + val port = 28895 + val reqUri = URI("http://127.0.0.1:$port/foo") + HttpServer(aapsLogger, port).use { server -> + server.registerEndpoint("/foo") { _: SocketAddress, uri: URI, _: String? -> + assertEquals(URI("/foo"), uri) + "test" + } + assertTrue(server.awaitReady(Duration.ofSeconds(10))) + val resp = reqUri.toURL().openConnection() as HttpURLConnection + assertEquals(200, resp.responseCode) + val content = (resp.content as InputStream).reader().use { r -> r.readText() } + assertEquals("test", content) + } + } + + @Test fun testRequest_NotFound() { + val port = 28895 + val reqUri = URI("http://127.0.0.1:$port/foo") + HttpServer(aapsLogger, port).use { server -> + assertTrue(server.awaitReady(Duration.ofSeconds(10))) + val resp = reqUri.toURL().openConnection() as HttpURLConnection + assertEquals(404, resp.responseCode) + } + } +} diff --git a/plugins/main/src/test/kotlin/app/aaps/plugins/main/general/garmin/LoopHubTest.kt b/plugins/main/src/test/kotlin/app/aaps/plugins/main/general/garmin/LoopHubTest.kt new file mode 100644 index 0000000000..a812f0cbef --- /dev/null +++ b/plugins/main/src/test/kotlin/app/aaps/plugins/main/general/garmin/LoopHubTest.kt @@ -0,0 +1,201 @@ +package app.aaps.plugins.main.general.garmin + + +import app.aaps.core.interfaces.aps.APSResult +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.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.queue.CommandQueue +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.embedments.InsulinConfiguration +import app.aaps.database.impl.AppRepository +import app.aaps.database.impl.transactions.InsertOrUpdateHeartRateTransaction +import app.aaps.shared.tests.TestBase +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Single +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.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mock +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.time.Clock +import java.time.Instant +import java.time.ZoneId + +class LoopHubTest: TestBase() { + @Mock lateinit var commandQueue: CommandQueue + @Mock lateinit var constraints: ConstraintsChecker + @Mock lateinit var iobCobCalculator: IobCobCalculator + @Mock lateinit var loop: Loop + @Mock lateinit var profileFunction: ProfileFunction + @Mock lateinit var repo: AppRepository + @Mock lateinit var userEntryLogger: UserEntryLogger + + 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.clock = clock + } + + @AfterEach + fun verifyNoFurtherInteractions() { + verifyNoMoreInteractions(commandQueue) + verifyNoMoreInteractions(constraints) + verifyNoMoreInteractions(iobCobCalculator) + verifyNoMoreInteractions(loop) + verifyNoMoreInteractions(profileFunction) + verifyNoMoreInteractions(repo) + verifyNoMoreInteractions(userEntryLogger) + } + + @Test + fun testCurrentProfile() { + val profile = mock(Profile::class.java) + `when`(profileFunction.getProfile()).thenReturn(profile) + assertEquals(profile, loopHub.currentProfile) + verify(profileFunction, times(1)).getProfile() + } + + @Test + fun testCurrentProfileName() { + `when`(profileFunction.getProfileName()).thenReturn("pro") + assertEquals("pro", loopHub.currentProfileName) + verify(profileFunction, times(1)).getProfileName() + } + + @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) + assertEquals(GlucoseUnit.MGDL, loopHub.glucoseUnit) + verify(profileFunction, times(1)).getProfile() + } + + @Test + fun testInsulinOnBoard() { + val iobTotal = IobTotal(time = 0).apply { iob = 23.9 } + `when`(iobCobCalculator.calculateIobFromBolus()).thenReturn(iobTotal) + assertEquals(23.9, loopHub.insulinOnboard, 1e-10) + verify(iobCobCalculator, times(1)).calculateIobFromBolus() + } + + @Test + fun testIsConnected() { + `when`(loop.isDisconnected).thenReturn(false) + assertEquals(true, loopHub.isConnected) + verify(loop, times(1)).isDisconnected + } + + private fun effectiveProfileSwitch(duration: Long) = EffectiveProfileSwitch( + timestamp = 100, + basalBlocks = emptyList(), + isfBlocks = emptyList(), + icBlocks = emptyList(), + targetBlocks = emptyList(), + glucoseUnit = EffectiveProfileSwitch.GlucoseUnit.MGDL, + originalProfileName = "foo", + originalCustomizedName = "bar", + originalTimeshift = 0, + originalPercentage = 100, + originalDuration = duration, + originalEnd = 100 + duration, + insulinConfiguration = InsulinConfiguration( + "label", 0, 0 + ) + ) + + @Test + fun testIsTemporaryProfileTrue() { + val eps = effectiveProfileSwitch(10) + `when`(repo.getEffectiveProfileSwitchActiveAt(clock.millis())).thenReturn( + Single.just(ValueWrapper.Existing(eps))) + assertEquals(true, loopHub.isTemporaryProfile) + verify(repo, times(1)).getEffectiveProfileSwitchActiveAt(clock.millis()) + } + + @Test + fun testIsTemporaryProfileFalse() { + val eps = effectiveProfileSwitch(0) + `when`(repo.getEffectiveProfileSwitchActiveAt(clock.millis())).thenReturn( + Single.just(ValueWrapper.Existing(eps))) + assertEquals(false, loopHub.isTemporaryProfile) + verify(repo).getEffectiveProfileSwitchActiveAt(clock.millis()) + } + + @Test + fun testTemporaryBasal() { + val apsResult = mock(APSResult::class.java) + `when`(apsResult.percent).thenReturn(45) + val lastRun = Loop.LastRun().apply { constraintsProcessed = apsResult } + `when`(loop.lastRun).thenReturn(lastRun) + assertEquals(0.45, loopHub.temporaryBasal, 1e-6) + verify(loop).lastRun + } + + @Test + fun testTemporaryBasalNoRun() { + `when`(loop.lastRun).thenReturn(null) + assertTrue(loopHub.temporaryBasal.isNaN()) + verify(loop, times(1)).lastRun + } + + @Test + fun testGetGlucoseValues() { + val glucoseValues = listOf( + GlucoseValue( + timestamp = 1_000_000L, raw = 90.0, value = 93.0, + trendArrow = GlucoseValue.TrendArrow.FLAT, noise = null, + sourceSensor = GlucoseValue.SourceSensor.DEXCOM_G5_XDRIP)) + `when`(repo.compatGetBgReadingsDataFromTime(1001_000, false)) + .thenReturn(Single.just(glucoseValues)) + assertArrayEquals( + glucoseValues.toTypedArray(), + loopHub.getGlucoseValues(Instant.ofEpochMilli(1001_000), false).toTypedArray()) + verify(repo).compatGetBgReadingsDataFromTime(1001_000, false) + } + + @Test + fun testStoreHeartRate() { + val samplingStart = Instant.ofEpochMilli(1_001_000) + val samplingEnd = Instant.ofEpochMilli(1_101_000) + val hr = HeartRate( + timestamp = samplingStart.toEpochMilli(), + duration = samplingEnd.toEpochMilli() - samplingStart.toEpochMilli(), + dateCreated = clock.millis(), + beatsPerMinute = 101.0, + device = "Test Device") + `when`(repo.runTransaction(InsertOrUpdateHeartRateTransaction(hr))).thenReturn( + Completable.fromCallable { + InsertOrUpdateHeartRateTransaction.TransactionResult( + emptyList(), emptyList())}) + loopHub.storeHeartRate( + samplingStart, samplingEnd, 101, "Test Device") + verify(repo).runTransaction(InsertOrUpdateHeartRateTransaction(hr)) + } +} \ No newline at end of file