Merge pull request #3059 from buessow/dev-garmin3

Support automatic exchange of communication key for HTTP via connecti…
This commit is contained in:
Milos Kozak 2023-12-05 21:34:37 +01:00 committed by GitHub
commit 9642f3ee26
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 1821 additions and 44 deletions

View file

@ -152,8 +152,11 @@ object Libs {
} }
object Mockito { object Mockito {
private const val mockitoVersion = "5.6.0"
const val jupiter = "org.mockito:mockito-junit-jupiter:5.6.0" const val android = "org.mockito:mockito-android:$mockitoVersion"
const val core = "org.mockito:mockito-core:$mockitoVersion"
const val jupiter = "org.mockito:mockito-junit-jupiter:$mockitoVersion"
const val kotlin = "org.mockito.kotlin:mockito-kotlin:5.1.0" const val kotlin = "org.mockito.kotlin:mockito-kotlin:5.1.0"
} }

View file

@ -22,6 +22,9 @@ dependencies {
androidTestImplementation(Libs.AndroidX.Test.rules) androidTestImplementation(Libs.AndroidX.Test.rules)
androidTestImplementation(Libs.Google.truth) androidTestImplementation(Libs.Google.truth)
androidTestImplementation(Libs.AndroidX.Test.uiAutomator) androidTestImplementation(Libs.AndroidX.Test.uiAutomator)
androidTestImplementation(Libs.Mockito.core)
androidTestImplementation(Libs.Mockito.android)
androidTestImplementation(Libs.Mockito.kotlin)
} }
tasks.withType<Test> { tasks.withType<Test> {

View file

@ -30,6 +30,7 @@ dependencies {
testImplementation(project(":shared:tests")) testImplementation(project(":shared:tests"))
testImplementation(project(":implementation")) testImplementation(project(":implementation"))
testImplementation(project(":plugins:aps")) testImplementation(project(":plugins:aps"))
androidTestImplementation(project(":shared:tests"))
// OpenHuman // OpenHuman
api(Libs.Squareup.Okhttp3.okhttp) api(Libs.Squareup.Okhttp3.okhttp)
@ -49,6 +50,10 @@ dependencies {
// DataLayerListenerService // DataLayerListenerService
api(Libs.Google.Android.PlayServices.wearable) api(Libs.Google.Android.PlayServices.wearable)
// Garmin
api("com.garmin.connectiq:ciq-companion-app-sdk:2.0.2@aar")
androidTestImplementation("com.garmin.connectiq:ciq-companion-app-sdk:2.0.2@aar")
kapt(Libs.Dagger.compiler) kapt(Libs.Dagger.compiler)
kapt(Libs.Dagger.androidProcessor) kapt(Libs.Dagger.androidProcessor)
} }

View file

@ -0,0 +1,301 @@
package app.aaps.plugins.sync.garmin
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.os.Binder
import android.os.IBinder
import androidx.test.ext.junit.runners.AndroidJUnit4
import app.aaps.shared.tests.TestBase
import com.garmin.android.apps.connectmobile.connectiq.IConnectIQService
import com.garmin.android.connectiq.ConnectIQ
import com.garmin.android.connectiq.IQApp
import com.garmin.android.connectiq.IQDevice
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.any
import org.mockito.Mockito.timeout
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.kotlin.argThat
import org.mockito.kotlin.atLeastOnce
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.verifyNoMoreInteractions
import org.mockito.kotlin.whenever
import java.util.concurrent.Executor
@RunWith(AndroidJUnit4::class)
class GarminDeviceClientTest: TestBase() {
private val serviceDescriptor = "com.garmin.android.apps.connectmobile.connectiq.IConnectIQService"
private lateinit var client: GarminDeviceClient
private lateinit var serviceConnection: ServiceConnection
private lateinit var device: GarminDevice
private val packageName = "TestPackage"
private val actions = mutableMapOf<String, BroadcastReceiver>()
// Maps app ids to intent actions.
private val receivers = mutableMapOf<String, String>()
private val receiver = mock<GarminReceiver>()
private val binder = mock<IBinder>() {
on { isBinderAlive } doReturn true
}
private val ciqService = mock<IConnectIQService>() {
on { asBinder() } doReturn binder
on { connectedDevices } doReturn listOf(IQDevice(1L, "TDevice"))
on { registerApp(any(), any(), any()) }.doAnswer { i ->
receivers[i.getArgument<IQApp>(0).applicationId] = i.getArgument(1)
}
}
private val context = mock<Context>() {
on { packageName } doReturn this@GarminDeviceClientTest.packageName
on { registerReceiver(any<BroadcastReceiver>(), any()) } doAnswer { i ->
actions[i.getArgument<IntentFilter>(1).getAction(0)] = i.getArgument(0)
Intent()
}
on { unregisterReceiver(any()) } doAnswer { i ->
val keys = actions.entries.filter {(_, br) -> br == i.getArgument(0) }.map { (k, _) -> k }
keys.forEach { k -> actions.remove(k) }
}
on { bindService(any(), eq(Context.BIND_AUTO_CREATE), any(), any()) }. doAnswer { i ->
serviceConnection = i.getArgument(3)
i.getArgument<Executor>(2).execute {
serviceConnection.onServiceConnected(
GarminDeviceClient.CONNECTIQ_SERVICE_COMPONENT,
Binder().apply { attachInterface(ciqService, serviceDescriptor) })
}
true
}
on { bindService(any(), any(), eq(Context.BIND_AUTO_CREATE)) }. doAnswer { i ->
serviceConnection = i.getArgument(1)
serviceConnection.onServiceConnected(
GarminDeviceClient.CONNECTIQ_SERVICE_COMPONENT,
Binder().apply { attachInterface(ciqService, serviceDescriptor) })
true
}
}
@Before
fun setup() {
client = GarminDeviceClient(aapsLogger, context, receiver, retryWaitFactor = 0L)
device = GarminDevice(client, 1L, "TDevice")
verify(receiver, timeout(2_000L)).onConnect(client)
}
@After
fun shutdown() {
if (::client.isInitialized) client.dispose()
assertEquals(0, actions.size) // make sure all broadcastReceivers were unregistered
verify(context).unbindService(serviceConnection)
}
@Test
fun connect() {
}
@Test
fun disconnect() {
serviceConnection.onServiceDisconnected(GarminDeviceClient.CONNECTIQ_SERVICE_COMPONENT)
verify(receiver).onDisconnect(client)
assertEquals(0, actions.size)
}
@Test
fun connectedDevices() {
assertEquals(listOf(device), client.connectedDevices)
verify(ciqService).connectedDevices
}
@Test
fun reconnectDeadBinder() {
whenever(binder.isBinderAlive).thenReturn(false, true)
assertEquals(listOf(device), client.connectedDevices)
verify(ciqService).connectedDevices
verify(ciqService, times(2)).asBinder()
verify(context, times(2))
.bindService(any(), eq(Context.BIND_AUTO_CREATE), any(), any())
verifyNoMoreInteractions(ciqService)
verifyNoMoreInteractions(receiver)
}
@Test
fun sendMessage() {
val appId = "APPID1"
val data = "Hello, World!".toByteArray()
client.sendMessage(GarminApplication(device, appId, "$appId-name"), data)
verify(ciqService).sendMessage(
argThat { iqMsg -> data.contentEquals(iqMsg.messageData)
&& iqMsg.notificationPackage == packageName
&& iqMsg.notificationAction == client.sendMessageAction },
argThat { iqDevice -> iqDevice.deviceIdentifier == device.id },
argThat { iqApp -> iqApp?.applicationId == appId })
val intent = Intent().apply {
putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.SUCCESS.ordinal)
putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, device.toIQDevice())
putExtra(GarminDeviceClient.EXTRA_APPLICATION_ID, appId)
}
actions[client.sendMessageAction]!!.onReceive(context, intent)
actions[client.sendMessageAction]!!.onReceive(context, intent) // extra on receive will be ignored
verify(receiver).onSendMessage(client, device.id, appId, null)
}
@Test
fun sendMessage_failNoRetry() {
val appId = "APPID1"
val data = "Hello, World!".toByteArray()
client.sendMessage(GarminApplication(device, appId, "$appId-name"), data)
verify(ciqService).sendMessage(
argThat { iqMsg -> data.contentEquals(iqMsg.messageData)
&& iqMsg.notificationPackage == packageName
&& iqMsg.notificationAction == client.sendMessageAction },
argThat {iqDevice -> iqDevice.deviceIdentifier == device.id },
argThat { iqApp -> iqApp?.applicationId == appId })
val intent = Intent().apply {
putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.FAILURE_MESSAGE_TOO_LARGE.ordinal)
putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, device.toIQDevice())
putExtra(GarminDeviceClient.EXTRA_APPLICATION_ID, appId)
}
actions[client.sendMessageAction]!!.onReceive(context, intent)
verify(receiver).onSendMessage(client, device.id, appId, "error FAILURE_MESSAGE_TOO_LARGE")
}
@Test
fun sendMessage_failRetry() {
val appId = "APPID1"
val data = "Hello, World!".toByteArray()
client.sendMessage(GarminApplication(device, appId, "$appId-name"), data)
verify(ciqService).sendMessage(
argThat { iqMsg -> data.contentEquals(iqMsg.messageData)
&& iqMsg.notificationPackage == packageName
&& iqMsg.notificationAction == client.sendMessageAction },
argThat {iqDevice -> iqDevice.deviceIdentifier == device.id },
argThat { iqApp -> iqApp?.applicationId == appId })
val intent = Intent().apply {
putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.FAILURE_DURING_TRANSFER.ordinal)
putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, device.toIQDevice())
putExtra(GarminDeviceClient.EXTRA_APPLICATION_ID, appId)
}
actions[client.sendMessageAction]!!.onReceive(context, intent)
verifyNoMoreInteractions(receiver)
// Verify retry ...
verify(ciqService, timeout(10_000L).times( 2)).sendMessage(
argThat { iqMsg -> data.contentEquals(iqMsg.messageData)
&& iqMsg.notificationPackage == packageName
&& iqMsg.notificationAction == client.sendMessageAction },
argThat {iqDevice -> iqDevice.deviceIdentifier == device.id },
argThat { iqApp -> iqApp?.applicationId == appId })
intent.putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.SUCCESS.ordinal)
actions[client.sendMessageAction]!!.onReceive(context, intent)
verify(receiver).onSendMessage(client, device.id, appId, null)
}
@Test
fun sendMessage_2toSameApp() {
val appId = "APPID1"
val data1 = "m1".toByteArray()
val data2 = "m2".toByteArray()
client.sendMessage(GarminApplication(device, appId, "$appId-name"), data1)
client.sendMessage(GarminApplication(device, appId, "$appId-name"), data2)
verify(ciqService).sendMessage(
argThat { iqMsg -> data1.contentEquals(iqMsg.messageData)
&& iqMsg.notificationPackage == packageName
&& iqMsg.notificationAction == client.sendMessageAction },
argThat {iqDevice -> iqDevice.deviceIdentifier == device.id },
argThat { iqApp -> iqApp?.applicationId == appId })
verify(ciqService, atLeastOnce()).asBinder()
verifyNoMoreInteractions(ciqService)
val intent = Intent().apply {
putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.SUCCESS.ordinal)
putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, device.toIQDevice())
putExtra(GarminDeviceClient.EXTRA_APPLICATION_ID, appId)
}
actions[client.sendMessageAction]!!.onReceive(context, intent)
verify(receiver).onSendMessage(client, device.id, appId, null)
verify(ciqService, timeout(5000L)).sendMessage(
argThat { iqMsg -> data2.contentEquals(iqMsg.messageData)
&& iqMsg.notificationPackage == packageName
&& iqMsg.notificationAction == client.sendMessageAction },
argThat {iqDevice -> iqDevice.deviceIdentifier == device.id },
argThat { iqApp -> iqApp?.applicationId == appId })
actions[client.sendMessageAction]!!.onReceive(context, intent)
verify(receiver, times(2)).onSendMessage(client, device.id, appId, null)
}
@Test
fun sendMessage_2to2Apps() {
val appId1 = "APPID1"
val appId2 = "APPID2"
val data1 = "m1".toByteArray()
val data2 = "m2".toByteArray()
client.sendMessage(GarminApplication(device, appId1, "$appId1-name"), data1)
client.sendMessage(GarminApplication(device, appId2, "$appId2-name"), data2)
verify(ciqService).sendMessage(
argThat { iqMsg -> data1.contentEquals(iqMsg.messageData)
&& iqMsg.notificationPackage == packageName
&& iqMsg.notificationAction == client.sendMessageAction },
argThat {iqDevice -> iqDevice.deviceIdentifier == device.id },
argThat { iqApp -> iqApp?.applicationId == appId1 })
verify(ciqService, timeout(5000L)).sendMessage(
argThat { iqMsg -> data2.contentEquals(iqMsg.messageData)
&& iqMsg.notificationPackage == packageName
&& iqMsg.notificationAction == client.sendMessageAction },
argThat {iqDevice -> iqDevice.deviceIdentifier == device.id },
argThat { iqApp -> iqApp?.applicationId == appId2 })
val intent1 = Intent().apply {
putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.SUCCESS.ordinal)
putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, device.toIQDevice())
putExtra(GarminDeviceClient.EXTRA_APPLICATION_ID, appId1)
}
actions[client.sendMessageAction]!!.onReceive(context, intent1)
verify(receiver).onSendMessage(client, device.id, appId1, null)
val intent2 = Intent().apply {
putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.SUCCESS.ordinal)
putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, device.toIQDevice())
putExtra(GarminDeviceClient.EXTRA_APPLICATION_ID, appId2)
}
actions[client.sendMessageAction]!!.onReceive(context, intent2)
verify(receiver).onSendMessage(client, device.id, appId2, null)
}
@Test
fun receiveMessage() {
val app = GarminApplication(GarminDevice(client, 1L, "D1"), "APPID1", "N1")
client.registerForMessages(app)
assertTrue(receivers.contains(app.id))
val intent = Intent().apply {
putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, app.device.toIQDevice())
putExtra(GarminDeviceClient.EXTRA_PAYLOAD, "foo".toByteArray())
}
actions[receivers[app.id]]!!.onReceive(context, intent)
verify(receiver).onReceiveMessage(
eq(client),
eq(app.device.id),
eq(app.id),
argThat { payload -> "foo" == String(payload) })
}
}

