Support automatic exchange of communication key for HTTP via connectiq native comm (usually unreliable but could enough for that).
This commit is contained in:
parent
fa78a8214e
commit
07cc0e19c7
20 changed files with 1694 additions and 34 deletions
|
@ -9,6 +9,9 @@ plugins {
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "app.aaps.plugins.sync"
|
namespace = "app.aaps.plugins.sync"
|
||||||
|
buildFeatures {
|
||||||
|
aidl = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
@ -18,6 +19,7 @@ import com.google.gson.JsonObject
|
||||||
import dagger.android.HasAndroidInjector
|
import dagger.android.HasAndroidInjector
|
||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
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
|
||||||
|
@ -42,6 +44,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,
|
||||||
|
@ -57,7 +60,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
|
||||||
|
@ -68,10 +83,25 @@ 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}")
|
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() {
|
override fun onStart() {
|
||||||
|
@ -83,19 +113,26 @@ 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()
|
||||||
|
setupGarminMessenger()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupHttpServer() {
|
fun setupHttpServer() {
|
||||||
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))
|
||||||
}
|
}
|
||||||
} else if (server != null) {
|
} else if (server != null) {
|
||||||
aapsLogger.info(LTag.GARMIN, "stopping HTTP server")
|
aapsLogger.info(LTag.GARMIN, "stopping HTTP server")
|
||||||
|
@ -104,7 +141,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()
|
||||||
|
@ -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. */
|
/** Gets the last 2+ hours of glucose values. */
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
fun getGlucoseValues(): List<GlucoseValue> {
|
fun getGlucoseValues(): List<GlucoseValue> {
|
||||||
|
@ -161,21 +226,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)
|
||||||
|
@ -189,9 +266,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 ?: "")
|
||||||
|
@ -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
|
@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()
|
||||||
|
@ -237,7 +325,7 @@ 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)
|
||||||
|
@ -248,9 +336,7 @@ class GarminPlugin @Inject constructor(
|
||||||
|
|
||||||
/** 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 ""
|
||||||
}
|
}
|
||||||
|
@ -263,9 +349,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)
|
||||||
|
|
|
@ -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?)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,15 @@ 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.ArgumentMatchers.eq
|
||||||
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
|
||||||
|
@ -21,6 +28,8 @@ import org.mockito.Mockito.times
|
||||||
import org.mockito.Mockito.verify
|
import org.mockito.Mockito.verify
|
||||||
import org.mockito.Mockito.verifyNoMoreInteractions
|
import org.mockito.Mockito.verifyNoMoreInteractions
|
||||||
import org.mockito.Mockito.`when`
|
import org.mockito.Mockito.`when`
|
||||||
|
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
|
||||||
|
@ -34,6 +43,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"))
|
||||||
|
|
||||||
|
@ -44,9 +54,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
|
||||||
|
@ -76,6 +91,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)
|
||||||
|
@ -119,6 +145,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()
|
||||||
|
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
|
@Test
|
||||||
fun testOnGetBloodGlucose() {
|
fun testOnGetBloodGlucose() {
|
||||||
`when`(loopHub.isConnected).thenReturn(true)
|
`when`(loopHub.isConnected).thenReturn(true)
|
||||||
|
@ -129,7 +281,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," +
|
||||||
|
@ -161,7 +313,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," +
|
||||||
|
@ -184,7 +336,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -192,7 +344,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
|
||||||
}
|
}
|
||||||
|
@ -201,7 +353,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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue