Support automatic exchange of communication key for HTTP via connectiq native comm (usually unreliable but could enough for that).

This commit is contained in:
Robert Buessow 2023-11-13 22:39:57 +01:00
parent fa78a8214e
commit 07cc0e19c7
20 changed files with 1694 additions and 34 deletions

View file

@ -9,6 +9,9 @@ plugins {
android {
namespace = "app.aaps.plugins.sync"
buildFeatures {
aidl = true
}
}
dependencies {

View file

@ -0,0 +1,30 @@
/**
* Copyright (C) 2014 Garmin International Ltd.
* Subject to Garmin SDK License Agreement and Wearables Application Developer Agreement.
*/
//IConnectIQService
package com.garmin.android.apps.connectmobile.connectiq;
import com.garmin.android.connectiq.IQDevice;
import com.garmin.android.connectiq.IQApp;
import com.garmin.android.connectiq.IQMessage;
interface IConnectIQService {
boolean openStore(String applicationID);
List<IQDevice> getConnectedDevices();
List<IQDevice> getKnownDevices();
// Remote device methods
int getStatus(in IQDevice device);
// Messages and Commands
oneway void getApplicationInfo(String notificationPackage, String notificationAction, in IQDevice device, String applicationID);
oneway void openApplication(String notificationPackage, String notificationAction, in IQDevice device, in IQApp app);
// Pending intent will be fired to let the sdk know a message has been transferred.
oneway void sendMessage(in IQMessage message, in IQDevice device, in IQApp app);
oneway void sendImage(in IQMessage image, in IQDevice device, in IQApp app);
// registers a companion app with the remote service so that it can receive messages from remote device.
oneway void registerApp(in IQApp app, String notificationAction, String notificationPackage);
}

View file

@ -0,0 +1,12 @@
/**
* Copyright (C) 2014 Garmin International Ltd.
* Subject to Garmin SDK License Agreement and Wearables Application Developer Agreement.
*/
package com.garmin.android.connectiq;
parcelable IQApp {
String applicationID;
int status;
String displayName;
int version;
}

View file

@ -0,0 +1,11 @@
/**
* Copyright (C) 2014 Garmin International Ltd.
* Subject to Garmin SDK License Agreement and Wearables Application Developer Agreement.
*/
package com.garmin.android.connectiq;
parcelable IQDevice {
long deviceIdentifier;
String friendlyName;
int status;
}

View file

@ -0,0 +1,20 @@
/**
* Copyright (C) 2014 Garmin International Ltd.
* Subject to Garmin SDK License Agreement and Wearables Application Developer Agreement.
*/
package com.garmin.android.connectiq;
parcelable IQMessage {
const int SUCCESS = 0;
const int FAILURE_UNKNOWN = 1;
const int FAILURE_INVALID_FORMAT = 2;
const int FAILURE_MESSAGE_TOO_LARGE = 3;
const int FAILURE_UNSUPPORTED_TYPE = 4;
const int FAILURE_DURING_TRANSFER = 5;
const int FAILURE_INVALID_DEVICE = 6;
const int FAILURE_DEVICE_NOT_CONNECTED = 7;
byte[] messageData;
String notificationPackage;
String notificationAction;
}

View file

@ -0,0 +1,39 @@
package app.aaps.plugins.sync.garmin
data class GarminApplication(
val client: GarminClient,
val device: GarminDevice,
val id: String,
val name: String?) {
enum class Status {
@Suppress("UNUSED")
UNKNOWN,
INSTALLED,
@Suppress("UNUSED")
NOT_INSTALLED,
@Suppress("UNUSED")
NOT_SUPPORTED;
}
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
}
}

View file

@ -0,0 +1,14 @@
package app.aaps.plugins.sync.garmin
import io.reactivex.rxjava3.disposables.Disposable
interface GarminClient: Disposable {
/** Name of the client. */
val name: String
/** Asynchronously retrieves status information for the given application. */
fun retrieveApplicationInfo(device: GarminDevice, appId: String, appName: String)
/** Asynchronously sends a message to an application. */
fun sendMessage(app: GarminApplication, data: ByteArray)
}

View file

@ -0,0 +1,54 @@
package app.aaps.plugins.sync.garmin
import com.garmin.android.connectiq.IQDevice
data class GarminDevice(
val client: GarminClient,
val id: Long,
var name: String,
var status: Status = Status.UNKNOWN) {
constructor(client: GarminClient, iqDevice: IQDevice): this(
client,
iqDevice.deviceIdentifier,
iqDevice.friendlyName,
Status.from(iqDevice.status)) {}
enum class Status {
NOT_PAIRED,
NOT_CONNECTED,
CONNECTED,
UNKNOWN;
companion object {
fun from(ordinal: Int?): Status =
values().firstOrNull { s -> s.ordinal == ordinal } ?: UNKNOWN
}
}
override fun toString(): String = "D[$name/$id]"
fun toIQDevice() = IQDevice().apply {
deviceIdentifier = id
friendlyName = name
status = Status.UNKNOWN.ordinal }
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,286 @@
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.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.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.time.Duration
import java.time.Instant
import java.util.LinkedList
import java.util.Locale
import java.util.Queue
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 bindLock = Object()
private var ciqService: IConnectIQService? = null
get() {
val waitUntil = Instant.now().plusSeconds(2)
synchronized (bindLock) {
while(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
context.bindService(serviceIntent, ciqServiceConnection, Context.BIND_AUTO_CREATE)
}
// Wait for the connection, that is the call to onServiceConnected.
val wait = Duration.between(Instant.now(), waitUntil)
if (wait > Duration.ZERO) bindLock.waitMillis(wait.toMillis())
if (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 null
}
}
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)
ciq.connectedDevices?.forEach { d ->
receiver.onConnectDevice(this@GarminDeviceClient, d.deviceIdentifier, d.friendlyName) }
}
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
context.bindService(serviceIntent, ciqServiceConnection, Context.BIND_AUTO_CREATE)
}
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 retrieveApplicationInfo(device: GarminDevice, appId: String, appName: String) {
val action = createAction("APPLICATION_INFO_${device.id}_$appId")
synchronized (registeredActions) {
if (!registeredActions.contains(action)) {
registerReceiver(action) { intent -> onApplicationInfo(appId, device, intent) }
}
registeredActions.add(action)
}
ciqService?.getApplicationInfo(context.packageName, action, device.toIQDevice(), appId)
}
/** Receives application info callbacks from ConnectIQ app.*/
private fun onApplicationInfo(appId: String, device: GarminDevice, intent: Intent) {
val receivedAppId = intent.getStringExtra(EXTRA_APPLICATION_ID)?.lowercase(Locale.getDefault())
val version = intent.getIntExtra(EXTRA_APPLICATION_VERSION, -1)
val isInstalled = receivedAppId != null && version >= 0 && version != 65535
if (isInstalled) registerForMessages(device.id, appId)
receiver.onApplicationInfo(device, appId, isInstalled)
}
private fun registerForMessages(deviceId: Long, appId: String) {
aapsLogger.info(LTag.GARMIN, "registerForMessage $name $appId")
val action = createAction("ON_MESSAGE_${deviceId}_$appId")
val app = IQApp().apply { applicationID = appId; displayName = "" }
synchronized (registeredActions) {
if (!registeredActions.contains(action)) {
registerReceiver(action) { intent: Intent -> onReceiveMessage(app, intent) }
ciqService?.registerApp(app, 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 status = intent.getIntExtra(EXTRA_STATUS, 0)
val deviceId = getDevice(intent)
val appId = intent.getStringExtra(EXTRA_APPLICATION_ID)?.lowercase()
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
}
when (status) {
IQMessage.FAILURE_DEVICE_NOT_CONNECTED,
IQMessage.FAILURE_DURING_TRANSFER -> {
if (msg.attempt < MAX_RETRIES) {
val delaySec = retryWaitFactor * msg.attempt
Schedulers.io().scheduleDirect({ retryMessage(deviceId, appId) }, delaySec, TimeUnit.SECONDS)
return
}
}
else -> {}
}
queue.remove(msg)
val errorMessage = status
.takeUnless { it == IQMessage.SUCCESS }?.let { s -> "error $s" }
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
var lastAttempt: Instant? = null
val iqApp get() = IQApp().apply { applicationID = app.id; displayName = app.name }
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 queue = messageQueues.getOrPut(app.device.id to app.id) { LinkedList() }
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().apply {
messageData = msg.data
notificationPackage = context.packageName
notificationAction = 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_APPLICATION_VERSION = "com.garmin.android.connectiq.EXTRA_APPLICATION_VERSION"
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,159 @@
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
import org.jetbrains.annotations.VisibleForTesting
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>()
@VisibleForTesting
val liveApplications = mutableSetOf<GarminApplication>()
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["SimAp"] = "SimulatorApp"
GarminSimulatorClient(aapsLogger, this)
}
}
private fun getDevice(client: GarminClient, deviceId: Long): GarminDevice {
synchronized (devices) {
return devices.getOrPut(deviceId) { GarminDevice(client, deviceId, "unknown") }
}
}
private fun getApplication(client: GarminClient, deviceId: Long, appId: String): GarminApplication {
synchronized (liveApplications) {
var app = liveApplications.firstOrNull { app ->
app.client == client && app.device.id == deviceId && app.id == appId }
if (app == null) {
app = GarminApplication(client, getDevice(client, deviceId), appId, appIdNames[appId])
liveApplications.add(app)
}
return app
}
}
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 (liveApplications) {
liveApplications.removeIf { app -> app.client == client }
}
client.dispose()
when (client.name) {
"Device" -> startDeviceClient()
"Sim"-> GarminSimulatorClient(aapsLogger, this)
else -> aapsLogger.warn(LTag.GARMIN, "onDisconnect unknown client $client")
}
}
/** Receives notifications that a device has connected.
*
* It will retrieve status information for all applications we care about (in [appIdNames]). */
override fun onConnectDevice(client: GarminClient, deviceId: Long, deviceName: String) {
val device = getDevice(client, deviceId).apply { name = deviceName }
aapsLogger.info(LTag.GARMIN, "onConnectDevice $device")
appIdNames.forEach { (id, name) -> client.retrieveApplicationInfo(device, id, name) }
}
/** Receives notifications about disconnection of a device. */
override fun onDisconnectDevice(client: GarminClient, deviceId: Long) {
val device = getDevice(client, deviceId)
aapsLogger.info(LTag.GARMIN,"onDisconnectDevice $device")
synchronized (liveApplications) {
liveApplications.removeIf { app -> app.device == device }
}
}
/** Receives notification about applications that are installed/uninstalled
* on a device from the client. */
override fun onApplicationInfo(device: GarminDevice, appId: String, isInstalled: Boolean) {
val app = getApplication(device.client, device.id, appId)
aapsLogger.info(LTag.GARMIN, "onApplicationInfo add $app ${if (isInstalled) "" else "un"}installed")
if (!isInstalled) {
synchronized (liveApplications) { liveApplications.remove(app) }
}
}
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) {
liveApplications
.filter { a -> a.device.id == device.id }
.forEach { a -> sendMessage(a, msg) }
}
/** Sends a message to all applications on all devices. */
fun sendMessage(msg: Any) {
liveApplications.forEach { app -> sendMessage(app, 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 $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
import android.content.Context
import androidx.annotation.VisibleForTesting
import app.aaps.core.interfaces.db.GlucoseUnit
import app.aaps.core.interfaces.logging.AAPSLogger
@ -18,6 +19,7 @@ import com.google.gson.JsonObject
import dagger.android.HasAndroidInjector
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.schedulers.Schedulers
import java.net.HttpURLConnection
import java.net.SocketAddress
import java.net.URI
import java.time.Clock
@ -42,6 +44,7 @@ class GarminPlugin @Inject constructor(
injector: HasAndroidInjector,
aapsLogger: AAPSLogger,
resourceHelper: ResourceHelper,
private val context: Context,
private val loopHub: LoopHub,
private val rxBus: RxBus,
private val sp: SP,
@ -57,7 +60,19 @@ class GarminPlugin @Inject constructor(
) {
/** HTTP Server for local HTTP server communication (device app requests values) .*/
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()
@VisibleForTesting
@ -68,10 +83,25 @@ class GarminPlugin @Inject constructor(
var newValue: Condition = valueLock.newCondition()
private var lastGlucoseValueTimestamp: Long? = null
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) {
aapsLogger.info(LTag.GARMIN, "preferences change ${event.changedKey}")
setupHttpServer()
when (event.changedKey) {
"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() {
@ -83,19 +113,26 @@ class GarminPlugin @Inject constructor(
.observeOn(Schedulers.io())
.subscribe(::onPreferenceChange)
)
disposable.add(
rxBus
.toObservable(EventNewBG::class.java)
.observeOn(Schedulers.io())
.subscribe(::onNewBloodGlucose)
)
setupHttpServer()
setupGarminMessenger()
}
private fun setupHttpServer() {
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)
registerEndpoint("/carbs", ::onPostCarbs)
registerEndpoint("/connect", ::onConnectPump)
registerEndpoint("/get", requestHandler(::onGetBloodGlucose))
registerEndpoint("/carbs", requestHandler(::onPostCarbs))
registerEndpoint("/connect", requestHandler(::onConnectPump))
}
} else if (server != null) {
aapsLogger.info(LTag.GARMIN, "stopping HTTP server")
@ -104,7 +141,7 @@ class GarminPlugin @Inject constructor(
}
}
override fun onStop() {
public override fun onStop() {
disposable.clear()
aapsLogger.info(LTag.GARMIN, "Stop")
server?.close()
@ -128,6 +165,34 @@ class GarminPlugin @Inject constructor(
}
}
@VisibleForTesting
fun onConnectDevice(device: GarminDevice) {
aapsLogger.info(LTag.GARMIN, "onConnectDevice $device sending glucose")
if (garminAapsKey.isNotEmpty()) 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. */
@VisibleForTesting
fun getGlucoseValues(): List<GlucoseValue> {
@ -161,21 +226,33 @@ class GarminPlugin @Inject constructor(
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()
}
@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.
*
* 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")
fun onGetBloodGlucose(uri: URI): CharSequence {
receiveHeartRate(uri)
val profileName = loopHub.currentProfileName
val waitSec = getQueryParameter(uri, "wait", 0L)
@ -189,9 +266,7 @@ class GarminPlugin @Inject constructor(
}
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")
}
return jo.toString()
}
private fun getQueryParameter(uri: URI, name: String) = (uri.query ?: "")
@ -223,6 +298,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
fun receiveHeartRate(uri: URI) {
val avg: Int = getQueryParameter(uri, "hr", 0L).toInt()
@ -237,7 +325,7 @@ class GarminPlugin @Inject constructor(
private fun receiveHeartRate(
samplingStart: Instant, samplingEnd: Instant,
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 (avg > 10 && samplingStart > Instant.ofEpochMilli(0L) && samplingEnd > samplingStart) {
loopHub.storeHeartRate(samplingStart, samplingEnd, avg, device)
@ -248,9 +336,7 @@ class GarminPlugin @Inject constructor(
/** Handles carb notification from the device. */
@VisibleForTesting
@Suppress("UNUSED_PARAMETER")
fun onPostCarbs(caller: SocketAddress, uri: URI, requestBody: String?): CharSequence {
aapsLogger.info(LTag.GARMIN, "carbs from $caller, req: $uri")
fun onPostCarbs(uri: URI): CharSequence {
postCarbs(getQueryParameter(uri, "carbs", 0L).toInt())
return ""
}
@ -263,9 +349,7 @@ class GarminPlugin @Inject constructor(
/** Handles pump connected notification that the user entered on the Garmin device. */
@VisibleForTesting
@Suppress("UNUSED_PARAMETER")
fun onConnectPump(caller: SocketAddress, uri: URI, requestBody: String?): CharSequence {
aapsLogger.info(LTag.GARMIN, "connect from $caller, req: $uri")
fun onConnectPump(uri: URI): CharSequence {
val minutes = getQueryParameter(uri, "disconnectMinutes", 0L).toInt()
if (minutes > 0) {
loopHub.disconnectPump(minutes)

View file

@ -0,0 +1,36 @@
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)
/**
* Notifies that a device is connected. This will be called for all connected devices
* initially.
*/
fun onConnectDevice(client: GarminClient, deviceId: Long, deviceName: String)
fun onDisconnectDevice(client: GarminClient, deviceId: Long)
/**
* Provides application info after a call to
* {@link ConnectIqClient#retrieveApplicationInfo retrieveApplicationInfo}.
*/
fun onApplicationInfo(device: GarminDevice, appId: String, isInstalled: Boolean)
/**
* 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,186 @@
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 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().apply {
applicationID = "SimApp"
status = GarminApplication.Status.INSTALLED.ordinal
displayName = "Simulator"
version = 1 }
private val readyLock = ReentrantLock()
private val readyCond = readyLock.newCondition()
private inner class Connection(private val socket: Socket): Disposable {
val device = GarminDevice(
this@GarminSimulatorClient,
nextDeviceId.getAndAdd(1L),
"Sim@${socket.remoteSocketAddress}",
GarminDevice.Status.CONNECTED)
fun start() {
executor.execute {
try {
receiver.onConnectDevice(this@GarminSimulatorClient, device.id, device.name)
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}")
}
}
aapsLogger.info(LTag.GARMIN, "disconnect ${device.name}" )
connections.remove(this)
receiver.onDisconnectDevice(this@GarminSimulatorClient, device.id)
}
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
override fun retrieveApplicationInfo(device: GarminDevice, appId: String, appName: String) {
receiver.onApplicationInfo(device, appId, true)
}
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 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()
private var serverSocket: ServerSocket? = null
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. */
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'")
endpoints[path] = endpoint
}
@ -127,8 +127,8 @@ class HttpServer internal constructor(private var aapsLogger: AAPSLogger, val po
respond(HttpURLConnection.HTTP_NOT_FOUND, out)
} else {
try {
val body = endpoint(s.remoteSocketAddress, uri, reqBody)
respond(HttpURLConnection.HTTP_OK, body, "application/json", out)
val (code, body) = endpoint(s.remoteSocketAddress, uri, reqBody)
respond(code, body, "application/json", out)
} catch (e: Exception) {
aapsLogger.error(LTag.GARMIN, "endpoint " + uri.path + " failed", e)
respond(HttpURLConnection.HTTP_INTERNAL_ERROR, out)

View file

@ -0,0 +1,142 @@
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.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import java.util.LinkedList
import java.util.Queue
class GarminMessengerTest: TestBase() {
private val context = mock<Context>()
private val client1 = mock<GarminClient>() {
on { name } doReturn "Mock1"
}
private val client2 = mock<GarminClient>() {
on { name } doReturn "Mock2"
}
private var appId1 = "appId1"
private val appId2 = "appId2"
private val apps = mapOf(appId1 to "$appId1-name", appId2 to "$appId2-name")
private val msgs: Queue<Pair<GarminApplication, Any>> = LinkedList()
private var messenger = GarminMessenger(
aapsLogger, context, apps, { app, msg -> msgs.add(app to msg) }, false, false)
private val deviceId = 11L
private val deviceName = "$deviceId-name"
private val device = GarminDevice(client1, deviceId, deviceName)
private val device2 = GarminDevice(client2, 12L, "dev2-name")
@BeforeEach
fun setup() {
messenger.onConnect(client1)
messenger.onConnect(client2)
}
@AfterEach
fun cleanup() {
messenger.dispose()
assertTrue(messenger.isDisposed)
}
@Test
fun onConnectDevice() {
messenger.onConnectDevice(client1, deviceId, deviceName)
verify(client1).retrieveApplicationInfo(device, appId1, apps[appId1]!!)
verify(client1).retrieveApplicationInfo(device, appId2, apps[appId2]!!)
}
@Test
fun onApplicationInfo() {
messenger.onApplicationInfo(device, appId1, true)
val app = messenger.liveApplications.first()
assertEquals(device, app.device)
assertEquals(appId1, app.id)
assertEquals(apps[appId1], app.name)
messenger.onApplicationInfo(device, appId1, false)
assertEquals(0, messenger.liveApplications.size)
}
@Test
fun onDisconnectDevice() {
messenger.onConnectDevice(client1, deviceId, deviceName)
messenger.onApplicationInfo(device, appId1, true)
messenger.onApplicationInfo(device2, appId1, true)
assertEquals(2, messenger.liveApplications.size)
messenger.onDisconnectDevice(client1, device2.id)
assertEquals(1, messenger.liveApplications.size)
assertEquals(appId1, messenger.liveApplications.first().id)
}
@Test
fun onDisconnect() {
messenger.onApplicationInfo(device, appId1, true)
messenger.onApplicationInfo(device2, appId2, true)
assertEquals(2, messenger.liveApplications.size)
messenger.onDisconnect(client1)
assertEquals(1, messenger.liveApplications.size)
val app = messenger.liveApplications.first()
assertEquals(device2, app.device)
assertEquals(appId2, app.id)
assertEquals(apps[appId2], app.name)
}
@Test
fun onReceiveMessage() {
val data = GarminSerializer.serialize("foo")
messenger.onReceiveMessage(client1, device.id, appId1, data)
val (app, payload) = msgs.remove()
assertEquals(appId1, app.id)
assertEquals("foo", payload)
}
@Test
fun sendMessageDevice() {
messenger.onApplicationInfo(device, appId1, true)
messenger.onApplicationInfo(device, appId2, true)
val msgs = mutableListOf<Pair<GarminApplication, ByteArray>>()
whenever(client1.sendMessage(any(), any())).thenAnswer { i ->
msgs.add(i.getArgument<GarminApplication>(0) to i.getArgument(1))
}
messenger.sendMessage(device, "foo")
assertEquals(2, msgs.size)
val msg1 = msgs.first { (app, _) -> app.id == appId1 }.second
val msg2 = msgs.first { (app, _) -> app.id == appId2 }.second
assertEquals("foo", GarminSerializer.deserialize(msg1))
assertEquals("foo", GarminSerializer.deserialize(msg2))
messenger.onSendMessage(client1, device.id, appId1, null)
}
@Test
fun onSendMessageAll() {
messenger.onApplicationInfo(device, appId1, true)
messenger.onApplicationInfo(device2, appId2, true)
assertEquals(2, messenger.liveApplications.size)
val msgs = mutableListOf<Pair<GarminApplication, ByteArray>>()
whenever(client1.sendMessage(any(), any())).thenAnswer { i ->
msgs.add(i.getArgument<GarminApplication>(0) to i.getArgument(1))
}
whenever(client2.sendMessage(any(), any())).thenAnswer { i ->
msgs.add(i.getArgument<GarminApplication>(0) to i.getArgument(1))
}
messenger.sendMessage(listOf("foo"))
assertEquals(2, msgs.size)
val msg1 = msgs.first { (app, _) -> app.id == appId1 }.second
val msg2 = msgs.first { (app, _) -> app.id == appId2 }.second
assertEquals(listOf("foo"), GarminSerializer.deserialize(msg1))
assertEquals(listOf("foo"), GarminSerializer.deserialize(msg2))
messenger.onSendMessage(client1, device.id, appId1, null)
}
}

View file

@ -1,5 +1,6 @@
package app.aaps.plugins.sync.garmin
import android.content.Context
import app.aaps.core.interfaces.db.GlucoseUnit
import app.aaps.core.interfaces.resources.ResourceHelper
import app.aaps.core.interfaces.rx.events.EventNewBG
@ -11,9 +12,15 @@ import dagger.android.HasAndroidInjector
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.BeforeEach
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.anyString
import org.mockito.ArgumentMatchers.eq
import org.mockito.Mock
import org.mockito.Mockito.atMost
import org.mockito.Mockito.mock
@ -21,6 +28,8 @@ import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoMoreInteractions
import org.mockito.Mockito.`when`
import java.net.ConnectException
import java.net.HttpURLConnection
import java.net.SocketAddress
import java.net.URI
import java.time.Clock
@ -34,6 +43,7 @@ class GarminPluginTest: TestBase() {
@Mock private lateinit var rh: ResourceHelper
@Mock private lateinit var sp: SP
@Mock private lateinit var context: Context
@Mock private lateinit var loopHub: LoopHub
private val clock = Clock.fixed(Instant.ofEpochMilli(10_000), ZoneId.of("UTC"))
@ -44,9 +54,14 @@ class GarminPluginTest: TestBase() {
@BeforeEach
fun setup() {
gp = GarminPlugin(injector, aapsLogger, rh, loopHub, rxBus, sp)
gp = GarminPlugin(injector, aapsLogger, rh, context, loopHub, rxBus, sp)
gp.clock = clock
`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
@ -76,6 +91,17 @@ class GarminPluginTest: TestBase() {
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
fun testReceiveHeartRateUri() {
val hr = createHeartRate(99)
@ -119,6 +145,132 @@ class GarminPluginTest: TestBase() {
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()
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()
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()
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()
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) ?: 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
fun testOnGetBloodGlucose() {
`when`(loopHub.isConnected).thenReturn(true)
@ -129,7 +281,7 @@ class GarminPluginTest: TestBase() {
listOf(createGlucoseValue(Instant.ofEpochSecond(1_000))))
val hr = createHeartRate(99)
val uri = createUri(hr)
val result = gp.onGetBloodGlucose(mock(SocketAddress::class.java), uri, null)
val result = gp.onGetBloodGlucose(uri)
assertEquals(
"{\"encodedGlucose\":\"0A+6AQ==\"," +
"\"remainingInsulin\":3.14," +
@ -161,7 +313,7 @@ class GarminPluginTest: TestBase() {
params["wait"] = 10
val uri = createUri(params)
gp.newValue = mock(Condition::class.java)
val result = gp.onGetBloodGlucose(mock(SocketAddress::class.java), uri, null)
val result = gp.onGetBloodGlucose(uri)
assertEquals(
"{\"encodedGlucose\":\"/wS6AQ==\"," +
"\"remainingInsulin\":3.14," +
@ -184,7 +336,7 @@ class GarminPluginTest: TestBase() {
@Test
fun testOnPostCarbs() {
val uri = createUri(mapOf("carbs" to "12"))
assertEquals("", gp.onPostCarbs(mock(SocketAddress::class.java), uri, null))
assertEquals("", gp.onPostCarbs(uri))
verify(loopHub).postCarbs(12)
}
@ -192,7 +344,7 @@ class GarminPluginTest: TestBase() {
fun testOnConnectPump_Disconnect() {
val uri = createUri(mapOf("disconnectMinutes" to "20"))
`when`(loopHub.isConnected).thenReturn(false)
assertEquals("{\"connected\":false}", gp.onConnectPump(mock(SocketAddress::class.java), uri, null))
assertEquals("{\"connected\":false}", gp.onConnectPump(uri))
verify(loopHub).disconnectPump(20)
verify(loopHub).isConnected
}
@ -201,7 +353,7 @@ class GarminPluginTest: TestBase() {
fun testOnConnectPump_Connect() {
val uri = createUri(mapOf("disconnectMinutes" to "0"))
`when`(loopHub.isConnected).thenReturn(true)
assertEquals("{\"connected\":true}", gp.onConnectPump(mock(SocketAddress::class.java), uri, null))
assertEquals("{\"connected\":true}", gp.onConnectPump(uri))
verify(loopHub).connectPump()
verify(loopHub).isConnected
}

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,86 @@
package app.aaps.plugins.sync.garmin
import app.aaps.shared.tests.TestBase
import org.junit.jupiter.api.Assertions.assertNotNull
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.argThat
import org.mockito.kotlin.doAnswer
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 var device: GarminDevice? = null
private var app: GarminApplication? = null
private lateinit var client: GarminSimulatorClient
private val receiver: GarminReceiver = mock() {
on { onConnectDevice(any(), any(), any()) }.doAnswer { i ->
device = GarminDevice(client, i.getArgument(1), i.getArgument(2))
app = GarminApplication(
client, device!!, client.iqApp.applicationID, client.iqApp.displayName)
}
}
@BeforeEach
fun setup() {
client = GarminSimulatorClient(aapsLogger, receiver, 0)
}
@Test
fun retrieveApplicationInfo() {
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)
verify(receiver).onConnect(client)
verify(receiver, timeout(1_000)).onConnectDevice(eq(client), any(), any())
client.retrieveApplicationInfo(app!!.device, app!!.id, app!!.name!!)
}
verify(receiver).onApplicationInfo(app!!.device, app!!.id, true)
}
@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)
verify(receiver, timeout(1_000)).onConnectDevice(eq(client), any(), any())
}
verify(receiver).onReceiveMessage(
eq(client), eq(device!!.id), eq("SimApp"),
argThat { p -> payload.contentEquals(p) })
}
@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))
Socket(ip, port).use { socket ->
assertTrue(socket.isConnected)
verify(receiver).onConnect(client)
verify(receiver, timeout(1_000)).onConnectDevice(eq(client), any(), any())
assertNotNull(device)
assertNotNull(app)
client.sendMessage(app!!, payload)
}
verify(receiver).onSendMessage(eq(client), any(), eq(app!!.id), isNull())
}
}

View file

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