View file

@ -0,0 +1,32 @@
package app.aaps.plugins.sync.garmin
data class GarminApplication(
val device: GarminDevice,
val id: String,
val name: String?) {
val client get() = device.client
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as GarminApplication
if (client != other.client) return false
if (device != other.device) return false
if (id != other.id) return false
return true
}
override fun hashCode(): Int {
var result = client.hashCode()
result = 31 * result + device.hashCode()
result = 31 * result + id.hashCode()
return result
}
override fun toString() = "A[$device:$id:$name]"
}

View file

@ -0,0 +1,16 @@
package app.aaps.plugins.sync.garmin
import io.reactivex.rxjava3.disposables.Disposable
interface GarminClient: Disposable {
/** Name of the client. */
val name: String
val connectedDevices: List<GarminDevice>
/** Register to receive messages from the given up. */
fun registerForMessages(app: GarminApplication)
/** Asynchronously sends a message to an application. */
fun sendMessage(app: GarminApplication, data: ByteArray)
}

View file

@ -0,0 +1,36 @@
package app.aaps.plugins.sync.garmin
import com.garmin.android.connectiq.IQDevice
data class GarminDevice(
val client: GarminClient,
val id: Long,
var name: String) {
constructor(client: GarminClient, iqDevice: IQDevice): this(
client,
iqDevice.deviceIdentifier,
iqDevice.friendlyName) {}
override fun toString(): String = "D[$name/$id]"
fun toIQDevice() = IQDevice(id, name)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as GarminDevice
if (client != other.client) return false
if (id != other.id) return false
return true
}
override fun hashCode(): Int {
var result = client.hashCode()
result = 31 * result + id.hashCode()
return result
}
}

View file

@ -0,0 +1,292 @@
package app.aaps.plugins.sync.garmin
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.os.Build
import android.os.IBinder
import app.aaps.core.interfaces.logging.AAPSLogger
import app.aaps.core.interfaces.logging.LTag
import app.aaps.core.utils.waitMillis
import com.garmin.android.apps.connectmobile.connectiq.IConnectIQService
import com.garmin.android.connectiq.ConnectIQ.IQMessageStatus
import com.garmin.android.connectiq.IQApp
import com.garmin.android.connectiq.IQDevice
import com.garmin.android.connectiq.IQMessage
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.jetbrains.annotations.VisibleForTesting
import java.lang.Thread.UncaughtExceptionHandler
import java.time.Instant
import java.util.LinkedList
import java.util.Queue
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
/** GarminClient that talks via the ConnectIQ app to a physical device. */
class GarminDeviceClient(
private val aapsLogger: AAPSLogger,
private val context: Context,
private val receiver: GarminReceiver,
private val retryWaitFactor: Long = 5L): Disposable, GarminClient {
override val name = "Device"
private var executor = Executors.newSingleThreadExecutor { r ->
Thread(r).apply {
name = "Garmin callback"
isDaemon = true
uncaughtExceptionHandler = UncaughtExceptionHandler { _, e ->
aapsLogger.error(LTag.GARMIN, "ConnectIQ callback failed", e) }
}
}
private var bindLock = Object()
private var ciqService: IConnectIQService? = null
get() {
synchronized (bindLock) {
if (field?.asBinder()?.isBinderAlive != true) {
field = null
if (state !in arrayOf(State.BINDING, State.RECONNECTING)) {
aapsLogger.info(LTag.GARMIN, "reconnecting to ConnectIQ service")
state = State.RECONNECTING
bindService()
}
bindLock.waitMillis(2_000L)
if (field?.asBinder()?.isBinderAlive != true) {
field = null
// The [serviceConnection] didn't have a chance to reassign ciqService,
// i.e. the wait timed out. Give up.
aapsLogger.warn(LTag.GARMIN, "no ciqservice $this")
}
}
return field
}
}
private val registeredActions = mutableSetOf<String>()
private val broadcastReceiver = mutableListOf<BroadcastReceiver>()
private var state = State.DISCONNECTED
private val serviceIntent get() = Intent(CONNECTIQ_SERVICE_ACTION).apply {
component = CONNECTIQ_SERVICE_COMPONENT }
@VisibleForTesting
val sendMessageAction = createAction("SEND_MESSAGE")
private enum class State {
BINDING,
CONNECTED,
DISCONNECTED,
DISPOSED,
RECONNECTING,
}
private val ciqServiceConnection = object: ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
var notifyReceiver: Boolean
val ciq: IConnectIQService
synchronized(bindLock) {
aapsLogger.info(LTag.GARMIN, "ConnectIQ App connected")
ciq = IConnectIQService.Stub.asInterface(service)
notifyReceiver = state != State.RECONNECTING
state = State.CONNECTED
ciqService = ciq
bindLock.notifyAll()
}
if (notifyReceiver) receiver.onConnect(this@GarminDeviceClient)
}
override fun onServiceDisconnected(name: ComponentName?) {
synchronized(bindLock) {
aapsLogger.info(LTag.GARMIN, "ConnectIQ App disconnected")
ciqService = null
if (state != State.DISPOSED) state = State.DISCONNECTED
}
broadcastReceiver.forEach { br -> context.unregisterReceiver(br) }
broadcastReceiver.clear()
registeredActions.clear()
receiver.onDisconnect(this@GarminDeviceClient)
}
}
init {
aapsLogger.info(LTag.GARMIN, "binding to ConnectIQ service")
registerReceiver(sendMessageAction, ::onSendMessage)
state = State.BINDING
bindService()
}
private fun bindService() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
context.bindService(serviceIntent, Context.BIND_AUTO_CREATE, executor, ciqServiceConnection)
} else {
context.bindService(serviceIntent, ciqServiceConnection, Context.BIND_AUTO_CREATE)
}
}
override val connectedDevices: List<GarminDevice>
get() = ciqService?.connectedDevices?.map { iqDevice -> GarminDevice(this, iqDevice) }
?: emptyList()
override fun isDisposed() = state == State.DISPOSED
override fun dispose() {
broadcastReceiver.forEach { context.unregisterReceiver(it) }
broadcastReceiver.clear()
registeredActions.clear()
try {
context.unbindService(ciqServiceConnection)
} catch (e: Exception) {
aapsLogger.warn(LTag.GARMIN, "unbind CIQ failed ${e.message}")
}
state = State.DISPOSED
}
/** Creates a unique action name for ConnectIQ callbacks. */
private fun createAction(action: String) = "${javaClass.`package`!!.name}.$action"
/** Registers a callback [BroadcastReceiver] under the given action that will
* used by the ConnectIQ app for callbacks.*/
private fun registerReceiver(action: String, receive: (intent: Intent) -> Unit) {
val recv = object: BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent) { receive(intent) }
}
broadcastReceiver.add(recv)
context.registerReceiver(recv, IntentFilter(action))
}
override fun registerForMessages(app: GarminApplication) {
aapsLogger.info(LTag.GARMIN, "registerForMessage $name $app")
val action = createAction("ON_MESSAGE_${app.device.id}_${app.id}")
val iqApp = IQApp(app.id)
synchronized (registeredActions) {
if (!registeredActions.contains(action)) {
registerReceiver(action) { intent: Intent -> onReceiveMessage(iqApp, intent) }
ciqService?.registerApp(iqApp, action, context.packageName)
registeredActions.add(action)
} else {
aapsLogger.info(LTag.GARMIN, "registerForMessage $action already registered")
}
}
}
@Suppress("Deprecation")
private fun onReceiveMessage(iqApp: IQApp, intent: Intent) {
val iqDevice = intent.getParcelableExtra(EXTRA_REMOTE_DEVICE) as IQDevice?
val data = intent.getByteArrayExtra(EXTRA_PAYLOAD)
if (iqDevice != null && data != null)
receiver.onReceiveMessage(this, iqDevice.deviceIdentifier, iqApp.applicationId, data)
}
/** Receives callback from ConnectIQ about message transfers. */
private fun onSendMessage(intent: Intent) {
val statusOrd = intent.getIntExtra(EXTRA_STATUS, IQMessageStatus.FAILURE_UNKNOWN.ordinal)
val status = IQMessageStatus.values().firstOrNull { s -> s.ordinal == statusOrd } ?: IQMessageStatus.FAILURE_UNKNOWN
val deviceId = getDevice(intent)
val appId = intent.getStringExtra(EXTRA_APPLICATION_ID)?.uppercase()
if (deviceId == null || appId == null) {
aapsLogger.warn(LTag.GARMIN, "onSendMessage device='$deviceId' app='$appId'")
} else {
synchronized (messageQueues) {
val queue = messageQueues[deviceId to appId]
val msg = queue?.peek()
if (queue == null || msg == null) {
aapsLogger.warn(LTag.GARMIN, "onSendMessage unknown message $deviceId, $appId, $status")
return
}
var errorMessage: String? = null
when (status) {
IQMessageStatus.SUCCESS -> {}
IQMessageStatus.FAILURE_DEVICE_NOT_CONNECTED,
IQMessageStatus.FAILURE_DURING_TRANSFER -> {
if (msg.attempt < MAX_RETRIES) {
val delaySec = retryWaitFactor * msg.attempt
Schedulers.io().scheduleDirect({ retryMessage(deviceId, appId) }, delaySec, TimeUnit.SECONDS)
return
}
}
else -> {
errorMessage = "error $status"
}
}
queue.poll()
receiver.onSendMessage(this, msg.app.device.id, msg.app.id, errorMessage)
if (queue.isNotEmpty()) {
Schedulers.io().scheduleDirect { retryMessage(deviceId, appId) }
}
}
}
}
@Suppress("Deprecation")
private fun getDevice(intent: Intent): Long? {
val rawDevice = intent.extras?.get(EXTRA_REMOTE_DEVICE)
return if (rawDevice is Long) rawDevice else (rawDevice as IQDevice?)?.deviceIdentifier
?: return null
}
private class Message(
val app: GarminApplication,
val data: ByteArray) {
var attempt: Int = 0
val creation = Instant.now()
var lastAttempt: Instant? = null
val iqApp get() = IQApp(app.id, app.name, 0)
val iqDevice get() = app.device.toIQDevice()
}
private val messageQueues = mutableMapOf<Pair<Long, String>, Queue<Message>> ()
override fun sendMessage(app: GarminApplication, data: ByteArray) {
val msg = synchronized (messageQueues) {
val msg = Message(app, data)
val oldMessageCutOff = Instant.now().minusSeconds(30)
val queue = messageQueues.getOrPut(app.device.id to app.id) { LinkedList() }
while (true) {
val oldMsg = queue.peek() ?: break
if ((oldMsg.lastAttempt ?: oldMsg.creation).isBefore(oldMessageCutOff)) {
aapsLogger.warn(LTag.GARMIN, "remove old msg ${msg.app}")
queue.poll()
} else {
break
}
}
queue.add(msg)
// Make sure we have only one outstanding message per app, so we ensure
// that always the first message in the queue is currently send.
if (queue.size == 1) msg else null
}
if (msg != null) sendMessage(msg)
}
private fun retryMessage(deviceId: Long, appId: String) {
val msg = synchronized (messageQueues) {
messageQueues[deviceId to appId]?.peek() ?: return
}
sendMessage(msg)
}
private fun sendMessage(msg: Message) {
msg.attempt++
msg.lastAttempt = Instant.now()
val iqMsg = IQMessage(msg.data, context.packageName, sendMessageAction)
ciqService?.sendMessage(iqMsg, msg.iqDevice, msg.iqApp)
}
override fun toString() = "$name[$state]"
companion object {
const val CONNECTIQ_SERVICE_ACTION = "com.garmin.android.apps.connectmobile.CONNECTIQ_SERVICE_ACTION"
const val EXTRA_APPLICATION_ID = "com.garmin.android.connectiq.EXTRA_APPLICATION_ID"
const val EXTRA_REMOTE_DEVICE = "com.garmin.android.connectiq.EXTRA_REMOTE_DEVICE"
const val EXTRA_PAYLOAD = "com.garmin.android.connectiq.EXTRA_PAYLOAD"
const val EXTRA_STATUS = "com.garmin.android.connectiq.EXTRA_STATUS"
val CONNECTIQ_SERVICE_COMPONENT = ComponentName(
"com.garmin.android.apps.connectmobile",
"com.garmin.android.apps.connectmobile.connectiq.ConnectIQService")
const val MAX_RETRIES = 10
}
}

