Local http server communication to integrate Garmin devices.

This commit is contained in:
Robert Buessow 2023-10-12 21:34:18 +02:00
parent bb133cdbce
commit 5a17a05ee0
19 changed files with 1490 additions and 1 deletions

View file

@ -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()

View file

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

View file

@ -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"),

View file

@ -187,7 +187,9 @@ data class UserEntry(
Overview, //From OverViewPlugin
Stats, //From Stat Activity
Aaps, // MainApp
GarminDevice,
Unknown //if necessary
,
;
companion object {

View file

@ -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<HeartRate>, val updated: List<HeartRate>)
}

View file

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

View file

@ -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,
]
)

View file

@ -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<Long> {
val byteBuffer: ByteBuffer = ByteBuffer.wrap(data)
byteBuffer.order(ByteOrder.LITTLE_ENDIAN)
byteBuffer.limit(toLongBoundary(end))
val buffer: LongBuffer = byteBuffer.asLongBuffer()
val encodedData: MutableList<Long> = 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)
}
}
}

View file

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

View file

@ -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<GlucoseValue> {
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<GlucoseValue> {
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<GlucoseValue>): 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")
}
}
}

View file

@ -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: MutableMap<String, (SocketAddress, URI, String?)->CharSequence> =
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<URI, String?> {
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<String, String?> = 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")
}
}
}

View file

@ -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<GlucoseValue>
/** Stores hear rate readings that a taken and averaged of the given interval. */
fun storeHeartRate(
samplingStart: Instant, samplingEnd: Instant,
avgHeartRate: Int,
device: String?
)
}

View file

@ -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<EffectiveProfileSwitch>)?.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<GlucoseValue> {
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()
}
}

View file

@ -401,5 +401,8 @@
<string name="default_range">DEFAULT RANGE</string>
<string name="target">target</string>
<string name="rate_duration">Rate: %1$.2fU/h (%2$.2f%%) \nDuration %3$d min</string>
<string name="garmin">Garmin</string>
<string name="garmin_description">Connection to Garmin device (Fenix, Edge, …)</string>
<string name="key_garmin_settings">Garmin settings</string>
</resources>

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceCategory
android:key="@string/key_garmin_settings"
app:initialExpandedChildrenCount="0"
android:title="@string/garmin">
<CheckBoxPreference
android:defaultValue="true"
android:key="communication_http"
android:title="Local HTTP server" />
<EditTextPreference
android:defaultValue="28891"
android:digits="0123456789"
android:inputType="numberDecimal"
android:key="communication_http_port"
android:title="Local HTTP server port" />
</PreferenceCategory>
</PreferenceScreen>

View file

@ -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())
}
}

View file

@ -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<String, Any>): URI {
return URI("http://foo?" + params.entries.joinToString(separator = "&") { (k, v) ->
"$k=$v"})
}
private fun createHeartRate(@Suppress("SameParameterValue") heartRate: Int) = mapOf<String, Any>(
"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)
}
}

View file

@ -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)
}
}
}

View file

@ -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))
}
}