View file

@ -0,0 +1,123 @@
package app.aaps.plugins.sync.garmin
import android.content.Context
import app.aaps.core.interfaces.logging.AAPSLogger
import app.aaps.core.interfaces.logging.LTag
import io.reactivex.rxjava3.disposables.Disposable
class GarminMessenger(
private val aapsLogger: AAPSLogger,
private val context: Context,
applicationIdNames: Map<String, String>,
private val messageCallback: (app: GarminApplication, msg: Any) -> Unit,
enableConnectIq: Boolean,
enableSimulator: Boolean): Disposable, GarminReceiver {
private var disposed: Boolean = false
/** All devices that where connected since this instance was created. */
private val devices = mutableMapOf<Long, GarminDevice>()
private val clients = mutableListOf<GarminClient>()
private val appIdNames = mutableMapOf<String, String>()
init {
aapsLogger.info(LTag.GARMIN, "init CIQ debug=$enableSimulator")
appIdNames.putAll(applicationIdNames)
if (enableConnectIq) startDeviceClient()
if (enableSimulator) {
appIdNames["SimApp"] = "SimulatorApp"
GarminSimulatorClient(aapsLogger, this)
}
}
private fun getDevice(client: GarminClient, deviceId: Long): GarminDevice {
synchronized (devices) {
return devices.getOrPut(deviceId) {
client.connectedDevices.firstOrNull { d -> d.id == deviceId } ?:
GarminDevice(client, deviceId, "unknown") }
}
}
private fun getApplication(client: GarminClient, deviceId: Long, appId: String): GarminApplication {
return GarminApplication(getDevice(client, deviceId), appId, appIdNames[appId])
}
private fun startDeviceClient() {
GarminDeviceClient(aapsLogger, context, this)
}
override fun onConnect(client: GarminClient) {
aapsLogger.info(LTag.GARMIN, "onConnect $client")
clients.add(client)
}
override fun onDisconnect(client: GarminClient) {
aapsLogger.info(LTag.GARMIN, "onDisconnect ${client.name}")
clients.remove(client)
synchronized (devices) {
val deviceIds = devices.filter { (_, d) -> d.client == client }.map { (id, _) -> id }
deviceIds.forEach { id -> devices.remove(id) }
}
client.dispose()
when (client) {
is GarminDeviceClient -> startDeviceClient()
is GarminSimulatorClient -> GarminSimulatorClient(aapsLogger, this)
else -> aapsLogger.warn(LTag.GARMIN, "onDisconnect unknown client $client")
}
}
override fun onReceiveMessage(client: GarminClient, deviceId: Long, appId: String, data: ByteArray) {
val app = getApplication(client, deviceId, appId)
val msg = GarminSerializer.deserialize(data)
if (msg == null) {
aapsLogger.warn(LTag.GARMIN, "receive NULL msg")
} else {
aapsLogger.info(LTag.GARMIN, "receive ${data.size} bytes")
messageCallback(app, msg)
}
}
/** Receives status notifications for a sent message. */
override fun onSendMessage(client: GarminClient, deviceId: Long, appId: String, errorMessage: String?) {
val app = getApplication(client, deviceId, appId)
aapsLogger.info(LTag.GARMIN, "onSendMessage $app ${errorMessage ?: "OK"}")
}
fun sendMessage(device: GarminDevice, msg: Any) {
appIdNames.forEach { (appId, _) ->
sendMessage(getApplication(device.client, device.id, appId), msg)
}
}
/** Sends a message to all applications on all devices. */
fun sendMessage(msg: Any) {
clients.forEach { cl -> cl.connectedDevices.forEach { d -> sendMessage(d, msg) }}
}
private fun sendMessage(app: GarminApplication, msg: Any) {
// Convert msg to string for logging.
val s = when (msg) {
is Map<*,*> ->
msg.entries.joinToString(", ", "(", ")") { (k, v) -> "$k=$v" }
is List<*> ->
msg.joinToString(", ", "(", ")")
else ->
msg.toString()
}
val data = GarminSerializer.serialize(msg)
aapsLogger.info(LTag.GARMIN, "sendMessage $app ${data.size} bytes $s")
try {
app.client.sendMessage(app, data)
} catch (e: IllegalStateException) {
aapsLogger.error(LTag.GARMIN, "${app.client} not connected", e)
}
}
override fun dispose() {
if (!disposed) {
clients.forEach { c -> c.dispose() }
disposed = true
}
clients.clear()
}
override fun isDisposed() = disposed
}

View file

@ -1,5 +1,6 @@
package app.aaps.plugins.sync.garmin package app.aaps.plugins.sync.garmin
import android.content.Context
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import app.aaps.core.interfaces.db.GlucoseUnit import app.aaps.core.interfaces.db.GlucoseUnit
import app.aaps.core.interfaces.logging.AAPSLogger import app.aaps.core.interfaces.logging.AAPSLogger
@ -22,6 +23,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import java.math.BigDecimal import java.math.BigDecimal
import java.math.MathContext import java.math.MathContext
import java.math.RoundingMode import java.math.RoundingMode
import java.net.HttpURLConnection
import java.net.SocketAddress import java.net.SocketAddress
import java.net.URI import java.net.URI
import java.time.Clock import java.time.Clock
@ -46,6 +48,7 @@ class GarminPlugin @Inject constructor(
injector: HasAndroidInjector, injector: HasAndroidInjector,
aapsLogger: AAPSLogger, aapsLogger: AAPSLogger,
resourceHelper: ResourceHelper, resourceHelper: ResourceHelper,
private val context: Context,
private val loopHub: LoopHub, private val loopHub: LoopHub,
private val rxBus: RxBus, private val rxBus: RxBus,
private val sp: SP, private val sp: SP,
@ -61,7 +64,19 @@ class GarminPlugin @Inject constructor(
) { ) {
/** HTTP Server for local HTTP server communication (device app requests values) .*/ /** HTTP Server for local HTTP server communication (device app requests values) .*/
private var server: HttpServer? = null private var server: HttpServer? = null
var garminMessenger: GarminMessenger? = null
/** Garmin ConnectIQ application id for native communication. Phone pushes values. */
private val glucoseAppIds = mapOf(
"C9E90EE7E6924829A8B45E7DAFFF5CB4" to "GlucoseWatch_Dev",
"1107CA6C2D5644B998D4BCB3793F2B7C" to "GlucoseDataField_Dev",
"928FE19A4D3A4259B50CB6F9DDAF0F4A" to "GlucoseWidget_Dev",
"662DFCF7F5A147DE8BD37F09574ADB11" to "GlucoseWatch",
"815C7328C21248C493AD9AC4682FE6B3" to "GlucoseDataField",
"4BDDCC1740084A1FAB83A3B2E2FCF55B" to "GlucoseWidget",
)
@VisibleForTesting
private val disposable = CompositeDisposable() private val disposable = CompositeDisposable()
@VisibleForTesting @VisibleForTesting
@ -72,10 +87,24 @@ class GarminPlugin @Inject constructor(
var newValue: Condition = valueLock.newCondition() var newValue: Condition = valueLock.newCondition()
private var lastGlucoseValueTimestamp: Long? = null private var lastGlucoseValueTimestamp: Long? = null
private val glucoseUnitStr get() = if (loopHub.glucoseUnit == GlucoseUnit.MGDL) "mgdl" else "mmoll" private val glucoseUnitStr get() = if (loopHub.glucoseUnit == GlucoseUnit.MGDL) "mgdl" else "mmoll"
private val garminAapsKey get() = sp.getString("garmin_aaps_key", "")
private fun onPreferenceChange(event: EventPreferenceChange) { private fun onPreferenceChange(event: EventPreferenceChange) {
aapsLogger.info(LTag.GARMIN, "preferences change ${event.changedKey}") when (event.changedKey) {
setupHttpServer() "communication_debug_mode" -> setupGarminMessenger()
"communication_http", "communication_http_port" -> setupHttpServer()
"garmin_aaps_key" -> sendPhoneAppMessage()
}
}
private fun setupGarminMessenger() {
val enableDebug = sp.getBoolean("communication_ciq_debug_mode", false)
garminMessenger?.dispose()
garminMessenger = null
aapsLogger.info(LTag.GARMIN, "initialize IQ messenger in debug=$enableDebug")
garminMessenger = GarminMessenger(
aapsLogger, context, glucoseAppIds, {_, _ -> },
true, enableDebug).also { disposable.add(it) }
} }
override fun onStart() { override fun onStart() {
@ -87,20 +116,34 @@ class GarminPlugin @Inject constructor(
.observeOn(Schedulers.io()) .observeOn(Schedulers.io())
.subscribe(::onPreferenceChange) .subscribe(::onPreferenceChange)
) )
disposable.add(
rxBus
.toObservable(EventNewBG::class.java)
.observeOn(Schedulers.io())
.subscribe(::onNewBloodGlucose)
)
setupHttpServer() setupHttpServer()
if (garminAapsKey.isNotEmpty())
setupGarminMessenger()
} }
private fun setupHttpServer() { private fun setupHttpServer() {
setupHttpServer(Duration.ZERO)
}
@VisibleForTesting
fun setupHttpServer(wait: Duration) {
if (sp.getBoolean("communication_http", false)) { if (sp.getBoolean("communication_http", false)) {
val port = sp.getInt("communication_http_port", 28891) val port = sp.getInt("communication_http_port", 28891)
if (server != null && server?.port == port) return if (server != null && server?.port == port) return
aapsLogger.info(LTag.GARMIN, "starting HTTP server on $port") aapsLogger.info(LTag.GARMIN, "starting HTTP server on $port")
server?.close() server?.close()
server = HttpServer(aapsLogger, port).apply { server = HttpServer(aapsLogger, port).apply {
registerEndpoint("/get", ::onGetBloodGlucose) registerEndpoint("/get", requestHandler(::onGetBloodGlucose))
registerEndpoint("/carbs", ::onPostCarbs) registerEndpoint("/carbs", requestHandler(::onPostCarbs))
registerEndpoint("/connect", ::onConnectPump) registerEndpoint("/connect", requestHandler(::onConnectPump))
registerEndpoint("/sgv.json", ::onSgv) registerEndpoint("/connect", requestHandler(::onSgv))
awaitReady(wait)
} }
} else if (server != null) { } else if (server != null) {
aapsLogger.info(LTag.GARMIN, "stopping HTTP server") aapsLogger.info(LTag.GARMIN, "stopping HTTP server")
@ -109,7 +152,7 @@ class GarminPlugin @Inject constructor(
} }
} }
override fun onStop() { public override fun onStop() {
disposable.clear() disposable.clear()
aapsLogger.info(LTag.GARMIN, "Stop") aapsLogger.info(LTag.GARMIN, "Stop")
server?.close() server?.close()
@ -133,6 +176,36 @@ class GarminPlugin @Inject constructor(
} }
} }
@VisibleForTesting
fun onConnectDevice(device: GarminDevice) {
if (garminAapsKey.isNotEmpty()) {
aapsLogger.info(LTag.GARMIN, "onConnectDevice $device sending glucose")
sendPhoneAppMessage(device)
}
}
private fun sendPhoneAppMessage(device: GarminDevice) {
garminMessenger?.sendMessage(device, getGlucoseMessage())
}
private fun sendPhoneAppMessage() {
garminMessenger?.sendMessage(getGlucoseMessage())
}
@VisibleForTesting
fun getGlucoseMessage() = mapOf<String, Any>(
"key" to garminAapsKey,
"command" to "glucose",
"profile" to loopHub.currentProfileName.first().toString(),
"encodedGlucose" to encodedGlucose(getGlucoseValues()),
"remainingInsulin" to loopHub.insulinOnboard,
"glucoseUnit" to glucoseUnitStr,
"temporaryBasalRate" to
(loopHub.temporaryBasal.takeIf(java.lang.Double::isFinite) ?: 1.0),
"connected" to loopHub.isConnected,
"timestamp" to clock.instant().epochSecond
)
/** Gets the last 2+ hours of glucose values. */ /** Gets the last 2+ hours of glucose values. */
@VisibleForTesting @VisibleForTesting
fun getGlucoseValues(): List<GlucoseValue> { fun getGlucoseValues(): List<GlucoseValue> {
@ -166,21 +239,33 @@ class GarminPlugin @Inject constructor(
val glucoseMgDl: Int = glucose.value.roundToInt() val glucoseMgDl: Int = glucose.value.roundToInt()
encodedGlucose.add(timeSec, glucoseMgDl) encodedGlucose.add(timeSec, glucoseMgDl)
} }
aapsLogger.info(
LTag.GARMIN,
"retrieved ${glucoseValues.size} last ${Date(glucoseValues.lastOrNull()?.timestamp ?: 0L)} ${encodedGlucose.size}"
)
return encodedGlucose.encodedBase64() return encodedGlucose.encodedBase64()
} }
@VisibleForTesting
fun requestHandler(action: (URI) -> CharSequence) = {
caller: SocketAddress, uri: URI, _: String? ->
val key = garminAapsKey
val deviceKey = getQueryParameter(uri, "key")
if (key.isNotEmpty() && key != deviceKey) {
aapsLogger.warn(LTag.GARMIN, "Invalid AAPS Key from $caller, got '$deviceKey' want '$key' $uri")
sendPhoneAppMessage()
Thread.sleep(1000L)
HttpURLConnection.HTTP_UNAUTHORIZED to "{}"
} else {
aapsLogger.info(LTag.GARMIN, "get from $caller resp , req: $uri")
HttpURLConnection.HTTP_OK to action(uri).also {
aapsLogger.info(LTag.GARMIN, "get from $caller resp , req: $uri, result: $it")
}
}
}
/** Responses to get glucose value request by the device. /** Responses to get glucose value request by the device.
* *
* Also, gets the heart rate readings from the device. * Also, gets the heart rate readings from the device.
*/ */
@VisibleForTesting @VisibleForTesting
@Suppress("UNUSED_PARAMETER") fun onGetBloodGlucose(uri: URI): CharSequence {
fun onGetBloodGlucose(caller: SocketAddress, uri: URI, requestBody: String?): CharSequence {
aapsLogger.info(LTag.GARMIN, "get from $caller resp , req: $uri")
receiveHeartRate(uri) receiveHeartRate(uri)
val profileName = loopHub.currentProfileName val profileName = loopHub.currentProfileName
val waitSec = getQueryParameter(uri, "wait", 0L) val waitSec = getQueryParameter(uri, "wait", 0L)
@ -194,9 +279,7 @@ class GarminPlugin @Inject constructor(
} }
jo.addProperty("profile", profileName.first().toString()) jo.addProperty("profile", profileName.first().toString())
jo.addProperty("connected", loopHub.isConnected) jo.addProperty("connected", loopHub.isConnected)
return jo.toString().also { return jo.toString()
aapsLogger.info(LTag.GARMIN, "get from $caller resp , req: $uri, result: $it")
}
} }
private fun getQueryParameter(uri: URI, name: String) = (uri.query ?: "") private fun getQueryParameter(uri: URI, name: String) = (uri.query ?: "")
@ -228,6 +311,19 @@ class GarminPlugin @Inject constructor(
} }
} }
private fun toLong(v: Any?) = (v as? Number?)?.toLong() ?: 0L
@VisibleForTesting
fun receiveHeartRate(msg: Map<String, Any>, test: Boolean) {
val avg: Int = msg.getOrDefault("hr", 0) as Int
val samplingStartSec: Long = toLong(msg["hrStart"])
val samplingEndSec: Long = toLong(msg["hrEnd"])
val device: String? = msg["device"] as String?
receiveHeartRate(
Instant.ofEpochSecond(samplingStartSec), Instant.ofEpochSecond(samplingEndSec),
avg, device, test)
}
@VisibleForTesting @VisibleForTesting
fun receiveHeartRate(uri: URI) { fun receiveHeartRate(uri: URI) {
val avg: Int = getQueryParameter(uri, "hr", 0L).toInt() val avg: Int = getQueryParameter(uri, "hr", 0L).toInt()
@ -242,20 +338,18 @@ class GarminPlugin @Inject constructor(
private fun receiveHeartRate( private fun receiveHeartRate(
samplingStart: Instant, samplingEnd: Instant, samplingStart: Instant, samplingEnd: Instant,
avg: Int, device: String?, test: Boolean) { avg: Int, device: String?, test: Boolean) {
aapsLogger.info(LTag.GARMIN, "average heart rate $avg BPM test=$test") aapsLogger.info(LTag.GARMIN, "average heart rate $avg BPM $samplingStart to $samplingEnd")
if (test) return if (test) return
if (avg > 10 && samplingStart > Instant.ofEpochMilli(0L) && samplingEnd > samplingStart) { if (avg > 10 && samplingStart > Instant.ofEpochMilli(0L) && samplingEnd > samplingStart) {
loopHub.storeHeartRate(samplingStart, samplingEnd, avg, device) loopHub.storeHeartRate(samplingStart, samplingEnd, avg, device)
} else { } else if (avg > 0) {
aapsLogger.warn(LTag.GARMIN, "Skip saving invalid HR $avg $samplingStart..$samplingEnd") aapsLogger.warn(LTag.GARMIN, "Skip saving invalid HR $avg $samplingStart..$samplingEnd")
} }
} }
/** Handles carb notification from the device. */ /** Handles carb notification from the device. */
@VisibleForTesting @VisibleForTesting
@Suppress("UNUSED_PARAMETER") fun onPostCarbs(uri: URI): CharSequence {
fun onPostCarbs(caller: SocketAddress, uri: URI, requestBody: String?): CharSequence {
aapsLogger.info(LTag.GARMIN, "carbs from $caller, req: $uri")
postCarbs(getQueryParameter(uri, "carbs", 0L).toInt()) postCarbs(getQueryParameter(uri, "carbs", 0L).toInt())
return "" return ""
} }
@ -268,9 +362,7 @@ class GarminPlugin @Inject constructor(
/** Handles pump connected notification that the user entered on the Garmin device. */ /** Handles pump connected notification that the user entered on the Garmin device. */
@VisibleForTesting @VisibleForTesting
@Suppress("UNUSED_PARAMETER") fun onConnectPump(uri: URI): CharSequence {
fun onConnectPump(caller: SocketAddress, uri: URI, requestBody: String?): CharSequence {
aapsLogger.info(LTag.GARMIN, "connect from $caller, req: $uri")
val minutes = getQueryParameter(uri, "disconnectMinutes", 0L).toInt() val minutes = getQueryParameter(uri, "disconnectMinutes", 0L).toInt()
if (minutes > 0) { if (minutes > 0) {
loopHub.disconnectPump(minutes) loopHub.disconnectPump(minutes)
@ -289,8 +381,7 @@ class GarminPlugin @Inject constructor(
/** Returns glucose values in Nightscout/Xdrip format. */ /** Returns glucose values in Nightscout/Xdrip format. */
@VisibleForTesting @VisibleForTesting
@Suppress("UNUSED_PARAMETER") fun onSgv(uri: URI): CharSequence {
fun onSgv(call: SocketAddress, uri: URI, requestBody: String?): CharSequence {
val count = getQueryParameter(uri,"count", 24L) val count = getQueryParameter(uri,"count", 24L)
.toInt().coerceAtMost(1000).coerceAtLeast(1) .toInt().coerceAtMost(1000).coerceAtLeast(1)
val briefMode = getQueryParameter(uri, "brief_mode", false) val briefMode = getQueryParameter(uri, "brief_mode", false)

View file

@ -0,0 +1,23 @@
package app.aaps.plugins.sync.garmin
/**
* Callback interface for a @see ConnectIqClient.
*/
interface GarminReceiver {
/**
* Notifies that the client is ready, i.e. the app client as bound to the Garmin
* Android app.
*/
fun onConnect(client: GarminClient)
fun onDisconnect(client: GarminClient)
/**
* Delivers received device app messages.
*/
fun onReceiveMessage(client: GarminClient, deviceId: Long, appId: String, data: ByteArray)
/**
* Delivers status of @see ConnectIqClient#sendMessage requests.
*/
fun onSendMessage(client: GarminClient, deviceId: Long, appId: String, errorMessage: String?)
}

View file

@ -0,0 +1,254 @@
package app.aaps.plugins.sync.garmin
import java.io.ByteArrayOutputStream
import java.io.DataOutputStream
import java.nio.ByteBuffer
import java.util.ArrayDeque
import java.util.Queue
/**
* Serialize and Deserialize objects in Garmin format.
*
* Format is as follows:
* <STRS_MARKER><STRS_LEN><STRINGS><OBJS_MARKER><OBJS_LENGTH><OBJ><OBJ>...
*
* Serialized data starts with an optional string block. The string block is preceded with the STRS_MARKER,
* followed by the total length of the reminder (4 bytes). Then foreach string, the string length
* (2 bytes), followed by the string bytes, followed by a \0 byte.
*
* Objects are stored starting with OBJS_MARKER, followed by the total length (4 bytes), followed
* by a flat list of objects. Each object starts with its type (1 byte), followed by the data
* for numbers in Boolean. Strings a represented by an index into the string block. Arrays only have
* the length, the actual objects will be in the list of objects. Similarly, maps only have the
* length and the entries are represented by 2 objects (key + val) in the list of objects.
*/
object GarminSerializer {
private const val NULL = 0
private const val INT = 1
private const val FLOAT = 2
private const val STRING = 3
private const val ARRAY = 5
private const val BOOLEAN = 9
private const val MAP = 11
private const val LONG = 14
private const val DOUBLE = 15
private const val CHAR = 19
private const val STRINGS_MARKER = -1412584499
private const val OBJECTS_MARKER = -629482886
// ArrayDeque doesn't like null so we use this instead.
private val NULL_MARKER = object {}
private interface Container {
fun read(buf: ByteBuffer, strings: Map<Int, String>, container: Queue<Container>)
}
private class ListContainer(
val size: Int,
val list: MutableList<Any?>
) : Container {
override fun read(buf: ByteBuffer, strings: Map<Int, String>, container: Queue<Container>) {
for (i in 0 until size) {
list.add(readObject(buf, strings, container))
}
}
}
private class MapContainer(
val size: Int,
val map: MutableMap<Any, Any?>
) : Container {
override fun read(buf: ByteBuffer, strings: Map<Int, String>, container: Queue<Container>) {
for (i in 0 until size) {
val k = readObject(buf, strings, container)
val v = readObject(buf, strings, container)
map[k!!] = v
}
}
}
fun serialize(obj: Any?): ByteArray {
val strsOut = ByteArrayOutputStream()
val strsDataOut = DataOutputStream(strsOut)
val objsOut = ByteArrayOutputStream()
val strings = mutableMapOf<String, Int>()
val q = ArrayDeque<Any?>()
q.add(obj ?: NULL_MARKER)
while (!q.isEmpty()) {
serialize(q.poll(), strsDataOut, DataOutputStream(objsOut), strings, q)
}
var bufLen = 8 + objsOut.size()
if (strsOut.size() > 0) {
bufLen += 8 + strsOut.size()
}
val buf = ByteBuffer.allocate(bufLen)
if (strsOut.size() > 0) {
buf.putInt(STRINGS_MARKER)
buf.putInt(strsOut.size())
buf.put(strsOut.toByteArray(), 0, strsOut.size())
}
buf.putInt(OBJECTS_MARKER)
buf.putInt(objsOut.size())
buf.put(objsOut.toByteArray(), 0, objsOut.size())
return buf.array()
}
private fun serialize(
obj: Any?,
strOut: DataOutputStream,
objOut: DataOutputStream,
strings: MutableMap<String, Int>,
q: Queue<Any?>
) {
when (obj) {
NULL_MARKER -> objOut.writeByte(NULL)
is Int -> {
objOut.writeByte(INT)
objOut.writeInt(obj)
}
is Float -> {
objOut.writeByte(FLOAT)
objOut.writeFloat(obj)
}
is String -> {
objOut.writeByte(STRING)
val offset = strings[obj]
if (offset == null) {
strings[obj] = strOut.size()
val bytes = obj.toByteArray(Charsets.UTF_8)
strOut.writeShort(bytes.size + 1)
strOut.write(bytes)
strOut.write(0)
}
objOut.writeInt(strings[obj]!!)
}
is List<*> -> {
objOut.writeByte(ARRAY)
objOut.writeInt(obj.size)
obj.forEach { o -> q.add(o ?: NULL_MARKER) }
}
is Boolean -> {
objOut.writeByte(BOOLEAN)
objOut.writeByte(if (obj) 1 else 0)
}
is Map<*, *> -> {
objOut.writeByte(MAP)
objOut.writeInt(obj.size)
obj.entries.forEach { (k, v) ->
q.add(k ?: NULL_MARKER); q.add(v ?: NULL_MARKER) }
}
is Long -> {
objOut.writeByte(LONG)
objOut.writeLong(obj)
}
is Double -> {
objOut.writeByte(DOUBLE)
objOut.writeDouble(obj)
}
is Char -> {
objOut.writeByte(CHAR)
objOut.writeInt(obj.code)
}
else ->
throw IllegalArgumentException("Unsupported type ${obj?.javaClass} '$obj'")
}
}
fun deserialize(data: ByteArray): Any? {
val buf = ByteBuffer.wrap(data)
val marker1 = buf.getInt(0)
val strings = if (marker1 == STRINGS_MARKER) {
buf.int // swallow the marker
readStrings(buf)
} else {
emptyMap()
}
val marker2 = buf.int // swallow the marker
if (marker2 != OBJECTS_MARKER) {
throw IllegalArgumentException("expected data marker, got $marker2")
}
return readObjects(buf, strings)
}
private fun readStrings(buf: ByteBuffer): Map<Int, String> {
val strings = mutableMapOf<Int, String>()
val strBufferLen = buf.int
val offset = buf.position()
while (buf.position() - offset < strBufferLen) {
val pos = buf.position() - offset
val strLen = buf.short.toInt() - 1 // ignore \0 byte
val strBytes = ByteArray(strLen)
buf.get(strBytes)
strings[pos] = String(strBytes, Charsets.UTF_8)
buf.get() // swallow \0 byte
}
return strings
}
private fun readObjects(buf: ByteBuffer, strings: Map<Int, String>): Any? {
val objBufferLen = buf.int
if (objBufferLen > buf.remaining()) {
throw IllegalArgumentException("expect $objBufferLen bytes got ${buf.remaining()}")
}
val container = ArrayDeque<Container>()
val r = readObject(buf, strings, container)
while (container.isNotEmpty()) {
container.pollFirst()?.read(buf, strings, container)
}
return r
}
private fun readObject(buf: ByteBuffer, strings: Map<Int, String>, q: Queue<Container>): Any? {
when (buf.get().toInt()) {
NULL -> return null
INT -> return buf.int
FLOAT -> return buf.float
STRING -> {
val offset = buf.int
return strings[offset]!!
}
ARRAY -> {
val arraySize = buf.int
val array = mutableListOf<Any?>()
// We will populate the array with arraySize objects from the object list later,
// when we take the ListContainer from the queue.
q.add(ListContainer(arraySize, array))
return array
}
BOOLEAN -> return buf.get() > 0
MAP -> {
val mapSize = buf.int
val map = mutableMapOf<Any, Any?>()
q.add(MapContainer(mapSize, map))
return map
}
LONG -> return buf.long
DOUBLE -> return buf.double
CHAR -> return Char(buf.int)
else -> return null
}
}
}

View file

@ -0,0 +1,181 @@
package app.aaps.plugins.sync.garmin
import app.aaps.core.interfaces.logging.AAPSLogger
import app.aaps.core.interfaces.logging.LTag
import com.garmin.android.connectiq.IQApp
import com.garmin.android.connectiq.IQApp.IQAppStatus
import io.reactivex.rxjava3.disposables.Disposable
import org.jetbrains.annotations.VisibleForTesting
import java.io.InputStream
import java.net.Inet4Address
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
import java.net.SocketException
import java.time.Duration
import java.util.Collections
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
/** [GarminClient] that talks to the ConnectIQ simulator via HTTP.
*
* This is needed for Garmin device app development. */
class GarminSimulatorClient(
private val aapsLogger: AAPSLogger,
private val receiver: GarminReceiver,
var port: Int = 7381
): Disposable, GarminClient {
override val name = "Sim"
private val executor: ExecutorService = Executors.newCachedThreadPool()
private val serverSocket = ServerSocket()
private val connections: MutableList<Connection> = Collections.synchronizedList(mutableListOf())
private var nextDeviceId = AtomicLong(1)
@VisibleForTesting
val iqApp = IQApp("SimApp", IQAppStatus.INSTALLED, "Simulator", 1)
private val readyLock = ReentrantLock()
private val readyCond = readyLock.newCondition()
override val connectedDevices: List<GarminDevice> get() = connections.map { c -> c.device }
override fun registerForMessages(app: GarminApplication) {
}
private inner class Connection(private val socket: Socket): Disposable {
val device = GarminDevice(
this@GarminSimulatorClient,
nextDeviceId.getAndAdd(1L),
"Sim@${socket.remoteSocketAddress}")
fun start() {
executor.execute {
try {
run()
} catch (e: Throwable) {
aapsLogger.error(LTag.GARMIN, "$device failed", e)
}
}
}
fun send(data: ByteArray) {
if (socket.isConnected && !socket.isOutputShutdown) {
aapsLogger.info(LTag.GARMIN, "sending ${data.size} bytes to $device")
socket.outputStream.write(data)
socket.outputStream.flush()
} else {
aapsLogger.warn(LTag.GARMIN, "socket closed, cannot send $device")
}
}
private fun run() {
socket.soTimeout = 0
socket.isInputShutdown
while (!socket.isClosed && socket.isConnected) {
try {
val data = readAvailable(socket.inputStream) ?: break
if (data.isNotEmpty()) {
kotlin.runCatching {
receiver.onReceiveMessage(this@GarminSimulatorClient, device.id, iqApp.applicationId, data)
}
}
} catch (e: SocketException) {
aapsLogger.warn(LTag.GARMIN, "socket read failed ${e.message}")
break
}
}
aapsLogger.info(LTag.GARMIN, "disconnect ${device.name}" )
connections.remove(this)
}
private fun readAvailable(input: InputStream): ByteArray? {
val buffer = ByteArray(1 shl 14)
aapsLogger.info(LTag.GARMIN, "$device reading")
val len = input.read(buffer)
aapsLogger.info(LTag.GARMIN, "$device read $len bytes")
if (len < 0) {
return null
}
val data = ByteArray(len)
System.arraycopy(buffer, 0, data, 0, data.size)
return data
}
override fun dispose() {
aapsLogger.info(LTag.GARMIN, "close $device")
@Suppress("EmptyCatchBlock")
try {
socket.close()
} catch (e: SocketException) {
aapsLogger.warn(LTag.GARMIN, "closing socket failed ${e.message}")
}
}
override fun isDisposed() = socket.isClosed
}
init {
executor.execute {
runCatching(::listen).exceptionOrNull()?.let { e->
aapsLogger.error(LTag.GARMIN, "listen failed", e)
}
}
}
private fun listen() {
val ip = Inet4Address.getByAddress(byteArrayOf(127, 0, 0, 1))
aapsLogger.info(LTag.GARMIN, "bind to $ip:$port")
serverSocket.bind(InetSocketAddress(ip, port))
port = serverSocket.localPort
receiver.onConnect(this@GarminSimulatorClient)
while (!serverSocket.isClosed) {
val s = serverSocket.accept()
aapsLogger.info(LTag.GARMIN, "accept " + s.remoteSocketAddress)
connections.add(Connection(s))
connections.last().start()
}
receiver.onDisconnect(this@GarminSimulatorClient)
}
/** Wait for the server to start listing to requests. */
fun awaitReady(wait: Duration): Boolean {
var waitNanos = wait.toNanos()
readyLock.withLock {
while (!serverSocket.isBound && waitNanos > 0L) {
waitNanos = readyCond.awaitNanos(waitNanos)
}
}
return serverSocket.isBound
}
override fun dispose() {
connections.forEach { c -> c.dispose() }
connections.clear()
serverSocket.close()
executor.awaitTermination(10, TimeUnit.SECONDS)
}
override fun isDisposed() = serverSocket.isClosed
private fun getConnection(device: GarminDevice): Connection? {
return connections.firstOrNull { c -> c.device.id == device.id }
}
override fun sendMessage(app: GarminApplication, data: ByteArray) {
val c = getConnection(app.device) ?: return
try {
c.send(data)
receiver.onSendMessage(this, app.device.id, app.id, null)
} catch (e: SocketException) {
val errorMessage = "sending failed '${e.message}'"
receiver.onSendMessage(this, app.device.id, app.id, errorMessage)
c.dispose()
connections.remove(c)
}
}
override fun toString() = name
}

View file

@ -34,7 +34,7 @@ class HttpServer internal constructor(private var aapsLogger: AAPSLogger, val po
private val serverThread: Thread private val serverThread: Thread
private val workerExecutor: Executor = Executors.newCachedThreadPool() private val workerExecutor: Executor = Executors.newCachedThreadPool()
private val endpoints: MutableMap<String, (SocketAddress, URI, String?) -> CharSequence> = private val endpoints: MutableMap<String, (SocketAddress, URI, String?) -> Pair<Int, CharSequence>> =
ConcurrentHashMap() ConcurrentHashMap()
private var serverSocket: ServerSocket? = null private var serverSocket: ServerSocket? = null
private val readyLock = ReentrantLock() private val readyLock = ReentrantLock()
@ -76,7 +76,7 @@ class HttpServer internal constructor(private var aapsLogger: AAPSLogger, val po
} }
/** Register an endpoint (path) to handle requests. */ /** Register an endpoint (path) to handle requests. */
fun registerEndpoint(path: String, endpoint: (SocketAddress, URI, String?) -> CharSequence) { fun registerEndpoint(path: String, endpoint: (SocketAddress, URI, String?) -> Pair<Int, CharSequence>) {
aapsLogger.info(LTag.GARMIN, "Register: '$path'") aapsLogger.info(LTag.GARMIN, "Register: '$path'")
endpoints[path] = endpoint endpoints[path] = endpoint
} }
@ -127,8 +127,8 @@ class HttpServer internal constructor(private var aapsLogger: AAPSLogger, val po
respond(HttpURLConnection.HTTP_NOT_FOUND, out) respond(HttpURLConnection.HTTP_NOT_FOUND, out)
} else { } else {
try { try {
val body = endpoint(s.remoteSocketAddress, uri, reqBody) val (code, body) = endpoint(s.remoteSocketAddress, uri, reqBody)
respond(HttpURLConnection.HTTP_OK, body, "application/json", out) respond(code, body, "application/json", out)
} catch (e: Exception) { } catch (e: Exception) {
aapsLogger.error(LTag.GARMIN, "endpoint " + uri.path + " failed", e) aapsLogger.error(LTag.GARMIN, "endpoint " + uri.path + " failed", e)
respond(HttpURLConnection.HTTP_INTERNAL_ERROR, out) respond(HttpURLConnection.HTTP_INTERNAL_ERROR, out)

View file

@ -0,0 +1,110 @@
package app.aaps.plugins.sync.garmin
import android.content.Context
import app.aaps.shared.tests.TestBase
import org.junit.jupiter.api.AfterEach
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.kotlin.any
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.stub
import org.mockito.kotlin.verify
class GarminMessengerTest: TestBase() {
private val context = mock<Context>()
private var appId1 = "appId1"
private val appId2 = "appId2"
private val apps = mapOf(appId1 to "$appId1-name", appId2 to "$appId2-name")
private val outMessages = mutableListOf<Pair<GarminApplication, ByteArray>>()
private val inMessages = mutableListOf<Pair<GarminApplication, Any>>()
private var messenger = GarminMessenger(
aapsLogger, context, apps, { app, msg -> inMessages.add(app to msg) },
enableConnectIq = false, enableSimulator = false)
private val client1 = mock<GarminClient>() {
on { name } doReturn "Mock1"
on { sendMessage(any(), any()) } doAnswer { a ->
outMessages.add(a.getArgument<GarminApplication>(0) to a.getArgument(1))
Unit
}
}
private val client2 = mock<GarminClient>() {
on { name } doReturn "Mock2"
on { sendMessage(any(), any()) } doAnswer { a ->
outMessages.add(a.getArgument<GarminApplication>(0) to a.getArgument(1))
Unit
}
}
private val device1 = GarminDevice(client1, 11L, "dev1-name")
private val device2 = GarminDevice(client2, 12L, "dev2-name")
@BeforeEach
fun setup() {
messenger.onConnect(client1)
messenger.onConnect(client2)
client1.stub {
on { connectedDevices } doReturn listOf(device1)
}
client2.stub {
on { connectedDevices } doReturn listOf(device2)
}
}
@AfterEach
fun cleanup() {
messenger.dispose()
verify(client1).dispose()
verify(client2).dispose()
assertTrue(messenger.isDisposed)
}
@Test
fun onDisconnect() {
messenger.onDisconnect(client1)
val msg = "foo"
messenger.sendMessage(msg)
outMessages.forEach { (app, payload) ->
assertEquals(client2, app.device.client)
assertEquals(msg, GarminSerializer.deserialize(payload))
}
}
@Test
fun onReceiveMessage() {
val data = GarminSerializer.serialize("foo")
messenger.onReceiveMessage(client1, device1.id, appId1, data)
val (app, payload) = inMessages.removeAt(0)
assertEquals(appId1, app.id)
assertEquals("foo", payload)
}
@Test
fun sendMessageDevice() {
messenger.sendMessage(device1, "foo")
assertEquals(2, outMessages.size)
val msg1 = outMessages.first { (app, _) -> app.id == appId1 }.second
val msg2 = outMessages.first { (app, _) -> app.id == appId2 }.second
assertEquals("foo", GarminSerializer.deserialize(msg1))
assertEquals("foo", GarminSerializer.deserialize(msg2))
messenger.onSendMessage(client1, device1.id, appId1, null)
}
@Test
fun onSendMessageAll() {
messenger.sendMessage(listOf("foo"))
assertEquals(4, outMessages.size)
val msg11 = outMessages.first { (app, _) -> app.device == device1 && app.id == appId1 }.second
val msg12 = outMessages.first { (app, _) -> app.device == device1 && app.id == appId2 }.second
val msg21 = outMessages.first { (app, _) -> app.device == device2 && app.id == appId1 }.second
val msg22 = outMessages.first { (app, _) -> app.device == device2 && app.id == appId2 }.second
assertEquals(listOf("foo"), GarminSerializer.deserialize(msg11))
assertEquals(listOf("foo"), GarminSerializer.deserialize(msg12))
assertEquals(listOf("foo"), GarminSerializer.deserialize(msg21))
assertEquals(listOf("foo"), GarminSerializer.deserialize(msg22))
messenger.onSendMessage(client1, device1.id, appId1, null)
}
}

View file

@ -1,5 +1,6 @@
package app.aaps.plugins.sync.garmin package app.aaps.plugins.sync.garmin
import android.content.Context
import app.aaps.core.interfaces.db.GlucoseUnit import app.aaps.core.interfaces.db.GlucoseUnit
import app.aaps.core.interfaces.resources.ResourceHelper import app.aaps.core.interfaces.resources.ResourceHelper
import app.aaps.core.interfaces.rx.events.EventNewBG import app.aaps.core.interfaces.rx.events.EventNewBG
@ -11,9 +12,14 @@ import dagger.android.HasAndroidInjector
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers.anyBoolean
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.ArgumentMatchers.anyLong import org.mockito.ArgumentMatchers.anyLong
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mock import org.mockito.Mock
import org.mockito.Mockito.atMost import org.mockito.Mockito.atMost
import org.mockito.Mockito.mock import org.mockito.Mockito.mock
@ -25,9 +31,12 @@ import org.mockito.kotlin.any
import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.atLeastOnce
import org.mockito.kotlin.eq import org.mockito.kotlin.eq
import org.mockito.kotlin.whenever import org.mockito.kotlin.whenever
import java.net.ConnectException
import java.net.HttpURLConnection
import java.net.SocketAddress import java.net.SocketAddress
import java.net.URI import java.net.URI
import java.time.Clock import java.time.Clock
import java.time.Duration
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
@ -39,6 +48,7 @@ class GarminPluginTest: TestBase() {
@Mock private lateinit var rh: ResourceHelper @Mock private lateinit var rh: ResourceHelper
@Mock private lateinit var sp: SP @Mock private lateinit var sp: SP
@Mock private lateinit var context: Context
@Mock private lateinit var loopHub: LoopHub @Mock private lateinit var loopHub: LoopHub
private val clock = Clock.fixed(Instant.ofEpochMilli(10_000), ZoneId.of("UTC")) private val clock = Clock.fixed(Instant.ofEpochMilli(10_000), ZoneId.of("UTC"))
@ -49,9 +59,14 @@ class GarminPluginTest: TestBase() {
@BeforeEach @BeforeEach
fun setup() { fun setup() {
gp = GarminPlugin(injector, aapsLogger, rh, loopHub, rxBus, sp) gp = GarminPlugin(injector, aapsLogger, rh, context, loopHub, rxBus, sp)
gp.clock = clock gp.clock = clock
`when`(loopHub.currentProfileName).thenReturn("Default") `when`(loopHub.currentProfileName).thenReturn("Default")
`when`(sp.getBoolean(anyString(), anyBoolean())).thenAnswer { i -> i.arguments[1] }
`when`(sp.getString(anyString(), anyString())).thenAnswer { i -> i.arguments[1] }
`when`(sp.getInt(anyString(), anyInt())).thenAnswer { i -> i.arguments[1] }
`when`(sp.getInt(eq("communication_http_port"), anyInt()))
.thenReturn(28890)
} }
@AfterEach @AfterEach
@ -86,6 +101,17 @@ class GarminPluginTest: TestBase() {
sourceSensor = GlucoseValue.SourceSensor.RANDOM sourceSensor = GlucoseValue.SourceSensor.RANDOM
) )
@Test
fun testReceiveHeartRateMap() {
val hr = createHeartRate(80)
gp.receiveHeartRate(hr, false)
verify(loopHub).storeHeartRate(
Instant.ofEpochSecond(hr["hrStart"] as Long),
Instant.ofEpochSecond(hr["hrEnd"] as Long),
80,
hr["device"] as String)
}
@Test @Test
fun testReceiveHeartRateUri() { fun testReceiveHeartRateUri() {
val hr = createHeartRate(99) val hr = createHeartRate(99)
@ -129,6 +155,132 @@ class GarminPluginTest: TestBase() {
verify(loopHub).getGlucoseValues(from, true) verify(loopHub).getGlucoseValues(from, true)
} }
@Test
fun setupHttpServer_enabled() {
`when`(sp.getBoolean("communication_http", false)).thenReturn(true)
`when`(sp.getInt("communication_http_port", 28891)).thenReturn(28892)
gp.setupHttpServer(Duration.ofSeconds(10))
val reqUri = URI("http://127.0.0.1:28892/get")
val resp = reqUri.toURL().openConnection() as HttpURLConnection
assertEquals(200, resp.responseCode)
// Change port
`when`(sp.getInt("communication_http_port", 28891)).thenReturn(28893)
gp.setupHttpServer(Duration.ofSeconds(10))
val reqUri2 = URI("http://127.0.0.1:28893/get")
val resp2 = reqUri2.toURL().openConnection() as HttpURLConnection
assertEquals(200, resp2.responseCode)
`when`(sp.getBoolean("communication_http", false)).thenReturn(false)
gp.setupHttpServer(Duration.ofSeconds(10))
assertThrows(ConnectException::class.java) {
(reqUri2.toURL().openConnection() as HttpURLConnection).responseCode
}
gp.onStop()
verify(loopHub, times(2)).getGlucoseValues(anyObject(), eq(true))
verify(loopHub, times(2)).insulinOnboard
verify(loopHub, times(2)).temporaryBasal
verify(loopHub, times(2)).isConnected
verify(loopHub, times(2)).glucoseUnit
}
@Test
fun setupHttpServer_disabled() {
gp.setupHttpServer(Duration.ofSeconds(10))
val reqUri = URI("http://127.0.0.1:28890/get")
assertThrows(ConnectException::class.java) {
(reqUri.toURL().openConnection() as HttpURLConnection).responseCode
}
}
@Test
fun requestHandler_NoKey() {
val uri = createUri(emptyMap())
val handler = gp.requestHandler { u: URI -> assertEquals(uri, u); "OK" }
assertEquals(
HttpURLConnection.HTTP_OK to "OK",
handler(mock(SocketAddress::class.java), uri, null))
}
@Test
fun requestHandler_KeyProvided() {
val uri = createUri(mapOf("key" to "foo"))
val handler = gp.requestHandler { u: URI -> assertEquals(uri, u); "OK" }
assertEquals(
HttpURLConnection.HTTP_OK to "OK",
handler(mock(SocketAddress::class.java), uri, null))
}
@Test
fun requestHandler_KeyRequiredAndProvided() {
`when`(sp.getString("garmin_aaps_key", "")).thenReturn("foo")
val uri = createUri(mapOf("key" to "foo"))
val handler = gp.requestHandler { u: URI -> assertEquals(uri, u); "OK" }
assertEquals(
HttpURLConnection.HTTP_OK to "OK",
handler(mock(SocketAddress::class.java), uri, null))
}
@Test
fun requestHandler_KeyRequired() {
gp.garminMessenger = mock(GarminMessenger::class.java)
`when`(sp.getString("garmin_aaps_key", "")).thenReturn("foo")
val uri = createUri(emptyMap())
val handler = gp.requestHandler { u: URI -> assertEquals(uri, u); "OK" }
assertEquals(
HttpURLConnection.HTTP_UNAUTHORIZED to "{}",
handler(mock(SocketAddress::class.java), uri, null))
val captor = ArgumentCaptor.forClass(Any::class.java)
verify(gp.garminMessenger)!!.sendMessage(captor.capture() ?: "")
@Suppress("UNCHECKED_CAST")
val r = captor.value as Map<String, Any>
assertEquals("foo", r["key"])
assertEquals("glucose", r["command"])
assertEquals("D", r["profile"])
assertEquals("", r["encodedGlucose"])
assertEquals(0.0, r["remainingInsulin"])
assertEquals("mmoll", r["glucoseUnit"])
assertEquals(0.0, r["temporaryBasalRate"])
assertEquals(false, r["connected"])
assertEquals(clock.instant().epochSecond, r["timestamp"])
verify(loopHub).getGlucoseValues(getGlucoseValuesFrom, true)
verify(loopHub).insulinOnboard
verify(loopHub).temporaryBasal
verify(loopHub).isConnected
verify(loopHub).glucoseUnit
}
@Test
fun onConnectDevice() {
gp.garminMessenger = mock(GarminMessenger::class.java)
`when`(sp.getString("garmin_aaps_key", "")).thenReturn("foo")
val device = GarminDevice(mock(),1, "Edge")
gp.onConnectDevice(device)
val captor = ArgumentCaptor.forClass(Any::class.java)
verify(gp.garminMessenger)!!.sendMessage(eq(device), captor.capture() ?: "")
@Suppress("UNCHECKED_CAST")
val r = captor.value as Map<String, Any>
assertEquals("foo", r["key"])
assertEquals("glucose", r["command"])
assertEquals("D", r["profile"])
assertEquals("", r["encodedGlucose"])
assertEquals(0.0, r["remainingInsulin"])
assertEquals("mmoll", r["glucoseUnit"])
assertEquals(0.0, r["temporaryBasalRate"])
assertEquals(false, r["connected"])
assertEquals(clock.instant().epochSecond, r["timestamp"])
verify(loopHub).getGlucoseValues(getGlucoseValuesFrom, true)
verify(loopHub).insulinOnboard
verify(loopHub).temporaryBasal
verify(loopHub).isConnected
verify(loopHub).glucoseUnit
}
@Test @Test
fun testOnGetBloodGlucose() { fun testOnGetBloodGlucose() {
`when`(loopHub.isConnected).thenReturn(true) `when`(loopHub.isConnected).thenReturn(true)
@ -139,7 +291,7 @@ class GarminPluginTest: TestBase() {
listOf(createGlucoseValue(Instant.ofEpochSecond(1_000)))) listOf(createGlucoseValue(Instant.ofEpochSecond(1_000))))
val hr = createHeartRate(99) val hr = createHeartRate(99)
val uri = createUri(hr) val uri = createUri(hr)
val result = gp.onGetBloodGlucose(mock(SocketAddress::class.java), uri, null) val result = gp.onGetBloodGlucose(uri)
assertEquals( assertEquals(
"{\"encodedGlucose\":\"0A+6AQ==\"," + "{\"encodedGlucose\":\"0A+6AQ==\"," +
"\"remainingInsulin\":3.14," + "\"remainingInsulin\":3.14," +
@ -171,7 +323,7 @@ class GarminPluginTest: TestBase() {
params["wait"] = 10 params["wait"] = 10
val uri = createUri(params) val uri = createUri(params)
gp.newValue = mock(Condition::class.java) gp.newValue = mock(Condition::class.java)
val result = gp.onGetBloodGlucose(mock(SocketAddress::class.java), uri, null) val result = gp.onGetBloodGlucose(uri)
assertEquals( assertEquals(
"{\"encodedGlucose\":\"/wS6AQ==\"," + "{\"encodedGlucose\":\"/wS6AQ==\"," +
"\"remainingInsulin\":3.14," + "\"remainingInsulin\":3.14," +
@ -194,7 +346,7 @@ class GarminPluginTest: TestBase() {
@Test @Test
fun testOnPostCarbs() { fun testOnPostCarbs() {
val uri = createUri(mapOf("carbs" to "12")) val uri = createUri(mapOf("carbs" to "12"))
assertEquals("", gp.onPostCarbs(mock(SocketAddress::class.java), uri, null)) assertEquals("", gp.onPostCarbs(uri))
verify(loopHub).postCarbs(12) verify(loopHub).postCarbs(12)
} }
@ -202,7 +354,7 @@ class GarminPluginTest: TestBase() {
fun testOnConnectPump_Disconnect() { fun testOnConnectPump_Disconnect() {
val uri = createUri(mapOf("disconnectMinutes" to "20")) val uri = createUri(mapOf("disconnectMinutes" to "20"))
`when`(loopHub.isConnected).thenReturn(false) `when`(loopHub.isConnected).thenReturn(false)
assertEquals("{\"connected\":false}", gp.onConnectPump(mock(SocketAddress::class.java), uri, null)) assertEquals("{\"connected\":false}", gp.onConnectPump(uri))
verify(loopHub).disconnectPump(20) verify(loopHub).disconnectPump(20)
verify(loopHub).isConnected verify(loopHub).isConnected
} }
@ -211,7 +363,7 @@ class GarminPluginTest: TestBase() {
fun testOnConnectPump_Connect() { fun testOnConnectPump_Connect() {
val uri = createUri(mapOf("disconnectMinutes" to "0")) val uri = createUri(mapOf("disconnectMinutes" to "0"))
`when`(loopHub.isConnected).thenReturn(true) `when`(loopHub.isConnected).thenReturn(true)
assertEquals("{\"connected\":true}", gp.onConnectPump(mock(SocketAddress::class.java), uri, null)) assertEquals("{\"connected\":true}", gp.onConnectPump(uri))
verify(loopHub).connectPump() verify(loopHub).connectPump()
verify(loopHub).isConnected verify(loopHub).isConnected
} }
@ -220,7 +372,7 @@ class GarminPluginTest: TestBase() {
fun onSgv_NoGlucose() { fun onSgv_NoGlucose() {
whenever(loopHub.glucoseUnit).thenReturn(GlucoseUnit.MMOL) whenever(loopHub.glucoseUnit).thenReturn(GlucoseUnit.MMOL)
whenever(loopHub.getGlucoseValues(any(), eq(false))).thenReturn(emptyList()) whenever(loopHub.getGlucoseValues(any(), eq(false))).thenReturn(emptyList())
assertEquals("[]", gp.onSgv(mock(), createUri(mapOf()), null)) assertEquals("[]", gp.onSgv(createUri(mapOf())))
verify(loopHub).getGlucoseValues(clock.instant().minusSeconds(25L * 300L), false) verify(loopHub).getGlucoseValues(clock.instant().minusSeconds(25L * 300L), false)
} }
@ -236,7 +388,7 @@ fun onSgv_NoDelta() {
clock.instant().minusSeconds(100L), 99.3))) clock.instant().minusSeconds(100L), 99.3)))
assertEquals( assertEquals(
"""[{"_id":"-900000","device":"RANDOM","deviceString":"1969-12-31T23:58:30Z","sysTime":"1969-12-31T23:58:30Z","unfiltered":90.0,"date":-90000,"sgv":99,"direction":"Flat","noise":4.5,"units_hint":"mmol","iob":5.2,"tbr":80,"cob":10.7}]""", """[{"_id":"-900000","device":"RANDOM","deviceString":"1969-12-31T23:58:30Z","sysTime":"1969-12-31T23:58:30Z","unfiltered":90.0,"date":-90000,"sgv":99,"direction":"Flat","noise":4.5,"units_hint":"mmol","iob":5.2,"tbr":80,"cob":10.7}]""",
gp.onSgv(mock(), createUri(mapOf()), null)) gp.onSgv(createUri(mapOf())))
verify(loopHub).getGlucoseValues(clock.instant().minusSeconds(25L * 300L), false) verify(loopHub).getGlucoseValues(clock.instant().minusSeconds(25L * 300L), false)
verify(loopHub).glucoseUnit verify(loopHub).glucoseUnit
} }
@ -255,7 +407,7 @@ fun onSgv_NoDelta() {
.mapIndexed { idx, ts -> createGlucoseValue(ts, 100.0+(10 * idx)) }.reversed()} .mapIndexed { idx, ts -> createGlucoseValue(ts, 100.0+(10 * idx)) }.reversed()}
assertEquals( assertEquals(
"""[{"_id":"100000","device":"RANDOM","deviceString":"1970-01-01T00:00:10Z","sysTime":"1970-01-01T00:00:10Z","unfiltered":90.0,"date":10000,"sgv":120,"delta":10,"direction":"Flat","noise":4.5,"units_hint":"mmol","iob":5.2,"tbr":80,"cob":10.7}]""", """[{"_id":"100000","device":"RANDOM","deviceString":"1970-01-01T00:00:10Z","sysTime":"1970-01-01T00:00:10Z","unfiltered":90.0,"date":10000,"sgv":120,"delta":10,"direction":"Flat","noise":4.5,"units_hint":"mmol","iob":5.2,"tbr":80,"cob":10.7}]""",
gp.onSgv(mock(), createUri(mapOf("count" to "1")), null)) gp.onSgv(createUri(mapOf("count" to "1"))))
verify(loopHub).getGlucoseValues( verify(loopHub).getGlucoseValues(
clock.instant().minusSeconds(600L), false) clock.instant().minusSeconds(600L), false)
@ -263,14 +415,14 @@ fun onSgv_NoDelta() {
assertEquals( assertEquals(
"""[{"_id":"100000","device":"RANDOM","deviceString":"1970-01-01T00:00:10Z","sysTime":"1970-01-01T00:00:10Z","unfiltered":90.0,"date":10000,"sgv":130,"delta":10,"direction":"Flat","noise":4.5,"units_hint":"mmol","iob":5.2,"tbr":80,"cob":10.7},""" + """[{"_id":"100000","device":"RANDOM","deviceString":"1970-01-01T00:00:10Z","sysTime":"1970-01-01T00:00:10Z","unfiltered":90.0,"date":10000,"sgv":130,"delta":10,"direction":"Flat","noise":4.5,"units_hint":"mmol","iob":5.2,"tbr":80,"cob":10.7},""" +
"""{"_id":"-2900000","device":"RANDOM","deviceString":"1969-12-31T23:55:10Z","sysTime":"1969-12-31T23:55:10Z","unfiltered":90.0,"date":-290000,"sgv":120,"delta":10,"direction":"Flat","noise":4.5}]""", """{"_id":"-2900000","device":"RANDOM","deviceString":"1969-12-31T23:55:10Z","sysTime":"1969-12-31T23:55:10Z","unfiltered":90.0,"date":-290000,"sgv":120,"delta":10,"direction":"Flat","noise":4.5}]""",
gp.onSgv(mock(), createUri(mapOf("count" to "2")), null)) gp.onSgv(createUri(mapOf("count" to "2"))))
verify(loopHub).getGlucoseValues( verify(loopHub).getGlucoseValues(
clock.instant().minusSeconds(900L), false) clock.instant().minusSeconds(900L), false)
assertEquals( assertEquals(
"""[{"date":10000,"sgv":130,"delta":10,"direction":"Flat","noise":4.5,"units_hint":"mmol","iob":5.2,"tbr":80,"cob":10.7},""" + """[{"date":10000,"sgv":130,"delta":10,"direction":"Flat","noise":4.5,"units_hint":"mmol","iob":5.2,"tbr":80,"cob":10.7},""" +
"""{"date":-290000,"sgv":120,"delta":10,"direction":"Flat","noise":4.5}]""", """{"date":-290000,"sgv":120,"delta":10,"direction":"Flat","noise":4.5}]""",
gp.onSgv(mock(), createUri(mapOf("count" to "2", "brief_mode" to "true")), null)) gp.onSgv(createUri(mapOf("count" to "2", "brief_mode" to "true"))))
verify(loopHub, times(2)).getGlucoseValues( verify(loopHub, times(2)).getGlucoseValues(
clock.instant().minusSeconds(900L), false) clock.instant().minusSeconds(900L), false)

View file

@ -0,0 +1,92 @@
package app.aaps.plugins.sync.garmin
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import kotlin.test.assertContentEquals
class GarminSerializerTest {
@Test fun testSerializeDeserializeString() {
val o = "Hello, world!"
val data = GarminSerializer.serialize(o)
assertContentEquals(
byteArrayOf(
-85, -51, -85, -51, 0, 0, 0, 16, 0, 14, 72, 101, 108, 108, 111, 44, 32, 119, 111,
114, 108, 100, 33, 0, -38, 122, -38, 122, 0, 0, 0, 5, 3, 0,0, 0, 0),
data)
assertEquals(o, GarminSerializer.deserialize(data))
}
@Test fun testSerializeDeserializeInteger() {
val o = 3
val data = GarminSerializer.serialize(o)
assertContentEquals(
byteArrayOf(-38, 122, -38, 122, 0, 0, 0, 5, 1, 0, 0, 0, 3),
data)
assertEquals(o, GarminSerializer.deserialize(data))
}
@Test fun tesSerializeDeserializeArray() {
val o = listOf("a", "b", true, 3, 3.4F, listOf(5L, 9), 42)
val data = GarminSerializer.serialize(o)
assertContentEquals(
byteArrayOf(
-85, -51, -85, -51, 0, 0, 0, 8, 0, 2, 97, 0, 0, 2, 98, 0, -38, 122, -38, 122, 0, 0,
0, 55, 5, 0, 0, 0, 7, 3, 0, 0, 0, 0, 3, 0, 0, 0, 4, 9, 1, 1, 0, 0, 0, 3, 2, 64, 89,
-103, -102, 5, 0, 0, 0, 2, 1, 0, 0, 0, 42, 14, 0, 0, 0, 0, 0, 0, 0, 5, 14, 0, 0, 0,
0, 0, 0, 0, 9),
data)
assertEquals(o, GarminSerializer.deserialize(data))
}
@Test
fun testSerializeDeserializeMap() {
val o = mapOf("a" to "abc", "c" to 3, "d" to listOf(4, 9, "abc"), true to null)
val data = GarminSerializer.serialize(o)
assertContentEquals(
byteArrayOf(
-85, -51, -85, -51, 0, 0, 0, 18, 0, 2, 97, 0, 0, 4, 97, 98, 99, 0, 0, 2, 99, 0, 0,
2, 100, 0, -38, 122, -38, 122, 0, 0, 0, 53, 11, 0, 0, 0, 4, 3, 0, 0, 0, 0, 3, 0, 0,
0, 4, 3, 0, 0, 0, 10, 1, 0, 0, 0, 3, 3, 0, 0, 0, 14, 5, 0, 0, 0, 3, 9, 1, 0, 1, 0, 0,
0, 4, 1, 0, 0, 0, 9, 3, 0, 0, 0, 4),
data)
assertEquals(o, GarminSerializer.deserialize(data))
}
@Test fun testSerializeDeserializeNull() {
val o = null
val data = GarminSerializer.serialize(o)
assertContentEquals(
byteArrayOf(-38, 122, -38, 122, 0, 0, 0, 1, 0),
data)
assertEquals(o, GarminSerializer.deserialize(data))
assertEquals(o, GarminSerializer.deserialize(data))
}
@Test fun testSerializeDeserializeAllPrimitiveTypes() {
val o = listOf(1, 1.2F, 1.3, "A", true, 2L, 'X', null)
val data = GarminSerializer.serialize(o)
assertContentEquals(
byteArrayOf(
-85, -51, -85, -51, 0, 0, 0, 4, 0, 2, 65, 0, -38, 122, -38, 122, 0, 0, 0, 46, 5, 0,
0, 0, 8, 1, 0, 0, 0, 1, 2, 63, -103, -103, -102, 15, 63, -12, -52, -52, -52, -52,
-52, -51, 3, 0, 0, 0, 0, 9, 1, 14, 0, 0, 0, 0, 0, 0, 0, 2, 19, 0, 0, 0, 88, 0),
data)
assertEquals(o, GarminSerializer.deserialize(data))
assertEquals(o, GarminSerializer.deserialize(data))
}
@Test fun testSerializeDeserializeMapNested() {
val o = mapOf("a" to "abc", "c" to 3, "d" to listOf(4, 9, "abc"))
val data = GarminSerializer.serialize(o)
assertContentEquals(
byteArrayOf(
-85, -51, -85, -51, 0, 0, 0, 18, 0, 2, 97, 0, 0, 4, 97, 98, 99, 0, 0, 2, 99, 0, 0,
2, 100, 0, -38, 122, -38, 122, 0, 0, 0, 50, 11, 0, 0, 0, 3, 3, 0, 0, 0, 0, 3, 0, 0,
0, 4, 3, 0, 0, 0, 10, 1, 0, 0, 0, 3, 3, 0, 0, 0, 14, 5, 0, 0, 0, 3, 1, 0, 0, 0, 4,
1, 0, 0, 0, 9, 3, 0, 0, 0, 4),
data)
assertEquals(o, GarminSerializer.deserialize(data))
}
}

View file

@ -0,0 +1,63 @@
package app.aaps.plugins.sync.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.BeforeEach
import org.junit.jupiter.api.Test
import org.mockito.kotlin.eq
import org.mockito.kotlin.isNull
import org.mockito.kotlin.mock
import org.mockito.kotlin.timeout
import org.mockito.kotlin.verify
import java.net.Inet4Address
import java.net.Socket
import java.time.Duration
class GarminSimulatorClientTest: TestBase() {
private lateinit var client: GarminSimulatorClient
private val receiver: GarminReceiver = mock()
@BeforeEach
fun setup() {
client = GarminSimulatorClient(aapsLogger, receiver, 0)
}
@Test
fun receiveMessage() {
val payload = "foo".toByteArray()
assertTrue(client.awaitReady(Duration.ofSeconds(10)))
val port = client.port
val ip = Inet4Address.getByAddress(byteArrayOf(127, 0, 0, 1))
Socket(ip, port).use { socket ->
assertTrue(socket.isConnected)
socket.getOutputStream().write(payload)
socket.getOutputStream().flush()
verify(receiver).onConnect(client)
}
assertEquals(1, client.connectedDevices.size)
val device: GarminDevice = client.connectedDevices.first()
verify(receiver, timeout(1_000))
.onReceiveMessage(eq(client), eq(device.id), eq("SIMAPP"), eq(payload))
}
@Test
fun sendMessage() {
val payload = "foo".toByteArray()
assertTrue(client.awaitReady(Duration.ofSeconds(10)))
val port = client.port
val ip = Inet4Address.getByAddress(byteArrayOf(127, 0, 0, 1))
val device: GarminDevice
val app: GarminApplication
Socket(ip, port).use { socket ->
assertTrue(socket.isConnected)
verify(receiver).onConnect(client)
assertEquals(1, client.connectedDevices.size)
device = client.connectedDevices.first()
app = GarminApplication(device, "SIMAPP", "T")
client.sendMessage(app, payload)
}
verify(receiver, timeout(1_000)).onSendMessage(eq(client), eq(device.id), eq(app.id), isNull())
}
}

View file

@ -77,7 +77,7 @@ internal class HttpServerTest: TestBase() {
HttpServer(aapsLogger, port).use { server -> HttpServer(aapsLogger, port).use { server ->
server.registerEndpoint("/foo") { _: SocketAddress, uri: URI, _: String? -> server.registerEndpoint("/foo") { _: SocketAddress, uri: URI, _: String? ->
assertEquals(URI("/foo"), uri) assertEquals(URI("/foo"), uri)
"test" HttpURLConnection.HTTP_OK to "test"
} }
assertTrue(server.awaitReady(Duration.ofSeconds(10))) assertTrue(server.awaitReady(Duration.ofSeconds(10)))
val resp = reqUri.toURL().openConnection() as HttpURLConnection val resp = reqUri.toURL().openConnection() as HttpURLConnection