Merge pull request #2167 from dv1/comboctl-dev

Combo v2 : new Combo driver, written from scratch in Kotlin, does not need or use Ruffy
This commit is contained in:
Milos Kozak 2022-11-22 22:06:29 +01:00 committed by GitHub
commit e0ea0ca56d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
91 changed files with 38849 additions and 2 deletions

View file

@ -1,4 +1,4 @@
package info.nightscout.shared.impl.sharedPreferences package info.nightscout.shared.sharedPreferences
import info.nightscout.shared.sharedPreferences.SP import info.nightscout.shared.sharedPreferences.SP
import kotlin.properties.ReadWriteProperty import kotlin.properties.ReadWriteProperty

View file

@ -203,6 +203,7 @@ dependencies {
implementation project(':database:entities') implementation project(':database:entities')
implementation project(':database:impl') implementation project(':database:impl')
implementation project(':pump:combo') implementation project(':pump:combo')
implementation project(':pump:combov2')
implementation project(':pump:dana') implementation project(':pump:dana')
implementation project(':pump:danars') implementation project(':pump:danars')
implementation project(':pump:danar') implementation project(':pump:danar')

View file

@ -27,6 +27,7 @@ import info.nightscout.androidaps.plugins.configBuilder.PluginStore
import info.nightscout.androidaps.plugins.general.maintenance.MaintenancePlugin import info.nightscout.androidaps.plugins.general.maintenance.MaintenancePlugin
import info.nightscout.androidaps.plugins.general.wear.WearPlugin import info.nightscout.androidaps.plugins.general.wear.WearPlugin
import info.nightscout.androidaps.plugins.pump.combo.ComboPlugin import info.nightscout.androidaps.plugins.pump.combo.ComboPlugin
import info.nightscout.androidaps.plugins.pump.combov2.ComboV2Plugin
import info.nightscout.androidaps.plugins.pump.eopatch.EopatchPumpPlugin import info.nightscout.androidaps.plugins.pump.eopatch.EopatchPumpPlugin
import info.nightscout.androidaps.plugins.pump.insight.LocalInsightPlugin import info.nightscout.androidaps.plugins.pump.insight.LocalInsightPlugin
import info.nightscout.androidaps.plugins.pump.medtronic.MedtronicPumpPlugin import info.nightscout.androidaps.plugins.pump.medtronic.MedtronicPumpPlugin
@ -89,6 +90,7 @@ class MyPreferenceFragment : PreferenceFragmentCompat(), OnSharedPreferenceChang
@Inject lateinit var danaRv2Plugin: DanaRv2Plugin @Inject lateinit var danaRv2Plugin: DanaRv2Plugin
@Inject lateinit var danaRSPlugin: DanaRSPlugin @Inject lateinit var danaRSPlugin: DanaRSPlugin
@Inject lateinit var comboPlugin: ComboPlugin @Inject lateinit var comboPlugin: ComboPlugin
@Inject lateinit var combov2Plugin: ComboV2Plugin
@Inject lateinit var insulinOrefFreePeakPlugin: InsulinOrefFreePeakPlugin @Inject lateinit var insulinOrefFreePeakPlugin: InsulinOrefFreePeakPlugin
@Inject lateinit var loopPlugin: LoopPlugin @Inject lateinit var loopPlugin: LoopPlugin
@Inject lateinit var localInsightPlugin: LocalInsightPlugin @Inject lateinit var localInsightPlugin: LocalInsightPlugin
@ -203,6 +205,7 @@ class MyPreferenceFragment : PreferenceFragmentCompat(), OnSharedPreferenceChang
addPreferencesFromResourceIfEnabled(danaRSPlugin, rootKey, config.PUMPDRIVERS) addPreferencesFromResourceIfEnabled(danaRSPlugin, rootKey, config.PUMPDRIVERS)
addPreferencesFromResourceIfEnabled(localInsightPlugin, rootKey, config.PUMPDRIVERS) addPreferencesFromResourceIfEnabled(localInsightPlugin, rootKey, config.PUMPDRIVERS)
addPreferencesFromResourceIfEnabled(comboPlugin, rootKey, config.PUMPDRIVERS) addPreferencesFromResourceIfEnabled(comboPlugin, rootKey, config.PUMPDRIVERS)
addPreferencesFromResourceIfEnabled(combov2Plugin, rootKey, config.PUMPDRIVERS)
addPreferencesFromResourceIfEnabled(medtronicPumpPlugin, rootKey, config.PUMPDRIVERS) addPreferencesFromResourceIfEnabled(medtronicPumpPlugin, rootKey, config.PUMPDRIVERS)
addPreferencesFromResourceIfEnabled(diaconnG8Plugin, rootKey, config.PUMPDRIVERS) addPreferencesFromResourceIfEnabled(diaconnG8Plugin, rootKey, config.PUMPDRIVERS)
addPreferencesFromResourceIfEnabled(eopatchPumpPlugin, rootKey, config.PUMPDRIVERS) addPreferencesFromResourceIfEnabled(eopatchPumpPlugin, rootKey, config.PUMPDRIVERS)

View file

@ -6,6 +6,7 @@ import dagger.android.AndroidInjectionModule
import dagger.android.AndroidInjector import dagger.android.AndroidInjector
import info.nightscout.androidaps.MainApp import info.nightscout.androidaps.MainApp
import info.nightscout.androidaps.combo.di.ComboModule import info.nightscout.androidaps.combo.di.ComboModule
import info.nightscout.androidaps.combov2.di.ComboV2Module
import info.nightscout.androidaps.dana.di.DanaHistoryModule import info.nightscout.androidaps.dana.di.DanaHistoryModule
import info.nightscout.androidaps.dana.di.DanaModule import info.nightscout.androidaps.dana.di.DanaModule
import info.nightscout.androidaps.danar.di.DanaRModule import info.nightscout.androidaps.danar.di.DanaRModule
@ -63,6 +64,7 @@ import javax.inject.Singleton
// pumps // pumps
ComboModule::class, ComboModule::class,
ComboV2Module::class,
DanaHistoryModule::class, DanaHistoryModule::class,
DanaModule::class, DanaModule::class,
DanaRModule::class, DanaRModule::class,

View file

@ -19,6 +19,7 @@ import info.nightscout.androidaps.plugins.general.persistentNotification.Persist
import info.nightscout.androidaps.plugins.general.wear.WearPlugin import info.nightscout.androidaps.plugins.general.wear.WearPlugin
import info.nightscout.androidaps.plugins.iob.iobCobCalculator.IobCobCalculatorPlugin import info.nightscout.androidaps.plugins.iob.iobCobCalculator.IobCobCalculatorPlugin
import info.nightscout.androidaps.plugins.pump.combo.ComboPlugin import info.nightscout.androidaps.plugins.pump.combo.ComboPlugin
import info.nightscout.androidaps.plugins.pump.combov2.ComboV2Plugin
import info.nightscout.androidaps.plugins.pump.eopatch.EopatchPumpPlugin import info.nightscout.androidaps.plugins.pump.eopatch.EopatchPumpPlugin
import info.nightscout.androidaps.plugins.pump.insight.LocalInsightPlugin import info.nightscout.androidaps.plugins.pump.insight.LocalInsightPlugin
import info.nightscout.androidaps.plugins.pump.medtronic.MedtronicPumpPlugin import info.nightscout.androidaps.plugins.pump.medtronic.MedtronicPumpPlugin
@ -431,6 +432,12 @@ abstract class PluginsListModule {
@IntKey(500) @IntKey(500)
abstract fun bindThemeSwitcherPlugin(plugin: ThemeSwitcherPlugin): PluginBase abstract fun bindThemeSwitcherPlugin(plugin: ThemeSwitcherPlugin): PluginBase
@Binds
@PumpDriver
@IntoMap
@IntKey(510)
abstract fun bindComboV2Plugin(plugin: ComboV2Plugin): PluginBase
@Qualifier @Qualifier
annotation class AllConfigs annotation class AllConfigs

View file

@ -47,6 +47,9 @@ buildscript {
wearable_version = '2.9.0' wearable_version = '2.9.0'
play_services_wearable_version = '17.1.0' play_services_wearable_version = '17.1.0'
play_services_location_version = '21.0.1' play_services_location_version = '21.0.1'
kotlinx_datetime_version = '0.3.2'
kotlinx_serialization_core_version = '1.3.2'
} }
repositories { repositories {
google() google()

View file

@ -37,6 +37,8 @@ files:
translation: /app-wear-shared/rx/src/main/res/values-%android_code%/strings.xml translation: /app-wear-shared/rx/src/main/res/values-%android_code%/strings.xml
- source: /pump/combo/src/main/res/values/strings.xml - source: /pump/combo/src/main/res/values/strings.xml
translation: /pump/combo/src/main/res/values-%android_code%/strings.xml translation: /pump/combo/src/main/res/values-%android_code%/strings.xml
- source: /pump/combov2/src/main/res/values/strings.xml
translation: /pump/combov2/src/main/res/values-%android_code%/strings.xml
- source: /pump/dana/src/main/res/values/strings.xml - source: /pump/dana/src/main/res/values/strings.xml
translation: /pump/dana/src/main/res/values-%android_code%/strings.xml translation: /pump/dana/src/main/res/values-%android_code%/strings.xml
- source: /pump/danar/src/main/res/values/strings.xml - source: /pump/danar/src/main/res/values/strings.xml

View file

@ -131,6 +131,8 @@ open class Notification {
const val IDENTIFICATION_NOT_SET = 77 const val IDENTIFICATION_NOT_SET = 77
const val PERMISSION_BT = 78 const val PERMISSION_BT = 78
const val EOELOW_PATCH_ALERTS = 79 const val EOELOW_PATCH_ALERTS = 79
const val COMBO_PUMP_SUSPENDED = 80
const val COMBO_UNKNOWN_TBR = 81
const val USER_MESSAGE = 1000 const val USER_MESSAGE = 1000

44
pump/combov2/build.gradle Normal file
View file

@ -0,0 +1,44 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-allopen'
apply plugin: 'com.hiya.jacoco-android'
apply from: "${project.rootDir}/core/core-main//android_dependencies.gradle"
apply from: "${project.rootDir}/core/core-main//android_module_dependencies.gradle"
apply from: "${project.rootDir}/core/core-main//test_dependencies.gradle"
apply from: "${project.rootDir}/core/core-main//jacoco_global.gradle"
dependencies {
implementation project(':libraries')
implementation project(':core:core-main')
implementation project(':core:ui')
implementation project(':core:fabric')
implementation project(':app-wear-shared:rx')
implementation project(':app-wear-shared:shared')
implementation project(':interfaces')
implementation(project(":pump:combov2:comboctl"))
implementation("org.jetbrains.kotlinx:kotlinx-datetime:$kotlinx_datetime_version")
// This is necessary to avoid errors like these which otherwise come up often at runtime:
// "WARNING: Failed to transform class kotlinx/datetime/TimeZone$Companion
// java.lang.NoClassDefFoundError: kotlinx/serialization/KSerializer"
//
// "Rejecting re-init on previously-failed class java.lang.Class<
// kotlinx.datetime.serializers.LocalDateTimeIso8601Serializer>:
// java.lang.NoClassDefFoundError: Failed resolution of: Lkotlinx/serialization/KSerializer"
//
// kotlinx-datetime higher than 0.2.0 depends on kotlinx-serialization, but that dependency
// is declared as "compileOnly". The runtime dependency on kotlinx-serialization is missing,
// causing this error. Solution is to add runtimeOnly here.
//
// Source: https://github.com/mockk/mockk/issues/685#issuecomment-907076353:
// TODO: Revisit this when upgrading kotlinx-datetime
runtimeOnly("org.jetbrains.kotlinx:kotlinx-serialization-core:$kotlinx_serialization_core_version")
}
android {
namespace 'info.nightscout.androidaps.combov2'
buildFeatures {
dataBinding true
}
}

View file

@ -0,0 +1,14 @@
The code in comboctl/ is ComboCtl minus the jvmMain/ code, which contains code for the Linux platform.
This includes C++ glue code to the BlueZ stack. Since none of this is useful to AndroidAPS, it is better
left out, especially since it consists of almost 9000 lines of code.
Also, the original comboctl/build.gradle.kts files is replaced by comboctl/build.gradle, which is
much simpler, and builds ComboCtl as a kotlin-android project, not a kotlin-multiplatform one.
This simplifies integration into AndroidAPS, and avoids multiplatform problems (after all,
Kotlin Multiplatform is still marked as an alpha version feature).
When updating ComboCtl, it is important to keep these differences in mind.
Differences between the copy in comboctl/ and the original ComboCtl code must be kept as little as
possible, and preferably be transferred to the main ComboCtl project. This helps keep the comboctl/
copy and the main project in sync.

View file

@ -0,0 +1,21 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply from: "${project.rootDir}/core/core-main/android_dependencies.gradle"
android {
namespace 'info.nightscout.comboctl'
sourceSets {
main {
kotlin.srcDirs += ['src/commonMain', 'src/androidMain']
manifest.srcFile 'src/androidMain/AndroidManifest.xml'
}
}
}
dependencies {
implementation platform("org.jetbrains.kotlin:kotlin-bom")
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-datetime:$kotlinx_datetime_version"
implementation "androidx.core:core-ktx:$core_version"
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="info.nightscout.comboctl.android">
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
</manifest>

View file

@ -0,0 +1,262 @@
package info.nightscout.comboctl.android
import android.bluetooth.BluetoothAdapter as SystemBluetoothAdapter
import android.bluetooth.BluetoothDevice as SystemBluetoothDevice
import android.bluetooth.BluetoothSocket as SystemBluetoothSocket
import android.content.Context
import info.nightscout.comboctl.base.BluetoothAddress
import info.nightscout.comboctl.base.BluetoothDevice
import info.nightscout.comboctl.base.BluetoothException
import info.nightscout.comboctl.base.BluetoothInterface
import info.nightscout.comboctl.base.ComboIOException
import info.nightscout.comboctl.base.LogLevel
import info.nightscout.comboctl.base.Logger
import info.nightscout.comboctl.utils.retryBlocking
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.util.Locale
import kotlinx.coroutines.Dispatchers
private val logger = Logger.get("AndroidBluetoothDevice")
/**
* Class representing a Bluetooth device accessible through Android's Bluetooth API.
*
* Users typically do not instantiate this directly. Instead,
* [AndroidBluetoothInterface]'s implementation of [BluetoothInterface.getDevice]
* instantiates and returns this (as a [BluetoothDevice]).
*/
class AndroidBluetoothDevice(
private val androidContext: Context,
private val systemBluetoothAdapter: SystemBluetoothAdapter,
override val address: BluetoothAddress
) : BluetoothDevice(Dispatchers.IO) {
private var systemBluetoothSocket: SystemBluetoothSocket? = null
private var inputStream: InputStream? = null
private var outputStream: OutputStream? = null
private var canDoIO: Boolean = false
private var abortConnectAttempt: Boolean = false
// Use toUpperCase() since Android expects the A-F hex digits in the
// Bluetooth address string to be uppercase (lowercase ones are considered
// invalid and cause an exception to be thrown).
private val androidBtAddressString = address.toString().uppercase(Locale.ROOT)
// Base class overrides.
override fun connect() {
check(systemBluetoothSocket == null) { "Connection already established" }
logger(LogLevel.DEBUG) { "Attempting to get object representing device with address $address" }
abortConnectAttempt = false
lateinit var device: SystemBluetoothDevice
try {
// Establishing the RFCOMM connection does not always work right away.
// Depending on the Android version and the individual Android device,
// it may require several attempts until the connection is actually
// established. Some phones behave better in this than others. We
// also retrieve the BluetoothDevice instance, create an RFCOMM
// socket, _and_ try to connect in each attempt, since any one of
// these steps may initially fail.
// This is kept separate from the for-loop in Pump.connect() on purpose;
// that loop is in place because the _pump_ may not be ready to connect
// just yet (for example because the UI is still shown on the LCD), while
// the retryBlocking loop here is in place because the _Android device_
// may not be ready to connect right away.
// TODO: Test and define what happens when all attempts failed.
// The user needs to be informed and given the choice to try again.
val totalNumAttempts = 5
retryBlocking(numberOfRetries = totalNumAttempts, delayBetweenRetries = 100) { attemptNumber, previousException ->
if (abortConnectAttempt)
return@retryBlocking
if (attemptNumber == 0) {
logger(LogLevel.DEBUG) { "First attempt to establish an RFCOMM client connection to the Combo" }
} else {
logger(LogLevel.DEBUG) {
"Previous attempt to establish an RFCOMM client connection to the Combo failed with" +
"exception \"$previousException\"; trying again (this is attempt #${attemptNumber + 1} of 5)"
}
}
// Give the GC the chance to collect an older BluetoothSocket instance
// while this thread sleep (see below).
systemBluetoothSocket = null
device = systemBluetoothAdapter.getRemoteDevice(androidBtAddressString)
// Wait for 500 ms until we actually try to connect. This seems to
// circumvent an as-of-yet unknown Bluetooth related race condition.
// TODO: Clarify this and wait for whatever is going on there properly.
try {
Thread.sleep(500)
} catch (ignored: InterruptedException) {
}
checkForConnectPermission(androidContext) {
systemBluetoothSocket = device.createInsecureRfcommSocketToServiceRecord(Constants.sdpSerialPortUUID)
// connect() must be explicitly called. Just creating the socket via
// createInsecureRfcommSocketToServiceRecord() does not implicitly
// establish the connection. This is important to keep in mind, since
// otherwise, the calls below get input and output streams which appear
// at first to be OK until their read/write functions are actually used.
// At that point, very confusing NullPointerExceptions are thrown from
// seemingly nowhere. These NPEs happen because *inside* the streams
// there are internal Input/OutputStreams, and *these* are set to null
// if the connection wasn't established. See also:
// https://stackoverflow.com/questions/24267671/inputstream-read-causes-nullpointerexception-after-having-checked-inputstream#comment37491136_24267671
// and: https://stackoverflow.com/a/24269255/560774
systemBluetoothSocket!!.connect()
}
}
} catch (t: Throwable) {
disconnectImpl() // Clean up any partial connection states that may exist.
throw BluetoothException("Could not establish an RFCOMM client connection to device with address $address", t)
}
if (abortConnectAttempt) {
logger(LogLevel.INFO) { "RFCOMM connection setup with device with address $address aborted" }
return
}
try {
inputStream = systemBluetoothSocket!!.inputStream
} catch (e: IOException) {
disconnectImpl()
throw ComboIOException("Could not get input stream to device with address $address", e)
}
try {
outputStream = systemBluetoothSocket!!.outputStream
} catch (e: IOException) {
disconnectImpl()
throw ComboIOException("Could not get output stream to device with address $address", e)
}
canDoIO = true
logger(LogLevel.INFO) { "RFCOMM connection with device with address $address established" }
}
override fun disconnect() {
if (systemBluetoothSocket == null) {
logger(LogLevel.DEBUG) { "Device already disconnected - ignoring redundant call" }
return
}
disconnectImpl()
logger(LogLevel.INFO) { "RFCOMM connection with device with address $address terminated" }
}
override fun unpair() {
try {
val device = systemBluetoothAdapter.getRemoteDevice(androidBtAddressString)
// At time of writing (2021-12-06), the removeBond method
// is inexplicably still marked with @hide, so we must use
// reflection to get to it and unpair this device.
val removeBondMethod = device::class.java.getMethod("removeBond")
removeBondMethod.invoke(device)
} catch (t: Throwable) {
logger(LogLevel.ERROR) { "Unpairing device with address $address failed with error $t" }
}
}
override fun blockingSend(dataToSend: List<Byte>) {
// Handle corner case when disconnect() is called in a different coroutine
// shortly before this function is run.
if (!canDoIO) {
logger(LogLevel.DEBUG) { "We are disconnecting; ignoring attempt at sending data" }
return
}
check(outputStream != null) { "Device is not connected - cannot send data" }
try {
outputStream!!.write(dataToSend.toByteArray())
} catch (e: IOException) {
// If we are disconnecting, don't bother re-throwing the exception;
// one is always thrown when the stream is closed while write() blocks,
// and this essentially just means "write() call aborted because the
// stream got closed". That's not an error.
if (canDoIO)
throw ComboIOException("Could not write data to device with address $address", e)
else
logger(LogLevel.DEBUG) { "Aborted write call because we are disconnecting" }
}
}
override fun blockingReceive(): List<Byte> {
// Handle corner case when disconnect() is called in a different coroutine
// shortly before this function is run.
if (!canDoIO) {
logger(LogLevel.DEBUG) { "We are disconnecting; ignoring attempt at receiving data" }
return listOf()
}
check(inputStream != null) { "Device is not connected - cannot receive data" }
try {
val buffer = ByteArray(512)
val numReadBytes = inputStream!!.read(buffer)
return if (numReadBytes > 0) buffer.toList().subList(0, numReadBytes) else listOf()
} catch (e: IOException) {
// If we are disconnecting, don't bother re-throwing the exception;
// one is always thrown when the stream is closed while read() blocks,
// and this essentially just means "read() call aborted because the
// stream got closed". That's not an error.
if (canDoIO)
throw ComboIOException("Could not read data from device with address $address", e)
else {
logger(LogLevel.DEBUG) { "Aborted read call because we are disconnecting" }
return listOf()
}
}
}
private fun disconnectImpl() {
canDoIO = false
abortConnectAttempt = true
if (inputStream != null) {
try {
logger(LogLevel.DEBUG) { "Closing input stream" }
inputStream!!.close()
} catch (e: IOException) {
logger(LogLevel.WARN) { "Caught exception while closing input stream to device with address $address: $e - ignoring exception" }
} finally {
inputStream = null
}
}
if (outputStream != null) {
try {
logger(LogLevel.DEBUG) { "Closing output stream" }
outputStream!!.close()
} catch (e: IOException) {
logger(LogLevel.WARN) { "Caught exception while closing output stream to device with address $address: $e - ignoring exception" }
} finally {
outputStream = null
}
}
if (systemBluetoothSocket != null) {
try {
logger(LogLevel.DEBUG) { "Closing Bluetooth socket" }
systemBluetoothSocket!!.close()
} catch (e: IOException) {
logger(LogLevel.WARN) { "Caught exception while closing Bluetooth socket to device with address $address: $e - ignoring exception" }
} finally {
systemBluetoothSocket = null
}
}
logger(LogLevel.DEBUG) { "Device disconnected" }
}
}

View file

@ -0,0 +1,614 @@
package info.nightscout.comboctl.android
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter as SystemBluetoothAdapter
import android.bluetooth.BluetoothDevice as SystemBluetoothDevice
import android.bluetooth.BluetoothManager as SystemBluetoothManager
import android.bluetooth.BluetoothServerSocket as SystemBluetoothServerSocket
import android.bluetooth.BluetoothSocket as SystemBluetoothSocket
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import info.nightscout.comboctl.base.BluetoothAddress
import info.nightscout.comboctl.base.BluetoothDevice
import info.nightscout.comboctl.base.BluetoothException
import info.nightscout.comboctl.base.BluetoothInterface
import info.nightscout.comboctl.base.LogLevel
import info.nightscout.comboctl.base.Logger
import info.nightscout.comboctl.base.toBluetoothAddress
import java.io.IOException
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.thread
private val logger = Logger.get("AndroidBluetoothInterface")
/**
* Class for accessing Bluetooth functionality on Android.
*
* This needs an Android [Context] that is always present for
* the duration of the app's existence. It is not recommended
* to use the context from an [Activity], since such a context
* may go away if the user turns the screen for example. If
* the context goes away, and discovery is ongoing, then that
* discovery prematurely ends. The context of an [Application]
* instance is an ideal choice.
*/
class AndroidBluetoothInterface(private val androidContext: Context) : BluetoothInterface {
private var bluetoothAdapter: SystemBluetoothAdapter? = null
private var rfcommServerSocket: SystemBluetoothServerSocket? = null
private var discoveryStarted = false
private var discoveryBroadcastReceiver: BroadcastReceiver? = null
// Note that this contains ALL paired/bonded devices, not just
// the ones that pass the deviceFilterCallback.This is important
// in case the filter is changed sometime later, otherwise
// getPairedDeviceAddresses() would return an incomplete
// list.getPairedDeviceAddresses() has to apply the filter manually.
private val pairedDeviceAddresses = mutableSetOf<BluetoothAddress>()
// This is necessary, since the BroadcastReceivers always
// run in the UI thread, while access to the pairedDeviceAddresses
// can be requested from other threads.
private val deviceAddressLock = ReentrantLock()
private var listenThread: Thread? = null
private var unpairedDevicesBroadcastReceiver: BroadcastReceiver? = null
// Stores SystemBluetoothDevice that were previously seen in
// onAclConnected(). These instances represent a device that
// was found during discovery. The first time the device is
// discovered, an instance is provided - but that first instance
// is not usable (this seems to be caused by an underlying
// Bluetooth stack bug). Only when _another_ instance that
// represents the same device is seen can that other instance
// be used and pairing can continue. Therefore, we store the
// previous observation to be able to detect whether a
// discovered instance is the first or second one that represents
// the device. We also retain the first instance until the
// second one is found - this seems to improve pairing stability
// on some Android devices.
// TODO: Find out why these weird behavior occurs and why
// we can only use the second instance.
private val previouslyDiscoveredDevices = mutableMapOf<BluetoothAddress, SystemBluetoothDevice?>()
// Set to a non-null value if discovery timeouts or if it is
// manually stopped via stopDiscovery().
private var discoveryStoppedReason: BluetoothInterface.DiscoveryStoppedReason? = null
// Set to true once a device is found. Used in onDiscoveryFinished()
// to suppress a discoveryStopped callback invocation.
private var foundDevice = false
// Invoked if discovery stops for any reason other than that
// a device was found.
private var discoveryStopped: (reason: BluetoothInterface.DiscoveryStoppedReason) -> Unit = { }
override var onDeviceUnpaired: (deviceAddress: BluetoothAddress) -> Unit = { }
override var deviceFilterCallback: (deviceAddress: BluetoothAddress) -> Boolean = { true }
fun setup() {
val bluetoothManager = androidContext.getSystemService(Context.BLUETOOTH_SERVICE) as SystemBluetoothManager
bluetoothAdapter = bluetoothManager.adapter
val bondedDevices = checkForConnectPermission(androidContext) {
bluetoothAdapter!!.bondedDevices
}
logger(LogLevel.DEBUG) { "Found ${bondedDevices.size} bonded Bluetooth device(s)" }
for (bondedDevice in bondedDevices) {
val androidBtAddressString = bondedDevice.address
logger(LogLevel.DEBUG) {
"... device $androidBtAddressString"
}
try {
val comboctlBtAddress = androidBtAddressString.toBluetoothAddress()
pairedDeviceAddresses.add(comboctlBtAddress)
} catch (e: IllegalArgumentException) {
logger(LogLevel.ERROR) {
"Could not convert Android bluetooth device address " +
"\"$androidBtAddressString\" to a valid BluetoothAddress instance; skipping device"
}
}
}
unpairedDevicesBroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
logger(LogLevel.DEBUG) { "unpairedDevicesBroadcastReceiver received new action: ${intent.action}" }
when (intent.action) {
SystemBluetoothDevice.ACTION_BOND_STATE_CHANGED -> onBondStateChanged(intent)
else -> Unit
}
}
}
androidContext.registerReceiver(
unpairedDevicesBroadcastReceiver,
IntentFilter(SystemBluetoothDevice.ACTION_BOND_STATE_CHANGED)
)
}
fun teardown() {
if (unpairedDevicesBroadcastReceiver != null) {
androidContext.unregisterReceiver(unpairedDevicesBroadcastReceiver)
unpairedDevicesBroadcastReceiver = null
}
}
/** Callback for custom discovery activity startup.
*
* Useful for when more elaborate start procedures are done such as those that use
* [ActivityResultCaller.registerForActivityResult]. If this callback is set to null,
* the default behavior is used (= start activity with [Activity.startActivity]).
* Note that this default behavior does not detected when the user rejects permission
* to make the Android device discoverable.
*/
var customDiscoveryActivityStartCallback: ((intent: Intent) -> Unit)? = null
override fun startDiscovery(
sdpServiceName: String,
sdpServiceProvider: String,
sdpServiceDescription: String,
btPairingPin: String,
discoveryDuration: Int,
onDiscoveryStopped: (reason: BluetoothInterface.DiscoveryStoppedReason) -> Unit,
onFoundNewPairedDevice: (deviceAddress: BluetoothAddress) -> Unit
) {
check(!discoveryStarted) { "Discovery already started" }
previouslyDiscoveredDevices.clear()
foundDevice = false
discoveryStoppedReason = null
// The Combo communicates over RFCOMM using the SDP Serial Port Profile.
// We use an insecure socket, which means that it lacks an authenticated
// link key. This is done because the Combo does not use this feature.
//
// TODO: Can Android RFCOMM SDP service records be given custom
// sdpServiceProvider and sdpServiceDescription values? (This is not
// necessary for correct function, just a detail for sake of completeness.)
logger(LogLevel.DEBUG) { "Setting up RFCOMM listener socket" }
rfcommServerSocket = checkForConnectPermission(androidContext) {
bluetoothAdapter!!.listenUsingInsecureRfcommWithServiceRecord(
sdpServiceName,
Constants.sdpSerialPortUUID
)
}
// Run a separate thread to accept and throw away incoming RFCOMM connections.
// We do not actually use those; the RFCOMM listener socket only exists to be
// able to provide an SDP SerialPort service record that can be discovered by
// the pump, and that record needs an RFCOMM listener port number.
listenThread = thread {
logger(LogLevel.DEBUG) { "RFCOMM listener thread started" }
try {
while (true) {
logger(LogLevel.DEBUG) { "Waiting for incoming RFCOMM socket to accept" }
var socket: SystemBluetoothSocket? = null
if (rfcommServerSocket != null)
socket = rfcommServerSocket!!.accept()
if (socket != null) {
logger(LogLevel.DEBUG) { "Closing accepted incoming RFCOMM socket" }
try {
socket.close()
} catch (e: IOException) {
}
}
}
} catch (t: Throwable) {
// This happens when rfcommServerSocket.close() is called.
logger(LogLevel.DEBUG) { "RFCOMM listener accept() call aborted" }
}
logger(LogLevel.DEBUG) { "RFCOMM listener thread stopped" }
}
this.discoveryStopped = onDiscoveryStopped
logger(LogLevel.DEBUG) {
"Registering receiver for getting notifications about pairing requests and connected devices"
}
val intentFilter = IntentFilter()
intentFilter.addAction(SystemBluetoothDevice.ACTION_ACL_CONNECTED)
intentFilter.addAction(SystemBluetoothDevice.ACTION_PAIRING_REQUEST)
intentFilter.addAction(SystemBluetoothAdapter.ACTION_DISCOVERY_FINISHED)
intentFilter.addAction(SystemBluetoothAdapter.ACTION_SCAN_MODE_CHANGED)
discoveryBroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
logger(LogLevel.DEBUG) { "discoveryBroadcastReceiver received new action: ${intent.action}" }
when (intent.action) {
SystemBluetoothDevice.ACTION_ACL_CONNECTED -> onAclConnected(intent, onFoundNewPairedDevice)
SystemBluetoothDevice.ACTION_PAIRING_REQUEST -> onPairingRequest(intent, btPairingPin)
SystemBluetoothAdapter.ACTION_DISCOVERY_FINISHED -> onDiscoveryFinished()
SystemBluetoothAdapter.ACTION_SCAN_MODE_CHANGED -> onScanModeChanged(intent)
else -> Unit
}
}
}
androidContext.registerReceiver(discoveryBroadcastReceiver, intentFilter)
logger(LogLevel.DEBUG) { "Starting activity for making this Android device discoverable" }
val discoverableIntent = Intent(SystemBluetoothAdapter.ACTION_REQUEST_DISCOVERABLE).apply {
putExtra(SystemBluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, discoveryDuration)
putExtra(SystemBluetoothAdapter.EXTRA_SCAN_MODE, SystemBluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE)
}
if (customDiscoveryActivityStartCallback == null) {
// Do the default start procedure if no custom one was defined.
// This flag is necessary to be able to start the scan from the given context,
// which is _not_ an activity. Starting scans from activities is potentially
// problematic since they can go away at any moment. If this is not desirable,
// relying on the AAPS Context is better, but we have to create a new task then.
discoverableIntent.flags = discoverableIntent.flags or Intent.FLAG_ACTIVITY_NEW_TASK
androidContext.startActivity(discoverableIntent)
} else {
customDiscoveryActivityStartCallback?.invoke(discoverableIntent)
}
logger(LogLevel.DEBUG) { "Started discovery" }
discoveryStarted = true
}
override fun stopDiscovery() {
discoveryStoppedReason = BluetoothInterface.DiscoveryStoppedReason.MANUALLY_STOPPED
stopDiscoveryInternal()
}
override fun getDevice(deviceAddress: BluetoothAddress): BluetoothDevice =
AndroidBluetoothDevice(androidContext, bluetoothAdapter!!, deviceAddress)
override fun getAdapterFriendlyName() =
checkForConnectPermission(androidContext) { bluetoothAdapter!!.name }
?: throw BluetoothException("Could not get Bluetooth adapter friendly name")
override fun getPairedDeviceAddresses(): Set<BluetoothAddress> =
try {
deviceAddressLock.lock()
pairedDeviceAddresses.filter { pairedDeviceAddress -> deviceFilterCallback(pairedDeviceAddress) }.toSet()
} finally {
deviceAddressLock.unlock()
}
private fun stopDiscoveryInternal() {
// Close the server socket. This frees RFCOMM resources and ends
// the listenThread because the accept() call inside will be aborted
// by the close() call.
try {
if (rfcommServerSocket != null)
rfcommServerSocket!!.close()
} catch (e: IOException) {
logger(LogLevel.ERROR) { "Caught IO exception while closing RFCOMM server socket: $e" }
} finally {
rfcommServerSocket = null
}
// The listenThread will be shutting down now after the server
// socket was closed, since the blocking accept() call inside
// the thread gets aborted by close(). Just wait here for the
// thread to fully finish before we continue.
if (listenThread != null) {
logger(LogLevel.DEBUG) { "Waiting for RFCOMM listener thread to finish" }
listenThread!!.join()
logger(LogLevel.DEBUG) { "RFCOMM listener thread finished" }
listenThread = null
}
if (discoveryBroadcastReceiver != null) {
androidContext.unregisterReceiver(discoveryBroadcastReceiver)
discoveryBroadcastReceiver = null
}
runIfScanPermissionGranted(androidContext) {
@SuppressLint("MissingPermission")
if (bluetoothAdapter!!.isDiscovering) {
logger(LogLevel.DEBUG) { "Stopping discovery" }
bluetoothAdapter!!.cancelDiscovery()
}
}
if (discoveryStarted) {
logger(LogLevel.DEBUG) { "Stopped discovery" }
discoveryStarted = false
}
}
private fun onAclConnected(intent: Intent, foundNewPairedDevice: (deviceAddress: BluetoothAddress) -> Unit) {
// Sanity check in case we get this notification for the
// device already and need to avoid duplicate processing.
if (intent.getStringExtra("address") != null)
return
// Sanity check to make sure we can actually get
// a Bluetooth device out of the intent. Otherwise,
// we have to wait for the next notification.
val androidBtDevice = intent.getParcelableExtra<SystemBluetoothDevice>(SystemBluetoothDevice.EXTRA_DEVICE)
if (androidBtDevice == null) {
logger(LogLevel.DEBUG) { "Ignoring ACL_CONNECTED intent that has no Bluetooth device" }
return
}
val androidBtAddressString = androidBtDevice.address
// This effectively marks the device as "already processed"
// (see the getStringExtra() call above).
intent.putExtra("address", androidBtAddressString)
logger(LogLevel.DEBUG) { "ACL_CONNECTED intent has Bluetooth device with address $androidBtAddressString" }
val comboctlBtAddress = try {
androidBtAddressString.toBluetoothAddress()
} catch (t: Throwable) {
logger(LogLevel.ERROR) {
"Could not convert Android bluetooth device address " +
"\"$androidBtAddressString\" to a valid BluetoothAddress instance; skipping device"
}
return
}
// During discovery, the ACTION_ACL_CONNECTED action apparently
// is notified at least *twice*. And, the device that is present
// in the intent may not be the same device there was during
// the first notification (= the parcelableExtra EXTRA_DEVICE
// seen above). It turns out that only the *second EXTRA_DEVICE
// can actually be used (otherwise pairing fails). The reason
// for this is unknown, but seems to be caused by bugs in the
// Fluoride (aka BlueDroid) Bluetooth stack.
// To circumvent this, we don't do anything the first time,
// but remember the device's Bluetooth address. Only when we
// see that address again do we actually proceed with announcing
// the device as having been discovered.
// NOTE: This is different from the getStringExtra() check
// above. That one checks if the *same* Android Bluetooth device
// instance was already processed. This check here instead
// verifies if we have seen the same Bluetooth address on
// *different* Android Bluetooth device instances.
// TODO: Test how AndroidBluetoothInterface behaves if the
// device is unpaired while discovery is ongoing (manually by
// the user for example). In theory, this should be handled
// properly by the onBondStateChanged function below.
// TODO: This check may not be necessary on all Android
// devices. On some, it seems to also work if we use the
// first offered BluetoothDevice.
if (comboctlBtAddress !in previouslyDiscoveredDevices) {
previouslyDiscoveredDevices[comboctlBtAddress] = androidBtDevice
logger(LogLevel.DEBUG) {
"Device with address $comboctlBtAddress discovered for the first time; " +
"need to \"discover\" it again to be able to announce its discovery"
}
return
} else {
previouslyDiscoveredDevices[comboctlBtAddress] = null
logger(LogLevel.DEBUG) {
"Device with address $comboctlBtAddress discovered for the second time; " +
"announcing it as discovered"
}
}
// Always adding the device to the paired addresses even
// if the deviceFilterCallback() below returns false. See
// the pairedDeviceAddresses comments above for more.
try {
deviceAddressLock.lock()
pairedDeviceAddresses.add(comboctlBtAddress)
} finally {
deviceAddressLock.unlock()
}
logger(LogLevel.INFO) { "Got device with address $androidBtAddressString" }
try {
// Apply device filter before announcing a newly
// discovered device, just as the ComboCtl
// BluetoothInterface.startDiscovery()
// documentation requires.
if (deviceFilterCallback(comboctlBtAddress)) {
foundDevice = true
stopDiscoveryInternal()
foundNewPairedDevice(comboctlBtAddress)
}
} catch (t: Throwable) {
logger(LogLevel.ERROR) { "Caught error while invoking foundNewPairedDevice callback: $t" }
}
}
private fun onBondStateChanged(intent: Intent) {
// Here, we handle the case where a previously paired
// device just got unpaired. The caller needs to know
// about this to check if said device was a Combo.
// If so, the caller may have to update states like
// the pump state store accordingly.
val androidBtDevice = intent.getParcelableExtra<SystemBluetoothDevice>(SystemBluetoothDevice.EXTRA_DEVICE)
if (androidBtDevice == null) {
logger(LogLevel.DEBUG) { "Ignoring BOND_STATE_CHANGED intent that has no Bluetooth device" }
return
}
val androidBtAddressString = androidBtDevice.address
logger(LogLevel.DEBUG) { "PAIRING_REQUEST intent has Bluetooth device with address $androidBtAddressString" }
val comboctlBtAddress = try {
androidBtAddressString.toBluetoothAddress()
} catch (e: IllegalArgumentException) {
logger(LogLevel.ERROR) {
"Could not convert Android bluetooth device address " +
"\"$androidBtAddressString\" to a valid BluetoothAddress instance; ignoring device"
}
return
}
val previousBondState = intent.getIntExtra(SystemBluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, SystemBluetoothDevice.ERROR)
val currentBondState = intent.getIntExtra(SystemBluetoothDevice.EXTRA_BOND_STATE, SystemBluetoothDevice.ERROR)
// An unpaired device is characterized by a state change
// from non-NONE to NONE. Filter out all other state changes.
if (!((currentBondState == SystemBluetoothDevice.BOND_NONE) && (previousBondState != SystemBluetoothDevice.BOND_NONE))) {
return
}
previouslyDiscoveredDevices.remove(comboctlBtAddress)
// Always removing the device from the paired addresses
// event if the deviceFilterCallback() below returns false.
// See the pairedDeviceAddresses comments above for more.
try {
deviceAddressLock.lock()
pairedDeviceAddresses.remove(comboctlBtAddress)
logger(LogLevel.DEBUG) { "Removed device with address $comboctlBtAddress from the list of paired devices" }
} finally {
deviceAddressLock.unlock()
}
// Apply device filter before announcing an
// unpaired device, just as the ComboCtl
// BluetoothInterface.startDiscovery()
// documentation requires.
try {
if (deviceFilterCallback(comboctlBtAddress)) {
onDeviceUnpaired(comboctlBtAddress)
}
} catch (t: Throwable) {
logger(LogLevel.ERROR) { "Caught error while invoking onDeviceUnpaired callback: $t" }
}
}
private fun onPairingRequest(intent: Intent, btPairingPin: String) {
val androidBtDevice = intent.getParcelableExtra<SystemBluetoothDevice>(SystemBluetoothDevice.EXTRA_DEVICE)
if (androidBtDevice == null) {
logger(LogLevel.DEBUG) { "Ignoring PAIRING_REQUEST intent that has no Bluetooth device" }
return
}
val androidBtAddressString = androidBtDevice.address
logger(LogLevel.DEBUG) { "PAIRING_REQUEST intent has Bluetooth device with address $androidBtAddressString" }
val comboctlBtAddress = try {
androidBtAddressString.toBluetoothAddress()
} catch (e: IllegalArgumentException) {
logger(LogLevel.ERROR) {
"Could not convert Android bluetooth device address " +
"\"$androidBtAddressString\" to a valid BluetoothAddress instance; ignoring device"
}
return
}
if (!deviceFilterCallback(comboctlBtAddress)) {
logger(LogLevel.DEBUG) { "This is not a Combo pump; ignoring device" }
return
}
logger(LogLevel.INFO) {
" Device with address $androidBtAddressString is a Combo pump; accepting Bluetooth pairing request"
}
// NOTE: The setPin(), createBond(), and setPairingConfirmation()
// calls *must* be made, no matter if the permissions were given
// or not. Otherwise, pairing fails. This is because the Combo's
// pairing mechanism is unusual; the Bluetooth PIN is hardcoded
// (see the BT_PAIRING_PIN constant), and the application enters
// it programmatically. For security reasons, this isn't normally
// doable. But, with the calls below, it seems to work. This sort
// of bends what is possible in Android, and is the cause for
// pairing difficulties, but cannot be worked around.
//
// This means that setPin(), createBond(), and setPairingConfirmation()
// _must not_ be called with functions like checkForConnectPermission(),
// since those functions would always detect the missing permissions
// and refuse to invoke these functions.
//
// Furthermore, setPairingConfirmation requires the BLUETOOTH_PRIVILEGED
// permission. However, this permission is only accessible to system
// apps. But again, without it, pairing fails, and we are probably
// using undocumented behavior here. The call does fail with a
// SecurityException, but still seems to do _something_.
try {
@SuppressLint("MissingPermission")
if (!androidBtDevice.setPin(btPairingPin.encodeToByteArray())) {
logger(LogLevel.WARN) { "Could not set Bluetooth pairing PIN" }
}
} catch (t: Throwable) {
logger(LogLevel.WARN) { "Caught error while setting Bluetooth pairing PIN: $t" }
}
try {
@SuppressLint("MissingPermission")
if (!androidBtDevice.createBond()) {
logger(LogLevel.WARN) { "Could not create bond" }
}
} catch (t: Throwable) {
logger(LogLevel.WARN) { "Caught error while creating bond: $t" }
}
try {
@SuppressLint("MissingPermission")
if (!androidBtDevice.setPairingConfirmation(true)) {
logger(LogLevel.WARN) { "Could not set pairing confirmation" }
}
} catch (t: Throwable) {
logger(LogLevel.WARN) { "Caught exception while setting pairing confirmation: $t" }
}
logger(LogLevel.INFO) { "Established Bluetooth pairing with Combo pump with address $androidBtAddressString" }
}
private fun onDiscoveryFinished() {
logger(LogLevel.DEBUG) { "Discovery finished" }
// If a device was found, foundNewPairedDevice is called,
// which implicitly announces that discovery stopped.
if (!foundDevice) {
// discoveryStoppedReason is set to a non-NULL value only
// if stopDiscovery() is called. If the discovery timeout
// is reached, we get to this point, but the value of
// discoveryStoppedReason is still null.
discoveryStopped(discoveryStoppedReason ?: BluetoothInterface.DiscoveryStoppedReason.DISCOVERY_TIMEOUT)
}
}
private fun onScanModeChanged(intent: Intent) {
// Only using EXTRA_SCAN_MODE here, since EXTRA_PREVIOUS_SCAN_MODE never
// seems to be populated. See: https://stackoverflow.com/a/30935424/560774
// This appears to be either a bug in Android or an error in the documentation.
val currentScanMode = intent.getIntExtra(SystemBluetoothAdapter.EXTRA_SCAN_MODE, SystemBluetoothAdapter.ERROR)
if (currentScanMode == SystemBluetoothAdapter.ERROR) {
logger(LogLevel.ERROR) { "Could not get current scan mode; EXTRA_SCAN_MODE extra field missing" }
return
}
logger(LogLevel.DEBUG) { "Scan mode changed to $currentScanMode" }
// Since EXTRA_PREVIOUS_SCAN_MODE is not available, we have to use a trick
// to make sure we detect a discovery timeout. If there's a broadcast
// receiver, we must have been discoverable so far. And if the EXTRA_SCAN_MODE
// field indicates that we aren't discoverable right now, it follows that
// we used to be discoverable but no longer are.
if ((discoveryBroadcastReceiver != null) &&
(currentScanMode != SystemBluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE)
) {
logger(LogLevel.INFO) { "We are no longer discoverable" }
// Only proceed if the discovery timed out. This happens if no device was
// found and discoveryStoppedReason wasn't set. (see stopDiscovery()
// for an example where discoveryStoppedReason is set prior to stopping.)
if (!foundDevice && (discoveryStoppedReason == null)) {
discoveryStoppedReason = BluetoothInterface.DiscoveryStoppedReason.DISCOVERY_TIMEOUT
onDiscoveryFinished()
}
}
}
}

View file

@ -0,0 +1,17 @@
package info.nightscout.comboctl.android
import android.util.Log
import info.nightscout.comboctl.base.LogLevel
import info.nightscout.comboctl.base.LoggerBackend
class AndroidLoggerBackend : LoggerBackend {
override fun log(tag: String, level: LogLevel, throwable: Throwable?, message: String?) {
when (level) {
LogLevel.VERBOSE -> Log.v(tag, message, throwable)
LogLevel.DEBUG -> Log.d(tag, message, throwable)
LogLevel.INFO -> Log.i(tag, message, throwable)
LogLevel.WARN -> Log.w(tag, message, throwable)
LogLevel.ERROR -> Log.e(tag, message, throwable)
}
}
}

View file

@ -0,0 +1,47 @@
package info.nightscout.comboctl.android
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import info.nightscout.comboctl.base.BluetoothPermissionException
private const val bluetoothConnectPermission = "android.permission.BLUETOOTH_CONNECT"
private const val bluetoothScanPermission = "android.permission.BLUETOOTH_SCAN"
class AndroidBluetoothPermissionException(val missingPermissions: List<String>) :
BluetoothPermissionException("Missing Bluetooth permissions: ${missingPermissions.joinToString(", ")}")
internal fun <T> checkForConnectPermission(androidContext: Context, block: () -> T) =
checkForPermissions(androidContext, listOf(bluetoothConnectPermission), block)
internal fun <T> checkForPermission(androidContext: Context, permission: String, block: () -> T) =
checkForPermissions(androidContext, listOf(permission), block)
internal fun <T> checkForPermissions(androidContext: Context, permissions: List<String>, block: () -> T): T {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val missingPermissions = permissions
.filter {
ContextCompat.checkSelfPermission(androidContext, it) != PackageManager.PERMISSION_GRANTED
}
if (missingPermissions.isEmpty())
block.invoke()
else
throw AndroidBluetoothPermissionException(missingPermissions)
} else
block.invoke()
}
internal fun runIfScanPermissionGranted(androidContext: Context, block: () -> Unit): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (ContextCompat.checkSelfPermission(androidContext, bluetoothScanPermission)
== PackageManager.PERMISSION_GRANTED) {
block.invoke()
true
} else
false
} else {
block.invoke()
true
}
}

View file

@ -0,0 +1,11 @@
package info.nightscout.comboctl.android
import java.util.UUID
object Constants {
// This is a combination of the base SDP service UUID, which is
// 00000000-0000-1000-8000-00805F9B34FB, and the short SerialPort
// UUID, which is 0x1101. The base UUID is specified in the
// Bluetooth 4.2 spec, Vol 3, Part B, section 2.5 .
val sdpSerialPortUUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")!!
}

View file

@ -0,0 +1,24 @@
package info.nightscout.comboctl.utils
fun <T> retryBlocking(
numberOfRetries: Int,
delayBetweenRetries: Long = 100,
block: (Int, Exception?) -> T
): T {
require(numberOfRetries > 0)
var previousException: Exception? = null
repeat(numberOfRetries - 1) { attemptNumber ->
try {
return block(attemptNumber, previousException)
} catch (exception: Exception) {
previousException = exception
}
Thread.sleep(delayBetweenRetries)
}
// The last attempt. This one is _not_ surrounded with a try-catch
// block to make sure that if even the last attempt fails with an
// exception the caller gets informed about said exception.
return block(numberOfRetries - 1, previousException)
}

View file

@ -0,0 +1,47 @@
package info.nightscout.comboctl.base
const val NUM_BLUETOOTH_ADDRESS_BYTES = 6
/**
* Class containing a 6-byte Bluetooth address.
*
* The address bytes are stored in the printed order.
* For example, a Bluetooth address 11:22:33:44:55:66
* is stored as a 0x11, 0x22, 0x33, 0x44, 0x55, 0x66
* array, with 0x11 being the first byte. This is how
* Android stores Bluetooth address bytes. Note though
* that some Bluetooth stacks like BlueZ store the
* bytes in the reverse order.
*/
data class BluetoothAddress(private val addressBytes: List<Byte>) : Iterable<Byte> {
/**
* Number of address bytes (always 6).
*
* This mainly exists to make this class compatible with
* code that operates on collections.
*/
val size = NUM_BLUETOOTH_ADDRESS_BYTES
init {
require(addressBytes.size == size)
}
operator fun get(index: Int) = addressBytes[index]
override operator fun iterator() = addressBytes.iterator()
override fun toString() = addressBytes.toHexString(":")
fun toByteArray() = addressBytes.toByteArray()
}
/**
* Converts a 6-byte bytearray to a BluetoothAddress.
*
* @return BluetoothAddress variant of the 6 bytearray bytes.
* @throws IllegalArgumentException if the bytearray's length
* is not exactly 6 bytes.
*/
fun ByteArray.toBluetoothAddress() = BluetoothAddress(this.toList())
fun String.toBluetoothAddress() = BluetoothAddress(this.split(":").map { it.toInt(radix = 16).toByte() })

View file

@ -0,0 +1,62 @@
package info.nightscout.comboctl.base
import kotlinx.coroutines.CoroutineDispatcher
/**
* Abstract class for operating Bluetooth devices.
*
* Subclasses implement blocking IO to allow for RFCOMM-based
* IO with a Bluetooth device.
*
* Subclass instances are created by [BluetoothInterface] subclasses.
*
* @param ioDispatcher [CoroutineDispatcher] where the
* [blockingSend] and [blockingReceive] calls shall take place.
* See the [BlockingComboIO] for details. Typically, this dispatcher
* is sent by the [BluetoothInterface] subclass that creates this
* [BluetoothDevice] instance.
*/
abstract class BluetoothDevice(ioDispatcher: CoroutineDispatcher) : BlockingComboIO(ioDispatcher) {
/**
* The device's Bluetooth address.
*/
abstract val address: BluetoothAddress
/**
* Set up the device's RFCOMM connection.
*
* This function blocks until the connection is set up or an error occurs.
*
* @throws BluetoothPermissionException if connecting fails
* because connection permissions are missing.
* @throws BluetoothException if connection fails due to an underlying
* Bluetooth issue.
* @throws ComboIOException if connection fails due to an underlying
* IO issue and if the device was unpaired.
* @throws IllegalStateException if this object is in a state
* that does not permit connecting, such as a device
* that has been shut down.
*/
abstract fun connect()
/**
* Explicitly disconnect the device's RFCOMM connection now.
*
* After this call, this BluetoothDevice instance cannot be user
* anymore until it is reconnected via a new [connect] call.
*/
abstract fun disconnect()
/**
* Unpairs this device.
*
* Once this was called, this [BluetoothDevice] instance must not be used anymore.
* [disconnect] may be called, but will be a no-op. [connect], [send] and [receive]
* will throw an [IllegalStateException].
*
* Calling this while a connection is running leads to undefined behavior.
* Make sure to call [disconnect] before this function if a connection
* is currently present.
*/
abstract fun unpair()
}

View file

@ -0,0 +1,25 @@
package info.nightscout.comboctl.base
/**
* Base class for Bluetooth specific exceptions.
*
* @param message The detail message.
* @param cause Throwable that further describes the cause of the exception.
*/
open class BluetoothException(message: String?, cause: Throwable?) : ComboIOException(message, cause) {
constructor(message: String) : this(message, null)
constructor(cause: Throwable) : this(null, cause)
}
/**
* Base class for exceptions that get thrown when permissions for scanning, connecting etc. are missing.
*
* Subclasses contain OS specific information, like the exact missing permissions.
*
* @param message The detail message.
* @param cause Throwable that further describes the cause of the exception.
*/
open class BluetoothPermissionException(message: String?, cause: Throwable?) : BluetoothException(message, cause) {
constructor(message: String) : this(message, null)
constructor(cause: Throwable) : this(null, cause)
}

View file

@ -0,0 +1,210 @@
package info.nightscout.comboctl.base
/**
* Simple high-level interface to the system's Bluetooth stack.
*
* This interface offers the bare minimum to accomplish the following tasks:
*
* 1. Discover and pair Bluetooth devices with the given pairing PIN.
* (An SDP service is temporarily set up during discovery.)
* 2. Connect to a Bluetooth device and enable RFCOMM-based blocking IO with it.
*
* The constructor must set up all necessary platform specific resources.
*/
interface BluetoothInterface {
/**
* Possible reasons for why discovery stopped.
*
* Used in the [startDiscovery] discoveryStopped callback.
*/
enum class DiscoveryStoppedReason(val str: String) {
MANUALLY_STOPPED("manually stopped"),
DISCOVERY_ERROR("error during discovery"),
DISCOVERY_TIMEOUT("discovery timeout reached")
}
/**
* Callback for when a previously paired device is unpaired.
*
* This is independent of the device discovery. That is, this callback
* can be invoked by the implementation even when discovery is inactive.
*
* The unpairing may have been done via [BluetoothDevice.unpair] or
* via some sort of system settings.
*
* Note that this callback may be called from another thread. Using
* synchronization primitives to avoid race conditions is recommended.
* Also, implementations must make sure that setting the callback
* can not cause data races; that is, it must not happen that a new
* callback is set while the existing callback is invoked due to an
* unpaired device.
*
* Do not spend too much time in this callback, since it may block
* internal threads.
*
* Exceptions thrown by this callback are logged, but not propagated.
*
* See the note at [getPairedDeviceAddresses] about using this callback
* and that function in the correct order.
*/
var onDeviceUnpaired: (deviceAddress: BluetoothAddress) -> Unit
/**
* Callback for filtering devices based on their Bluetooth addresses.
*
* This is used for checking if a device shall be processed or ignored.
* When a newly paired device is discovered, or a paired device is
* unpaired, this callback is invoked. If it returns false, then
* the device is ignored, and those callbacks don't get called.
*
* Note that this callback may be called from another thread. Using
* synchronization primitives to avoid race conditions is recommended.
* Also, implementations must make sure that setting the callback
* can not cause data races.
*
* Do not spend too much time in this callback, since it may block
* internal threads.
*
* IMPORTANT: This callback must not throw.
*
* The default callback always returns true.
*/
var deviceFilterCallback: (deviceAddress: BluetoothAddress) -> Boolean
/**
* Starts discovery of Bluetooth devices that haven't been paired yet.
*
* Discovery is actually a process that involves multiple parts:
*
* 1. An SDP service is set up. This service is then announced to
* Bluetooth devices. Each SDP device has a record with multiple
* attributes, three of which are defined by the sdp* arguments.
* 2. Pairing is set up so that when a device tries to pair with the
* interface, it is authenticated using the given PIN.
* 3. Each detected device is filtered via its address by calling
* the [deviceFilterCallback]. Only those devices whose addresses
* pass this filter are forwarded to the pairing authorization
* (see step 2 above). As a result, only the filtered devices
* can eventually have their address passed to the
* foundNewPairedDevice callback.
*
* Note that the callbacks typically are called from a different
* thread, so make sure that thread synchronization primitives like
* mutexes are used.
*
* Do not spend too much time in the callbacks, since this
* may block internal threads.
*
* This function may only be called after creating the interface and
* after discovery stopped.
*
* Discovery can stop because of these reasons:
*
* 1. [stopDiscovery] is called. This will cause the discoveryStopped
* callback to be invoked, with its "reason" argument value set to
* [DiscoveryStoppedReason.MANUALLY_STOPPED].
* 2. An error occurred during discovery. The discoveryStopped callback
* is then called with its "reason" argument value set to
* [DiscoveryStoppedReason.DISCOVERY_ERROR].
* 3. The discovery timeout was reached and no device was discovered.
* The discoveryStopped callback is then called with its "reason"
* argument value set to [DiscoveryStoppedReason.DISCOVERY_TIMEOUT].
* 4. A device is discovered and paired (with the given pairing PIN).
* The discoveryStopped callback is _not_ called in that case.
* The foundNewPairedDevice callback is called (after discovery
* was shut down), both announcing the newly discovered device to
* the caller and implicitly notifying that discovery stopped.
*
* @param sdpServiceName Name for the SDP service record.
* Must not be empty.
* @param sdpServiceProvider Human-readable name of the provider of
* this SDP service record. Must not be empty.
* @param sdpServiceDescription Human-readable description of
* this SDP service record. Must not be empty.
* @param btPairingPin Bluetooth PIN code to use for pairing.
* Not to be confused with the Combo's 10-digit pairing PIN.
* This PIN is a sequence of characters used by the Bluetooth
* stack for its pairing/authorization.
* @param discoveryDuration How long the discovery shall go on,
* in seconds. Must be a value between 1 and 300.
* @param onDiscoveryStopped: Callback that gets invoked when discovery
* is stopped for any reason _other_ than that a device
* was discovered.
* @param onFoundNewPairedDevice Callback that gets invoked when a device
* was found that passed the filter (see [deviceFilterCallback])
* and is paired. Exceptions thrown by this callback are logged,
* but not propagated. Discovery is stopped before this is called.
* @throws IllegalStateException if this is called again after
* discovery has been started already, or if the interface
* is in a state in which discovery is not possible, such as
* a Bluetooth subsystem that has been shut down.
* @throws BluetoothPermissionException if discovery fails because
* scanning and connection permissions are missing.
* @throws BluetoothException if discovery fails due to an underlying
* Bluetooth issue.
*/
fun startDiscovery(
sdpServiceName: String,
sdpServiceProvider: String,
sdpServiceDescription: String,
btPairingPin: String,
discoveryDuration: Int,
onDiscoveryStopped: (reason: DiscoveryStoppedReason) -> Unit,
onFoundNewPairedDevice: (deviceAddress: BluetoothAddress) -> Unit
)
/**
* Stops any ongoing discovery.
*
* If no discovery is going on, this does nothing.
*/
fun stopDiscovery()
/**
* Creates and returns a BluetoothDevice for the given address.
*
* This merely creates a new BluetoothDevice instance. It does
* not connect to the device. Use [BluetoothDevice.connect]
* for that purpose.
*
* NOTE: Creating multiple instances to the same device is
* possible, but untested.
*
* @return BluetoothDevice instance for the device with the
* given address
* @throws IllegalStateException if the interface is in a state
* in which accessing devices is not possible, such as
* a Bluetooth subsystem that has been shut down.
*/
fun getDevice(deviceAddress: BluetoothAddress): BluetoothDevice
/**
* Returns the friendly (= human-readable) name for the adapter.
*
* @throws BluetoothPermissionException if getting the adapter name
* fails because connection permissions are missing.
* @throws BluetoothException if getting the adapter name fails
* due to an underlying Bluetooth issue.
*/
fun getAdapterFriendlyName(): String
/**
* Returns a set of addresses of paired Bluetooth devices.
*
* The [deviceFilterCallback] is applied here. That is, the returned set
* only contains addresses of devices which passed that filter.
*
* The return value is a new set, not a reference to an internal
* one, so it is safe to use even if devices get paired/unpaired
* in the meantime.
*
* To avoid a race condition where an unpaired device is missed
* when an application is starting, it is recommended to first
* assign the [onDeviceUnpaired] callback, and then retrieve the
* list of paired addresses here. If it is done the other way
* round, it is possible that between the [getPairedDeviceAddresses]
* call and the [onDeviceUnpaired] assignment, a device is
* unpaired, and thus does not get noticed.
*/
fun getPairedDeviceAddresses(): Set<BluetoothAddress>
}

View file

@ -0,0 +1,40 @@
package info.nightscout.comboctl.base
/**
* Computes the CRC-16-MCRF4XX checksum out of the given data.
*
* This function can be called repeatedly on various buffer views if
* all of them are to be covered by the same checksum. In that case,
* simply pass the previously computed checksum as currentChecksum
* argument to get an updated checksum. Otherwise, just using the
* default value 0xFFFF (the "initial seed") is enough.
*
* @param data Data to compute the checksum out of.
* @param currentChecksum Current checksum, or 0xFFFF as initial seed.
* @return The computed checksum.
*/
fun calculateCRC16MCRF4XX(data: List<Byte>, currentChecksum: Int = 0xFFFF): Int {
// Original implementation from https://gist.github.com/aurelj/270bb8af82f65fa645c1#gistcomment-2884584
if (data.isEmpty())
return currentChecksum
var newChecksum = currentChecksum
for (dataByte in data) {
var t: Int
var L: Int
newChecksum = newChecksum xor dataByte.toPosInt()
// The "and 0xFF" are needed since the original C implementation
// worked with implicit 8-bit logic, meaning that only the lower
// 8 bits are kept - the rest is thrown away.
L = (newChecksum xor (newChecksum shl 4)) and 0xFF
t = ((L shl 3) or (L shr 5)) and 0xFF
L = L xor (t and 0x07)
t = (t and 0xF8) xor (((t shl 1) or (t shr 7)) and 0x0F) xor (newChecksum shr 8)
newChecksum = (L shl 8) or t
}
return newChecksum
}

View file

@ -0,0 +1,104 @@
package info.nightscout.comboctl.base
const val CIPHER_KEY_SIZE = 16
const val CIPHER_BLOCK_SIZE = 16
/**
* Class for en- and decrypting packets going to and coming from the Combo.
*
* The packets are encrypted using the Twofish symmetric block cipher.
* It en- and decrypts blocks of 128 bits (16 bytes). Key size too is 128 bits.
*
* @property key The 128-bit key for en- and decrypting. Initially set to null.
* Callers must first set this to a valid non-null value before any
* en- and decrypting can be performed.
*/
class Cipher(val key: ByteArray) {
init {
require(key.size == CIPHER_KEY_SIZE)
}
private val keyObject = Twofish.processKey(key)
/**
* Encrypts a 128-bit block of cleartext, producing a 128-bit ciphertext block.
*
* The key must have been set to a valid value before calling this function.
*
* @param cleartext Array of 16 bytes (128 bits) of cleartext to encrypt.
* @return Array of 16 bytes (128 bits) of ciphertext.
*/
fun encrypt(cleartext: ByteArray): ByteArray {
require(cleartext.size == CIPHER_BLOCK_SIZE)
return Twofish.blockEncrypt(cleartext, 0, keyObject)
}
/**
* Decrypts a 128-bit block of ciphertext, producing a 128-bit cleartext block.
*
* The key must have been set to a valid value before calling this function.
*
* @param ciphertext Array of 16 bytes (128 bits) of ciphertext to decrypt.
* @return Array of 16 bytes (128 bits) of cleartext.
*/
fun decrypt(ciphertext: ByteArray): ByteArray {
require(ciphertext.size == CIPHER_BLOCK_SIZE)
return Twofish.blockDecrypt(ciphertext, 0, keyObject)
}
override fun toString() = key.toHexString(" ")
}
fun String.toCipher() = Cipher(this.split(" ").map { it.toInt(radix = 16).toByte() }.toByteArray())
/**
* Generates a weak key out of a 10-digit PIN.
*
* The weak key is needed during the Combo pairing process. The
* 10-digit PIN is displayed on the Combo's LCD, and the user has
* to enter it into whatever program is being paired with the Combo.
* Out of that PIN, the "weak key" is generated. That key is used
* for decrypting a subsequently incoming packet that contains
* additional keys that are used for en- and decrypting followup
* packets coming from and going to the Combo.
*
* @param PIN Pairing PIN to use for generating the weak key.
* @return 16 bytes containing the generated 128-bit weak key.
*/
fun generateWeakKeyFromPIN(PIN: PairingPIN): ByteArray {
// Verify that the PIN is smaller than the cipher key.
// NOTE: This could be a compile-time check, since these
// are constants. But currently, it is not known how to
// do this check at compile time.
require(PAIRING_PIN_SIZE < CIPHER_KEY_SIZE)
val generatedKey = ByteArray(CIPHER_KEY_SIZE)
// The weak key generation algorithm computes the first
// 10 bytes simply by looking at the first 10 PIN
// digits, interpreting them as characters, and using the
// ASCII indices of these characters. For example, suppose
// that the first PIN digit is 2. It is interpreted as
// character "2". That character has ASCII index 50.
// Therefore, the first byte in the key is set to 50.
for (i in 0 until PAIRING_PIN_SIZE) {
val pinDigit = PIN[i]
val pinAsciiIndex = pinDigit + '0'.code
generatedKey[i] = pinAsciiIndex.toByte()
}
// The PIN has 10 digits, not 16, but the key has 16
// bytes. For the last 6 bytes, the first 6 digits are
// treated just like above, except that the ASCII index
// is XORed with 0xFF.
for (i in 0 until 6) {
val pinDigit = PIN[i]
val pinAsciiIndex = pinDigit + '0'.code
generatedKey[i + PAIRING_PIN_SIZE] = (0xFF xor pinAsciiIndex).toByte()
}
return generatedKey
}

View file

@ -0,0 +1,12 @@
package info.nightscout.comboctl.base
/**
* Base class for ComboCtl specific exceptions.
*
* @param message The detail message.
* @param cause Throwable that further describes the cause of the exception.
*/
open class ComboException(message: String?, cause: Throwable?) : Exception(message, cause) {
constructor(message: String) : this(message, null)
constructor(cause: Throwable) : this(null, cause)
}

View file

@ -0,0 +1,261 @@
package info.nightscout.comboctl.base
// Combo frames are delimited by the FRAME_DELIMITER byte 0xCC. Each
// frame begins and ends with this byte. Binary payload itself can
// also contain that byte, however. To solve this, there is the
// ESCAPE_BYTE 0x77. If payload itself contains a 0xCC byte, it is
// escaped by replacing that byte with the 2-byte sequence 0x77 0xDD.
// If the escape byte 0x77 itself is in the payload, then that byte
// is replaced by the 2-byte sequence 0x77 0xEE.
private const val FRAME_DELIMITER = 0xCC.toByte()
private const val ESCAPE_BYTE = 0x77.toByte()
private const val ESCAPED_FRAME_DELIMITER = 0xDD.toByte()
private const val ESCAPED_ESCAPE_BYTE = 0xEE.toByte()
/**
* Exception thrown when parsing a Combo frame fails.
*
* @param message The detail message.
*/
class FrameParseException(message: String) : ComboException(message)
/**
* Parses incoming streaming data to isolate frames and extract their payload.
*
* The Combo uses RFCOMM for communication, which is a stream based communication
* channel, not a datagram based one. Therefore, it is necessary to use some sort
* of framing mechanism to denote where frames begin and end.
*
* This class parses incoming data to detect the beginning of a frame. Then, it
* continues to parse data until it detects the end of the current frame. At that
* point, it extracts the payload from inside that frame, and returns it. This is
* done by [parseFrame].
*
* The payload is a transport layer packet. See [TransportLayerIO.Packet] for details
* about those.
*/
class ComboFrameParser {
/**
* Resets the internal state of the parser, discarding any accumulated data.
*
* This readies the parser for an entirely new transmission.
*/
fun reset() {
accumulationBuffer.clear()
currentReadOffset = 0
frameStarted = false
frameStartOffset = 0
}
/**
* Pushes incoming data into the parser's accumulation buffer for parsing.
*
* Call this only when [parseFrame] returns null, since incoming data blocks
* may contain more than one frame, meaning that the data passed to one
* [pushData] call may allow for multiple [parseFrame] calls that return
* parsed payload.
*
* @param data Data to push into the parser.
*/
fun pushData(data: List<Byte>) {
accumulationBuffer.addAll(data)
}
/**
* Parses previously accumulated data and extracts a frame if one is detected.
*
* This searches the accumulated data for a frame delimiter character. If one
* is found, the parser continues to look for a second delimiter. These two
* delimiters then denote the beginning and end of a frame. At that point, this
* function will extract the payload in between the delimiters.
*
* This function also takes care of un-escaping characters if necessary.
*
* If there currently is no complete frame in the accumulation buffer, this
* returns null. In that case, the user is supposed to call [pushData] to place
* more data into the accumulation buffer to parse.
*
* @return Payload of a detected frame, or null if no complete frame was
* currently found.
* @throws FrameParseException in case of invalid data.
*/
fun parseFrame(): List<Byte>? {
// The part that begins at currentReadOffset is not yet parsed.
// Look through it to see if a frame delimiter can be found.
while (currentReadOffset < accumulationBuffer.size) {
// Get the next byte. We use readNextFrameByte() here to handle
// escaped bytes.
val currentByteInfo = readNextFrameByte(currentReadOffset)
// Get the current byte. It is set to null when readNextFrameByte()
// finds an escape byte, but there's currently no more data after
// that byte. See readNextFrameByte() for more about this.
val currentByte: Byte = currentByteInfo.first ?: return null
val currentByteWasEscaped = currentByteInfo.third
val oldReadOffset = currentReadOffset
currentReadOffset = currentByteInfo.second
if (frameStarted) {
// The start of a frame was previously detected. Continue
// to parse data until either the end of the current accumulated
// data is reached or a frame delimiter byte is found.
if (currentByte == FRAME_DELIMITER) {
// Found a frame delimiter byte. If it wasn't present as
// an escaped byte, it means it actually does delimit
// the end of the current frame. if so, extract its payload.
if (currentByteWasEscaped) {
// In this case, the byte was escaped, so it is part
// of the payload, and not the end of the current frame.
continue
}
// Extract the frame's payload, un-escaping any escaped
// bytes inside (done by the readNextFrameByte() call).
val framePayload = ArrayList<Byte>()
val frameEndOffset = currentReadOffset - 1
var frameReadOffset = frameStartOffset
while (frameReadOffset < frameEndOffset) {
val nextByteInfo = readNextFrameByte(frameReadOffset)
frameReadOffset = nextByteInfo.second
framePayload.add(nextByteInfo.first!!)
}
// After extracting the frame, remove its data from the
// accumulation buffer and keep track of what data is left
// in there. We need to keep that remainder around, since
// it may be the start of a new frame.
accumulationBuffer.subList(0, currentReadOffset).clear()
frameStarted = false
currentReadOffset = 0
return framePayload
}
} else {
// No frame start was detected so far. Combo transmissions
// pack frames seamlessly together, meaning that as soon as
// a frame delimiter denotes the end of a frame, or an RFCOMM
// connection is freshly set up, the next byte can only be
// a frame delimiter. Anything else would indicate bogus data.
// Also, in this case, we don't assume that bytes could be
// escaped, since escaping only makes sense _within_ a frame.
if (currentByte == FRAME_DELIMITER) {
frameStarted = true
frameStartOffset = currentReadOffset
continue
} else {
throw FrameParseException(
"Found non-delimiter byte ${currentByte.toHexString(2)} " +
"outside of frames (surrounding context: ${accumulationBuffer.toHexStringWithContext(oldReadOffset)})"
)
}
}
}
return null
}
private fun readNextFrameByte(readOffset: Int): Triple<Byte?, Int, Boolean> {
// The code here reads bytes from the accumulation buffer. If the
// start of a frame was detected, it also handles un-escaping
// escaped bytes (see the explanation at the top of this source).
// It returns a triple of the parsed byte, the updated read offset,
// and a boolean that is true if the read byte was present in escaped
// escaped form in the accumulated data. The latter is important to
// be able to distinguish actual frame delimiters and escape bytes
// from payload bytes that happen to have the same value as these
// special bytes. In case no byte could be parsed, null is returned.
// We can't read anything, since there is no data that can be parsed.
// Return null as byte value to indicate to the caller that we need
// to accumulate more data.
if (readOffset >= accumulationBuffer.size)
return Triple(null, readOffset, false)
val curByte = accumulationBuffer[readOffset]
// If we are outside of a frame, just return the current byte directly.
// Outside of a frame, only frame delimiter bytes should exist anyway.
if (!frameStarted)
return Triple(curByte, readOffset + 1, false)
return if (curByte == ESCAPE_BYTE) {
// We found an escape byte. We need the next byte to see what
// particular byte was escaped by it.
if (readOffset == (accumulationBuffer.size - 1)) {
// Can't determine the output yet since we need 2 bytes to determine
// what the output should be, and currently we only have one byte;
// the escape byte. Return null as byte value to indicate to the
// caller that we need to accumulate more data.
Triple(null, readOffset, false)
} else {
when (accumulationBuffer[readOffset + 1]) {
ESCAPED_FRAME_DELIMITER -> Triple(FRAME_DELIMITER, readOffset + 2, true)
ESCAPED_ESCAPE_BYTE -> Triple(ESCAPE_BYTE, readOffset + 2, true)
else -> {
throw FrameParseException(
"Found escape byte, but followup byte ${accumulationBuffer[readOffset + 1].toHexString(2)} " +
"is not a valid combination (surrounding context: ${accumulationBuffer.toHexStringWithContext(readOffset + 1)})"
)
}
}
}
} else {
// This is not an escape byte, so just output it directly.
Triple(curByte, readOffset + 1, false)
}
}
private var accumulationBuffer = ArrayList<Byte>()
private var currentReadOffset: Int = 0
private var frameStarted: Boolean = false
private var frameStartOffset: Int = 0
}
/**
* Produces a Combo frame out of the given payload.
*
* The Combo uses RFCOMM for communication, which is a stream based communication
* channel, not a datagram based one. Therefore, it is necessary to use some sort
* of framing mechanism to denote where frames begin and end.
*
* This function places payload (a list of bytes) inside a frame so that it is
* suitable for transmission over RFCOMM.
*
* The reverse functionality is provided by the [ComboFrameParser] class.
*
* The payload is a transport layer packet. See [TransportLayerIO.Packet] for
* details about those.
*
* @return Framed version of this payload.
*/
fun List<Byte>.toComboFrame(): List<Byte> {
val escapedFrameData = ArrayList<Byte>()
escapedFrameData.add(FRAME_DELIMITER)
for (inputByte in this) {
when (inputByte) {
FRAME_DELIMITER -> {
escapedFrameData.add(ESCAPE_BYTE)
escapedFrameData.add(ESCAPED_FRAME_DELIMITER)
}
ESCAPE_BYTE -> {
escapedFrameData.add(ESCAPE_BYTE)
escapedFrameData.add(ESCAPED_ESCAPE_BYTE)
}
else -> escapedFrameData.add(inputByte)
}
}
escapedFrameData.add(FRAME_DELIMITER)
return escapedFrameData
}

View file

@ -0,0 +1,203 @@
package info.nightscout.comboctl.base
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
/**
* Interface for Combo IO operations.
*
* The send and receive functions are suspending functions to be
* able to fo pairing and regular sessions by using coroutines.
* Subclasses concern themselves with adapting blocking IO APIs
* and framing the data in some way. Subclasses can also choose
* to use Flows, Channels, and RxJava/RxKotlin mechanisms
* if they wish.
*
* IO errors in subclasses are communicated to callers by
* throwing exceptions.
*/
interface ComboIO {
/**
* Sends the given block of bytes, suspending the coroutine until it is done.
*
* This function either transmits all of the bytes, or throws an
* exception if this fails. Partial transmissions are not done.
* An exception is also thrown if sending fails due to something
* that's not an error, like when a connection is closed.
*
* If an exception is thrown, the data is to be considered not
* having been sent.
*
* @param dataToSend The data to send. Must not be empty.
* @throws CancellationException if sending is aboted due to
* a terminated IO, typically due to some sort of
* disconnect function call. (This is _not_ thrown if
* the remote side closes the connection! In such a
* case, ComboIOException is thrown instead.)
* @throws ComboIOException if sending fails.
* @throws IllegalStateException if this object is in a state
* that does not permit sending, such as a device
* that has been shut down or isn't connected.
*/
suspend fun send(dataToSend: List<Byte>)
/**
* Receives a block of bytes, suspending the coroutine until it finishes.
*
* If receiving fails, an exception is thrown. An exception
* is also thrown if receiving fails due to something that's not
* an error, like when a connection is closed.
*
* @return Received block of bytes. This is never empty.
* @throws CancellationException if receiving is aboted due to
* a terminated IO, typically due to some sort of
* disconnect function call. (This is _not_ thrown if
* the remote side closes the connection! In such a
* case, ComboIOException is thrown instead.)
* @throws ComboIOException if receiving fails.
* @throws IllegalStateException if this object is in a state
* that does not permit receiving, such as a device
* that has been shut down or isn't connected.
*/
suspend fun receive(): List<Byte>
}
/**
* Abstract combo IO class for adapting blocking IO APIs.
*
* The implementations of the ComboIO interface send and receive
* calls internally use blocking send/receive functions and run
* them in the IO context to make sure their blocking behavior
* does not block the coroutine. Subclasses must implement
* blockingSend and blockingReceive.
*
* @property ioDispatcher [CoroutineDispatcher] where the
* [blockingSend] and [blockingReceive] calls shall take place.
* These calls may block threads for a nontrivial amount of time,
* so the dispatcher must be suitable for that. On JVM and Android
* platforms, there is an IO dispatcher for this.
*/
abstract class BlockingComboIO(val ioDispatcher: CoroutineDispatcher) : ComboIO {
final override suspend fun send(dataToSend: List<Byte>) {
withContext(ioDispatcher) {
blockingSend(dataToSend)
}
}
final override suspend fun receive(): List<Byte> {
return withContext(ioDispatcher) {
blockingReceive()
}
}
/**
* Blocks the calling thread until the given block of bytes is fully sent.
*
* In case of an error, or some other reason why sending
* cannot be done (like a closed connection), an exception
* is thrown.
*
* This function sends atomically. Either, the entire data
* is sent, or none of it is sent (the latter happens in
* case of an exception).
*
* @param dataToSend The data to send. Must not be empty.
* @throws CancellationException if sending is aboted due to
* a terminated IO, typically due to some sort of
* disconnect function call. (This is _not_ thrown if
* the remote side closes the connection! In such a
* case, ComboIOException is thrown instead.)
* @throws ComboIOException if sending fails.
* @throws IllegalStateException if this object is in a state
* that does not permit sending, such as a device
* that has been shut down or isn't connected.
*/
abstract fun blockingSend(dataToSend: List<Byte>)
/**
* Blocks the calling thread until a given block of bytes is received.
*
* In case of an error, or some other reason why receiving
* cannot be done (like a closed connection), an exception
* is thrown.
*
* @return Received block of bytes. This is never empty.
*
* @throws CancellationException if receiving is aboted due to
* a terminated IO, typically due to some sort of
* disconnect function call. (This is _not_ thrown if
* the remote side closes the connection! In such a
* case, ComboIOException is thrown instead.)
* @throws ComboIOException if receiving fails.
* @throws IllegalStateException if this object is in a state
* that does not permit receiving, such as a device
* that has been shut down or isn't connected.
*/
abstract fun blockingReceive(): List<Byte>
}
/**
* ComboIO subclass that puts data into Combo frames and uses
* another ComboIO object for the actual transmission.
*
* This is intended to be used for composing framed IO with
* another ComboIO subclass. This allows for easily adding
* Combo framing without having to modify ComboIO subclasses
* or having to manually integrate the Combo frame parser.
*
* @property io Underlying ComboIO to use for sending
* and receiving&parsing framed data.
*/
class FramedComboIO(private val io: ComboIO) : ComboIO {
override suspend fun send(dataToSend: List<Byte>) = io.send(dataToSend.toComboFrame())
override suspend fun receive(): List<Byte> {
try {
// Loop until a full frame is parsed, an
// error occurs, or the job is canceled.
// In the latter two cases, an exception
// is thrown, so we won't end up in an
// infinite loop here.
while (true) {
val parseResult = frameParser.parseFrame()
if (parseResult == null) {
frameParser.pushData(io.receive())
continue
}
return parseResult
}
} catch (e: CancellationException) {
frameParser.reset()
throw e
} catch (e: ComboIOException) {
frameParser.reset()
throw e
}
}
/**
* Resets the internal frame parser.
*
* Resetting means that any partial frame data inside
* the parse is discarded. This is useful if this IO
* object is reused.
*/
fun reset() {
frameParser.reset()
}
private val frameParser = ComboFrameParser()
}
/**
* Base class for exceptions related to IO operations from/to the Combo.
*
* @param message The detail message.
* @param cause Throwable that further describes the cause of the exception.
*/
open class ComboIOException(message: String?, cause: Throwable?) : ComboException(message, cause) {
constructor(message: String) : this(message, null)
constructor(cause: Throwable) : this(null, cause)
}

View file

@ -0,0 +1,37 @@
package info.nightscout.comboctl.base
object Constants {
/**
* Hard-coded Bluetooth pairing PIN used by the Combo.
*
* This is not to be confused with the PIN that is displayed on
* the Combo's LCD when pairing it with a device. That other PIN
* is part of a non-standard, Combo specific pairing process that
* happens _after_ the Bluetooth pairing has been completed.
*/
const val BT_PAIRING_PIN = "}gZ='GD?gj2r|B}>"
/**
* SDP service record name the Combo searches for during discovery.
*
* Any SerialPort SDP service record that is not named like
* this is ignored by the Combo.
*/
const val BT_SDP_SERVICE_NAME = "SerialLink"
/**
* Client software version number for TL_REQUEST_ID packets.
*
* This version number is used as the payload of TL_REQUEST_ID
* packets that are sent to the Combo during the pairing process.
*/
const val CLIENT_SOFTWARE_VERSION = 10504
/**
* Serial number for AL_CTRL_CONNECT packets.
*
* This serial number is used as the payload of AL_CTRL_CONNECT
* packets that are sent to the Combo when connecting to it.
*/
const val APPLICATION_LAYER_CONNECT_SERIAL_NUMBER = 12345
}

View file

@ -0,0 +1,9 @@
package info.nightscout.comboctl.base
import kotlinx.coroutines.Dispatchers
// Dispatcher that enforces sequential execution of coroutines,
// thus disallowing parallelism. This is important, since parallel
// IO is not supported by the Combo and only causes IO errors.
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
internal val sequencedDispatcher = Dispatchers.Default.limitedParallelism(1)

View file

@ -0,0 +1,71 @@
package info.nightscout.comboctl.base
const val DISPLAY_FRAME_WIDTH = 96
const val DISPLAY_FRAME_HEIGHT = 32
// One frame consists of 96x32 pixels.
const val NUM_DISPLAY_FRAME_PIXELS = DISPLAY_FRAME_WIDTH * DISPLAY_FRAME_HEIGHT
/**
* Class containing a 96x32 pixel black&white Combo display frame.
*
* These frames are sent by the Combo when it is operating
* in the remote terminal (RT) mode.
*
* The pixels are stored in row-major order. One boolean equals
* one pixel.
*
* Note that this is not the layout of the pixels as transmitted
* by the Combo. Rather, the pixels are rearranged in a layout
* that is more commonly used and easier to work with.
*
* @property displayFramePixels Pixels of the display frame to use.
* The array has to have exactly NUM_DISPLAY_FRAME_PIXELS
* booleans.
*/
data class DisplayFrame(val displayFramePixels: BooleanArray) : Iterable<Boolean> {
/**
* Number of display frame pixels.
*
* This mainly exists to make this class compatible with
* code that operates on collections.
*/
val size = NUM_DISPLAY_FRAME_PIXELS
init {
require(displayFramePixels.size == size)
}
/**
* Returns the pixel at the given coordinates.
*
* @param x X coordinate. Valid range is 0..95 (inclusive).
* @param y Y coordinate. Valid range is 0..31 (inclusive).
* @return true if the pixel at these coordinates is set,
* false if it is cleared.
*/
fun getPixelAt(x: Int, y: Int) = displayFramePixels[x + y * DISPLAY_FRAME_WIDTH]
operator fun get(index: Int) = displayFramePixels[index]
override operator fun iterator() = displayFramePixels.iterator()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as DisplayFrame
if (!displayFramePixels.contentEquals(other.displayFramePixels)) return false
return true
}
override fun hashCode(): Int {
return displayFramePixels.contentHashCode()
}
}
/**
* Display frame filled with empty pixels. Useful for initializations.
*/
val NullDisplayFrame = DisplayFrame(BooleanArray(NUM_DISPLAY_FRAME_PIXELS) { false })

View file

@ -0,0 +1,165 @@
package info.nightscout.comboctl.base
private const val NUM_DISPLAY_FRAME_BYTES = NUM_DISPLAY_FRAME_PIXELS / 8
/**
* Class for assembling RT_DISPLAY application layer packet rows to a complete display frame.
*
* RT_DISPLAY packets contain 1/4th of a frame. These subsets are referred to as "rows".
* Since a frame contains 96x32 pixels, each row contains 96x8 pixels.
*
* This class assembles these rows into complete frames. To that end, it has to convert
* the layout the pixels are arranged in into a more intuitive column-major bitmap layout.
* The result is a [DisplayFrame] instance with all of the frame's pixels in row-major
* layout. See the [DisplayFrame] documentation for more details about its layout.
*
* The class is designed for streaming use. This means that it can be continuously fed
* the contents of RT_DISPLAY packets, and it will keep producing frames once it has
* enough data to complete a frame. When it completed one, it returns the frame, and
* wipes its internal row collection, allowing it to start from scratch to be able to
* begin completing a new frame.
*
* If frame data with a different index is fed into the assembler before the frame completion
* is fully done, it also resets itself. The purpose of the index is to define what frame
* each row belongs to. That way, it is assured that rows of different frames cannot be
* mixed up together. Without the index, if for some reason one RT_DISPLAY packet isn't
* received, the assembler would assemble a frame incorrectly.
*
* In practice, it is not necessary to keep this in mind. Just feed data into the assembler
* by calling its main function, [DisplayFrameAssembler.processRTDisplayPayload]. When that
* function returns null, just keep going. When it returns a [DisplayFrame] instance, then
* this is a complete frame that can be further processed / analyzed.
*
* Example:
*
* ```
* val assembler = DisplayFrameAssembler()
*
* while (receivingPackets()) {
* val rtDisplayPayload = applicationLayer.parseRTDisplayPacket(packet)
*
* val displayFrame = assembler.processRTDisplayPayload(
* rtDisplayPayload.index,
* rtDisplayPayload.row,
* rtDisplayPayload.pixels
* )
* if (displayFrame != null) {
* // Output the completed frame
* }
* }
* ```
*/
class DisplayFrameAssembler {
private val rtDisplayFrameRows = mutableListOf<List<Byte>?>(null, null, null, null)
private var currentIndex: Int? = null
private var numRowsLeftUnset = 4
/**
* Main assembly function.
*
* This feeds RT_DISPLAY data into the assembler. The index is the RT_DISPLAY
* index value. The row is a value in the 0-3 range, specifying what row this
* is about. rowBytes is the byte list containing the pixel bytes from the packet.
* This list must contain exactly 96 bytes, since the whole frame is made of
* 384 bytes, and there are 4 rows, so each row contains 384 / 4 = 96 bytes.
*
* (The incoming frame rows store pixels as bits, not as booleans, which is
* why one frame consists of 384 bytes. 96 * 32 pixels, 8 bits per byte,
* one bits per pixel -> 96 * 32 / 8 = 384 bytes.)
*
* @param index RT_DISPLAY index value.
* @param row Row number, in the 0-3 range (inclusive).
* @param rowBytes RT_DISPLAY pixel bytes.
* @return null if no frame could be completed yet. A DisplayFrame instance
* if the assembler had enough data to complete a frame.
*/
fun processRTDisplayPayload(index: Int, row: Int, rowBytes: List<Byte>): DisplayFrame? {
require(rowBytes.size == NUM_DISPLAY_FRAME_BYTES / 4) {
"Expected ${NUM_DISPLAY_FRAME_BYTES / 4} bytes in rowBytes list, got ${rowBytes.size} (index: $index row: $row)"
}
// Check if we got data from a different frame. If so, we have to throw
// away any previously collected data, since it belongs to a previous frame.
if (index != currentIndex) {
reset()
currentIndex = index
}
// If we actually are _adding_ a new row, decrement the numRowsLeftUnset
// counter. That counter specifies how many row entries are still set to
// null. Once the counter reaches zero, it means the rtDisplayFrameRows
// list is fully populated, and we can complete the frame.
if (rtDisplayFrameRows[row] == null)
numRowsLeftUnset -= 1
rtDisplayFrameRows[row] = rowBytes
return if (numRowsLeftUnset == 0) {
val displayFrame = assembleDisplayFrame()
currentIndex = null
displayFrame
} else
null
}
/**
* Main assembly function.
*
* This is an overloaded variant of [processRTDisplayPayload] that accepts
* an [ApplicationLayer.RTDisplayPayload] instance instead of the individual
* index, row, pixels arguments.
*/
fun processRTDisplayPayload(rtDisplayPayload: ApplicationLayer.RTDisplayPayload): DisplayFrame? =
processRTDisplayPayload(rtDisplayPayload.index, rtDisplayPayload.row, rtDisplayPayload.rowBytes)
/**
* Resets the state of the assembler.
*
* This resets internal states to their initial values, discarding any
* partial frames that might have been received earlier.
*
* Usually, this is only called internally. However, it is useful to call
* this if the outside code itself got reset, for example after a reconnect
* event. In such situations, it is a good idea to discard any existing state.
*/
fun reset() {
rtDisplayFrameRows.fill(null)
numRowsLeftUnset = 4
}
private fun assembleDisplayFrame(): DisplayFrame {
val displayFramePixels = BooleanArray(NUM_DISPLAY_FRAME_PIXELS) { false }
// (Note: Display frame rows are not to be confused with pixel rows. See the
// class description for details about the display frame rows.)
// Pixels are stored in the RT_DISPLAY display frame rows in a column-major
// order. Also, the rightmost column is actually stored first, and the leftmost
// one last. And since each display frame row contains 1/4th of the entire display
// frame, this means it contains 8 pixel rows. This in turn means that this
// layout stores one byte per column. So, the first byte in the display frame row
// contains the pixels from (x 95 y 0) to (x 95 y 7). The second byte contains
// pixels from (x 94 y 0) to (x 94 y 7) etc.
for (row in 0 until 4) {
val rtDisplayFrameRow = rtDisplayFrameRows[row]!!
for (column in 0 until DISPLAY_FRAME_WIDTH) {
// Get the 8 pixels from the current column.
// We invert the index by subtracting it from
// 95, since, as described above, the first
// byte actually contains the rightmost column.
val byteWithColumnPixels = rtDisplayFrameRow[95 - column].toPosInt()
// Scan the 8 pixels in the selected column.
for (y in 0 until 8) {
// Isolate the current pixel.
val pixel = ((byteWithColumnPixels and (1 shl y)) != 0)
if (pixel) {
val destPixelIndex = column + (y + row * 8) * DISPLAY_FRAME_WIDTH
displayFramePixels[destPixelIndex] = true
}
}
}
}
return DisplayFrame(displayFramePixels)
}
}

View file

@ -0,0 +1,216 @@
package info.nightscout.comboctl.base
/**
* Simple directed cyclic graph data structure.
*
* This class is intended to be used primarily for navigating through
* the graph and finding shortest paths through it. For this reason,
* there is an overall collection of all nodes (the [nodes] [Map]),
* while there is no corresponding overall collection of edges.
*
* Edges are all directional. If there is a bidirectional connection
* between nodes, it must be modeled with two directional edges.
*
* Nodes and edges have associated user defined values. Node values
* must be unique, since they are used as a key in [nodes].
*
* Note: Nodes and edges can only be added, not removed. This
* class isn't intended for graphs that change after construction.
*
* Constructing a graph is usually done with [apply], like this:
*
* ```
* val myGraph = Graph<Int, String>().apply {
* // Create some nodes with integer values
* val node1 = node(10)
* val node2 = node(20)
* val node3 = node(30)
* val node4 = node(40)
*
* // Connect two nodes in one direction (from node1 to node2) by
* // creating one edge. The new edge's value is "edge_1_2".
* connectDirectionally("edge_1_2", node1, node2)
* // Connect two nodes in both directions by creating two edges,
* // with the node2->node3 edge's value being "edge_2_3", and
* // the node3->node2 edge's value being "edge_3_2".
* connectBidirectionally("edge_2_3", "edge_3_2", node2, node3)
* // Connect more than 2 nodes in one direction. Two new edges
* // are created, node1->node2 and node2->node4. Both edges have
* // the same value "edge_N".
* connectDirectionally("edge_N", node1, node2, node4)
* }
* ```
*/
class Graph<NodeValue, EdgeValue> {
/**
* Directional edge in the graph.
*
* @param value User defined value associated with this edge.
* @param targetNode Node this edge leads to.
*/
inner class Edge(val value: EdgeValue, val targetNode: Node)
/**
* Node in the graph.
*
* @param value User defined value associated with this node.
*/
inner class Node(val value: NodeValue) {
private val _edges = mutableListOf<Edge>()
val edges: List<Edge> = _edges
internal fun connectTo(targetNode: Node, edgeValue: EdgeValue): Edge {
val newEdge = Edge(edgeValue, targetNode)
_edges.add(newEdge)
return newEdge
}
}
private val _nodes = mutableMapOf<NodeValue, Node>()
val nodes: Map<NodeValue, Node> = _nodes
/**
* Constructs a new [Node] with the given user defined [value].
*/
fun node(value: NodeValue): Node {
val newNode = Node(value)
_nodes[value] = newNode
return newNode
}
}
/**
* Segment of a path found by [findShortestPath].
*
* An edge that is part of the shortest path equals one path segment.
* [targetNodeValue] is the [Graph.Edge.targetNode] field of that edge,
* [edgeValue] the user-defined value of that edge.
*/
data class PathSegment<NodeValue, EdgeValue>(val targetNodeValue: NodeValue, val edgeValue: EdgeValue)
/**
* Convenience [findShortestPath] overload based on user-defined node values.
*
* This is a shortcut for accessing "from" and "to" nodes
* from [Graph.node] based on user-defined node values.
* If values are specified that are not associated with
* nodes, [IllegalArgumentException] is thrown.
*/
fun <NodeValue, EdgeValue> Graph<NodeValue, EdgeValue>.findShortestPath(
from: NodeValue,
to: NodeValue,
edgePredicate: (edgeValue: EdgeValue) -> Boolean = { true }
): List<PathSegment<NodeValue, EdgeValue>>? {
val fromNode = nodes[from] ?: throw IllegalArgumentException()
val toNode = nodes[to] ?: throw IllegalArgumentException()
return findShortestPath(fromNode, toNode, edgePredicate)
}
/**
* Finds the shortest path between two nodes.
*
* The path starts at [fromNode] and ends at [toNode]. If no path between
* these two nodes can be found, null is returned. If [fromNode] and
* [toNode] are the same, an empty list is returned. If no path can be
* found, null is returned.
*
* The [edgePredicate] decides whether an edge in the graph shall be
* traversed as part of the search. If the predicate returns false,
* the edge is skipped. This is useful for filtering out edges if the
* node they lead to is disabled/invalid for some reason. The predicate
* takes as its argument the value of the edge. The default predicate
* always returns true and thus allows all edges to be traversed.
*
* @param fromNode Start node of the shortest path.
* @param toNode End node of the shortest path.
* @param edgePredicate Predicate to apply to each edge during the search.
* @return Shortest path, or null if no such path exists.
*/
fun <NodeValue, EdgeValue> Graph<NodeValue, EdgeValue>.findShortestPath(
fromNode: Graph<NodeValue, EdgeValue>.Node,
toNode: Graph<NodeValue, EdgeValue>.Node,
edgePredicate: (edgeValue: EdgeValue) -> Boolean = { true }
): List<PathSegment<NodeValue, EdgeValue>>? {
if (fromNode === toNode)
return listOf()
val visitedNodes = mutableListOf<Graph<NodeValue, EdgeValue>.Node>()
val path = mutableListOf<PathSegment<NodeValue, EdgeValue>>()
fun visitAdjacentNodes(node: Graph<NodeValue, EdgeValue>.Node): Boolean {
if (node in visitedNodes)
return false
for (edge in node.edges) {
if (edgePredicate(edge.value) && (edge.targetNode === toNode)) {
path.add(0, PathSegment(edge.targetNode.value, edge.value))
return true
}
}
visitedNodes.add(node)
for (edge in node.edges) {
if (edgePredicate(edge.value) && visitAdjacentNodes(edge.targetNode)) {
path.add(0, PathSegment(edge.targetNode.value, edge.value))
return true
}
}
return false
}
return if (visitAdjacentNodes(fromNode))
path
else
null
}
fun <NodeValue, EdgeValue> connectDirectionally(
fromToEdgeValue: EdgeValue,
vararg nodes: Graph<NodeValue, EdgeValue>.Node
) {
require(nodes.size >= 2)
for (i in 0 until (nodes.size - 1)) {
val fromNode = nodes[i]
val toNode = nodes[i + 1]
fromNode.connectTo(toNode, fromToEdgeValue)
}
}
fun <NodeValue, EdgeValue> connectBidirectionally(
fromToEdgeValue: EdgeValue,
toFromEdgeValue: EdgeValue,
vararg nodes: Graph<NodeValue, EdgeValue>.Node
) {
require(nodes.size >= 2)
for (i in 0 until (nodes.size - 1)) {
val fromNode = nodes[i]
val toNode = nodes[i + 1]
fromNode.connectTo(toNode, fromToEdgeValue)
toNode.connectTo(fromNode, toFromEdgeValue)
}
}
/**
* Utility function to dump a graph using GraphViz DOT notation.
*
* This can be used for visualizing a graph using common GraphViz tools.
*/
fun <NodeValue, EdgeValue> Graph<NodeValue, EdgeValue>.toDotGraph(graphName: String): String {
var dotGraphText = "strict digraph \"$graphName\" {\n"
for (node in nodes.values) {
for (edge in node.edges) {
val fromNodeValue = node.value
val toNodeValue = edge.targetNode.value
dotGraphText += " \"$fromNodeValue\"->\"$toNodeValue\" [ label=\"${edge.value}\" ]\n"
}
}
dotGraphText += "}\n"
return dotGraphText
}

View file

@ -0,0 +1,129 @@
package info.nightscout.comboctl.base
/**
* Valid log levels.
*
* VERBOSE is recommended for log lines that produce large amounts of log information
* since they are called constantly. One example would be a packet data dump.
*
* DEBUG is recommended for detailed information about the internals that does _not_
* show up all the time (unlike VERBOSE).
*
* INFO is to be used for important information.
*
* WARN is to be used for situations that could lead to errors or are otherwise
* of potential concern.
*
* ERROR is to be used for when an error occurs.
*/
enum class LogLevel(val str: String, val numericLevel: Int) {
VERBOSE("VERBOSE", 4),
DEBUG("DEBUG", 3),
INFO("INFO", 2),
WARN("WARN", 1),
ERROR("ERROR", 0)
}
/**
* Interface for backends that actually logs the given message.
*/
interface LoggerBackend {
/**
* Instructs the backend to log the given message.
*
* The tag and log level are provided so the backend can highlight
* these in its output in whatever way it wishes.
*
* In addition, a throwable can be logged in case the log line
* was caused by one. The [SingleTagLogger.invoke] call may have provided
* only a message string, or a throwable, or both, which is why both of these
* arguments are nullable.
*
* @param tag Tag for this message. Typically, this is the name of the
* class the log operation was performed in.
* @param level Log level of the given message.
* @param message Optional string containing the message to log.
* @param throwable Optional throwable.
*/
fun log(tag: String, level: LogLevel, throwable: Throwable?, message: String?)
}
/**
* Backend that does not actually log anything. Logical equivalent of Unix' /dev/null.
*/
class NullLoggerBackend : LoggerBackend {
override fun log(tag: String, level: LogLevel, throwable: Throwable?, message: String?) = Unit
}
/**
* Backend that prints log lines to stderr.
*/
class StderrLoggerBackend : LoggerBackend {
override fun log(tag: String, level: LogLevel, throwable: Throwable?, message: String?) {
val timestamp = getElapsedTimeInMs()
val stackInfo = Throwable().stackTrace[1]
val className = stackInfo.className.substringAfterLast(".")
val methodName = stackInfo.methodName
val lineNumber = stackInfo.lineNumber
val fullMessage = "[${timestamp.toStringWithDecimal(3).padStart(10, ' ')}] " +
"[${level.str}] [$tag] [$className.$methodName():$lineNumber]" +
(if (throwable != null) " (${throwable::class.qualifiedName}: \"${throwable.message}\")" else "") +
(if (message != null) " $message" else "")
System.err.println(fullMessage)
}
}
class SingleTagLogger(val tag: String) {
inline operator fun invoke(logLevel: LogLevel, logLambda: () -> String) {
if (logLevel.numericLevel <= Logger.threshold.numericLevel)
Logger.backend.log(tag, logLevel, null, logLambda.invoke())
}
operator fun invoke(logLevel: LogLevel, throwable: Throwable) {
if (logLevel.numericLevel <= Logger.threshold.numericLevel)
Logger.backend.log(tag, logLevel, throwable, null)
}
inline operator fun invoke(logLevel: LogLevel, throwable: Throwable, logLambda: () -> String) {
if (logLevel.numericLevel <= Logger.threshold.numericLevel)
Logger.backend.log(tag, logLevel, throwable, logLambda.invoke())
}
}
/**
* Main logging interface.
*
* Applications can set a custom logger backend simply by setting
* the [Logger.backend] variable to a new value. By default, the
* [StderrLoggerBackend] is used.
*
* The logger is used by adding a line like this at the top of:
* a source file:
*
* private val logger = Logger.get("TagName")
*
* Then, in the source, logging can be done like this:
*
* logger(LogLevel.DEBUG) { "Logging example" }
*
* This logs the "Logging example line" with the DEBUG log level
* and the "TagName" tag (see [LoggerBackend.log] for details).
*
* Additionally, the threshold value provides a way to prefilter
* logging based on the log level. Only log calls with the log
* level of the threshold value (or below) are logged. This helps
* with reducing log spam, and improves performance, since the
* log line lambdas (shown above) are then only invoked if the
* log level is <= the threshold. In practice, this is most useful
* for enabling/disabling verbose logging. Verbose log lines are
* only really useful for development and more advanced debugging;
* in most cases, [LogLevel.DEBUG] as threshold should suffice.
*/
object Logger {
var threshold = LogLevel.DEBUG
var backend: LoggerBackend = StderrLoggerBackend()
fun get(tag: String) = SingleTagLogger(tag)
}

View file

@ -0,0 +1,32 @@
package info.nightscout.comboctl.base
const val NUM_MAC_BYTES = 8
/**
* Class containing an 8-byte machine authentication code.
*/
data class MachineAuthCode(private val macBytes: List<Byte>) : Iterable<Byte> {
/**
* Number of MAC bytes (always 8).
*
* This mainly exists to make this class compatible with
* code that operates on collections.
*/
val size = NUM_MAC_BYTES
init {
require(macBytes.size == size)
}
operator fun get(index: Int) = macBytes[index]
override operator fun iterator() = macBytes.iterator()
override fun toString() = macBytes.toHexString()
}
/**
* MAC consisting of 8 nullbytes. Useful for initializations and
* for the first few pairing packets that don't use MACs.
*/
val NullMachineAuthCode = MachineAuthCode(List(NUM_MAC_BYTES) { 0x00 })

View file

@ -0,0 +1,90 @@
package info.nightscout.comboctl.base
const val NUM_NONCE_BYTES = 13
/**
* Class containing a 13-byte nonce.
*
* The nonce is a byte sequence used in Combo transport layer packets.
* It uniquely identifies a packet, since each nonce is only ever used once.
* After sending out a packet, the current tx nonce gets incremented, and the
* next packet that is to be sent out will use the incremented nonce.
*
* This class is immutable, since modification operations on a nonce don't
* really make any sense. The only two modification-like operations one ever
* wants to perform is assigning the 13 bytes (done by the constructor) and
* incrementing (done by [getIncrementedNonce] returning an incremented copy).
* The behavior of [getIncrementedNonce] may seem wasteful at first, but it
* actually isn't, because one would have to do a copy of the current tx nonce
* anyway to make sure nonces assigned to outgoing packets retain their original
* value even after the current tx nonce was incremented (since assignments
* of non-primitives in Kotlin by default are done by-reference).
*/
data class Nonce(private val nonceBytes: List<Byte>) : Iterable<Byte> {
/**
* Number of Nonce bytes (always 13).
*
* This mainly exists to make this class compatible with
* code that operates on collections.
*/
val size = NUM_NONCE_BYTES
init {
// Check that we actually got exactly 13 nonce bytes.
require(nonceBytes.size == size)
}
operator fun get(index: Int) = nonceBytes[index]
override operator fun iterator() = nonceBytes.iterator()
override fun toString() = nonceBytes.toHexString(" ")
/**
* Return an incremented copy of this nonce.
*
* This nonce's bytes will not be modified by this call.
*
* @param incrementAmount By how much the nonce is to be incremented.
* Must be at least 1.
*/
fun getIncrementedNonce(incrementAmount: Int = 1): Nonce {
require(incrementAmount >= 1)
val outputNonceBytes = ArrayList<Byte>(NUM_NONCE_BYTES)
var carry = 0
var leftoverToIncrement = incrementAmount
nonceBytes.forEach { nonceByte ->
val a = leftoverToIncrement and 255
val b = nonceByte.toPosInt()
val sum = a + b + carry
val outputByte = sum and 255
leftoverToIncrement = leftoverToIncrement ushr 8
carry = sum ushr 8
outputNonceBytes.add(outputByte.toByte())
if ((leftoverToIncrement == 0) && (carry == 0))
return@forEach
}
for (i in outputNonceBytes.size until NUM_NONCE_BYTES)
outputNonceBytes.add(0x00.toByte())
return Nonce(outputNonceBytes)
}
companion object {
/**
* Convenience function to create a nonce with 13 nullbytes.
*
* Useful for initializations.
*/
fun nullNonce() = Nonce(List(NUM_NONCE_BYTES) { 0x00 })
}
}
fun String.toNonce() = Nonce(this.split(" ").map { it.toInt(radix = 16).toByte() })

View file

@ -0,0 +1,84 @@
package info.nightscout.comboctl.base
const val PAIRING_PIN_SIZE = 10
/**
* Class containing a 10-digit pairing PIN.
*
* This PIN is needed during the pairing process. The Combo shows a 10-digit
* PIN on its display. The user then has to enter that PIN in the application.
* This class contains such entered PINs.
*
* @param pinDigits PIN digits. Must be an array of exactly 10 Ints.
* The Ints must be in the 0-9 range.
*/
data class PairingPIN(val pinDigits: IntArray) : Iterable<Int> {
// This code was adapted from:
// https://discuss.kotlinlang.org/t/defining-a-type-for-a-fixed-length-array/12817/2
/**
* Number of PIN digits (always 10).
*
* This mainly exists to make this class compatible with
* code that operates on collections.
*/
val size = PAIRING_PIN_SIZE
init {
require(pinDigits.size == PAIRING_PIN_SIZE)
// Verify that all ints are a number between 0 and 9,
// since they are supposed to be digits.
for (i in 0 until PAIRING_PIN_SIZE) {
val pinDigit = pinDigits[i]
require((pinDigit >= 0) && (pinDigit <= 9))
}
}
/**
* Get operator to access the digit with the given index.
*
* @param index Digit index. Must be a value between 0 and 9 (since the PIN has 10 digits).
* @return The digit.
*/
operator fun get(index: Int) = pinDigits[index]
/**
* Set operator to set the digit with the given index.
*
* @param index Digit index. Must be a value between 0 and 9 (since the PIN has 10 digits).
* @param pinDigit The new digit to set at the given index.
*/
operator fun set(index: Int, pinDigit: Int) {
require((pinDigit >= 0) && (pinDigit <= 9))
pinDigits[index] = pinDigit
}
// Custom equals operator to compare content,
// which doesn't happen by default with arrays.
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null) return false
if (this::class != other::class) return false
other as PairingPIN
if (!pinDigits.contentEquals(other.pinDigits)) return false
return true
}
override fun hashCode(): Int {
return pinDigits.contentHashCode()
}
override operator fun iterator() = pinDigits.iterator()
/**
* Utility function to print the PIN in the format the Combo prints it on its display.
*
* The format is 012-345-6789.
*/
override fun toString() = "${pinDigits[0]}${pinDigits[1]}${pinDigits[2]}-" +
"${pinDigits[3]}${pinDigits[4]}${pinDigits[5]}-" +
"${pinDigits[6]}${pinDigits[7]}${pinDigits[8]}${pinDigits[9]}"
}
fun nullPairingPIN() = PairingPIN(intArrayOf(0, 0, 0, 0, 0, 0, 0, 0, 0, 0))

View file

@ -0,0 +1,248 @@
package info.nightscout.comboctl.base
import kotlin.reflect.KClassifier
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
private val logger = Logger.get("Pump")
/**
* Base class for specifying a stage for a [ProgressReporter] instance.
*
* @property id ID string, useful for serialization and logging.
*/
open class ProgressStage(val id: String)
/**
* Progress stages for basic operations.
*/
object BasicProgressStage {
// Fundamental stages, used for starting / ending a progress sequence.
// The Aborted stage base class is useful to be able to catch all possible
// abort reasons and also differentiate between them.
object Idle : ProgressStage("idle")
open class Aborted(id: String) : ProgressStage(id)
object Cancelled : Aborted("cancelled")
object Timeout : Aborted("timeout")
class Error(val cause: Throwable) : Aborted("error")
object Finished : ProgressStage("finished")
// Connection related stages.
object ScanningForPumpStage : ProgressStage("scanningForPump")
/**
* Bluetooth connection establishing stage.
*
* The connection setup may require several attempts on some platforms.
* If the number of attempts so far exceeds the total number, the
* connection attempt fails. If no total number is set (that is,
* (totalNumAttempts is set to null), then there is no defined limit.
* This is typically used when the caller manually aborts connection
* attempts after a while.
*
* @property currentAttemptNr Current attempt number, starting at 1.
* @property totalNumAttempts Total number of attempts that will be done,
* or null if no total number is defined.
*/
data class EstablishingBtConnection(val currentAttemptNr: Int, val totalNumAttempts: Int?) :
ProgressStage("establishingBtConnection")
object PerformingConnectionHandshake : ProgressStage("performingConnectionHandshake")
// Pairing related stages.
object ComboPairingKeyAndPinRequested : ProgressStage("comboPairingKeyAndPinRequested")
object ComboPairingFinishing : ProgressStage("comboPairingFinishing")
}
/**
* Report with updated progress information.
*
* @property stageNumber Current progress stage number, starting at 0.
* If stageNumber == numStages, then the stage is always
* [BasicProgressStage.Finished] or a subclass of [BasicProgressStage.Aborted].
* @property numStages Total number of stages in the progress sequence.
* @property stage Information about the current stage.
* @property overallProgress Numerical indicator for the overall progress.
* Valid range is 0.0 - 1.0, with 0.0 being the beginning of
* the progress, and 1.0 specifying the end.
*/
data class ProgressReport(val stageNumber: Int, val numStages: Int, val stage: ProgressStage, val overallProgress: Double)
/**
* Class for reporting progress updates.
*
* "Progress" is defined here as a planned sequence of [ProgressStage] instances.
* These stages describe information about the current progress. Stage instances
* can contain varying information, such as the index of the factor that is
* currently being set in a basal profile, or the IUs of a bolus that were
* administered so far.
*
* A sequence always begins with [BasicProgressStage.Idle] and ends with either
* [BasicProgressStage.Finished] or a subclass of [BasicProgressStage.Aborted]. These are
* special in that they are never explicitly specified in the sequence. [BasicProgressStage.Idle]
* is always set as the current flow value when the reporter is created and when
* [reset] is called. The other two are passed to [setCurrentProgressStage], which
* then immediately forwards them in a [ProgressReport] instance, with that instance's
* stage number set to [numStages] (since both of these stages define the end of a sequence).
*
* In code that reports progress, the [setCurrentProgressStage] function is called
* to deliver updates to the reporter, which then forwards that update to subscribers
* of the [progressFlow]. The reporter takes care of checking where in the sequence
* that stage is. Stages are indexed by stage numbers, which start at 0. The size
* of the sequence equals [numStages].
*
* Updates to the flow are communicated as [ProgressReport] instances. They provide
* subscribers with the necessary information to show details about the current
* stage and to compute a progress percentage (useful for GUI progress bar elements).
*
* Example of how to use this class:
*
* First, the reporter is instantiated, like this:
*
* ```
* val reporter = ProgressReporter(listOf(
* BasicProgressStage.StartingConnectionSetup::class,
* BasicProgressStage.EstablishingBtConnection::class,
* BasicProgressStage.PerformingConnectionHandshake::class
* ))
* ```
*
* Code can then report an update like this:
*
* ```
* reporter.setCurrentProgressStage(BasicProgressStage.EstablishingBtConnection(1, 4))
* ```
*
* This will cause the reporter to publish a [ProgressReport] instance through its
* [progressFlow], looking like this:
*
* ```
* ProgressReport(stageNumber = 1, numStages = 3, stage = BasicProgressStage.EstablishingBtConnection(1, 4))
* ```
*
* This allows code to report progress without having to know what its current
* stage number is (so it does not have to concern itself about providing a correct
* progress percentage). Also, that way, code that reports progress can be combined.
* For example, if function A contains setCurrentProgressStage calls, then the
* function that called A can continue to report progress. And, the setCurrentProgressStage
* calls from A can also be used to report progress in an entirely different function.
* One actual example is the progress reported when a Bluetooth connection is being
* established. This is used both during pairing and when setting up a regular
* connection, without having to write separate progress report code for both.
*
* @param plannedSequence The planned progress sequence, as a list of ProgressStage
* classes. This never contains [BasicProgressStage.Idle],
* [BasicProgressStage.Finished], or a [BasicProgressStage.Aborted] subclass.
* @param context User defined contxt to pass to computeOverallProgressCallback.
* This can be updated via [reset] calls.
* @param computeOverallProgressCallback Callback for computing an overall progress
* indication out of the current stage. Valid range for the return value
* is 0.0 to 1.0. See [ProgressReport] for an explanation of the arguments.
* Default callback calculates (stageNumber / numStages) and uses the result.
*/
class ProgressReporter<Context>(
private val plannedSequence: List<KClassifier>,
private var context: Context,
private val computeOverallProgressCallback: (stageNumber: Int, numStages: Int, stage: ProgressStage, context: Context) -> Double =
{ stageNumber: Int, numStages: Int, _: ProgressStage, _: Context -> stageNumber.toDouble() / numStages.toDouble() }
) {
private var currentStageNumber = 0
private val mutableProgressFlow =
MutableStateFlow(ProgressReport(0, plannedSequence.size, BasicProgressStage.Idle, 0.0))
/**
* Flow for getting progress reports.
*/
val progressFlow = mutableProgressFlow.asStateFlow()
/**
* Total number of stages in the sequence.
*/
val numStages = plannedSequence.size
/**
* Resets the reporter to its initial state.
*
* The flow's state will be set to a report whose stage is [BasicProgressStage.Idle].
*
* @param context User defined contxt to pass to computeOverallProgressCallback.
* Replaces the context passed to the constructor.
*/
fun reset(context: Context) {
this.context = context
reset()
}
/**
* Resets the reporter to its initial state.
*
* The flow's state will be set to a report whose stage is [BasicProgressStage.Idle].
*
* This overload works just like the other one, except that it keeps the context intact.
*/
fun reset() {
currentStageNumber = 0
mutableProgressFlow.value = ProgressReport(0, numStages, BasicProgressStage.Idle, 0.0)
}
/**
* Sets the current stage and triggers an update via a [ProgressReport] instance through the [progressFlow].
*
* If the process that is being tracked by this reported was cancelled
* or aborted due to an error, pass a subclass of [BasicProgressStage.Aborted]
* as the stage argument. This will trigger a report with the stage number
* set to the total number of stages (to signify that the work is over)
* and the stage set to the [BasicProgressStage.Aborted] subclass.
*
* If the process finished successfully, do the same as written above,
* except using [BasicProgressStage.Finished] as the stage instead.
*
* @param stage Stage of the progress to report.
*/
fun setCurrentProgressStage(stage: ProgressStage) {
when (stage) {
is BasicProgressStage.Finished,
is BasicProgressStage.Aborted -> {
currentStageNumber = numStages
mutableProgressFlow.value = ProgressReport(currentStageNumber, numStages, stage, 1.0)
return
}
else -> Unit
}
if (stage::class != plannedSequence[currentStageNumber]) {
// Search forward first. Typically, this succeeds, since stages
// are reported in the order specified in the sequence.
var succeedingStageNumber = plannedSequence.subList(currentStageNumber + 1, numStages).indexOfFirst {
stage::class == it
}
currentStageNumber = if (succeedingStageNumber == -1) {
// Unusual case: An _earlier_ stage was reported. This is essentially
// a backwards progress (= a regress?). It is not unthinkable that
// this can happen, but it should be rare. In that case, we have
// to search backwards in the sequence.
val precedingStageNumber = plannedSequence.subList(0, currentStageNumber).indexOfFirst {
stage::class == it
}
// If the progress info was not found in the sequence, log this and exit.
// Do not throw; a missing progress info ID in the sequence is not
// a fatal error, so do not break the application because of it.
if (precedingStageNumber == -1) {
logger(LogLevel.WARN) { "Progress stage \"$stage\" not found in stage sequence; not passing it to flow" }
return
}
precedingStageNumber
} else {
// Need to add (currentStageNumber + 1) as an offset, since the indexOfFirst
// call returns indices that are based on the sub list, not the entire list.
succeedingStageNumber + (currentStageNumber + 1)
}
}
mutableProgressFlow.value = ProgressReport(
currentStageNumber, numStages, stage,
computeOverallProgressCallback(currentStageNumber, numStages, stage, context)
)
}
}

View file

@ -0,0 +1,329 @@
package info.nightscout.comboctl.base
import kotlinx.datetime.UtcOffset
/**
* Pump related data that is set during pairing and not changed afterwards.
*
* This data is created by [PumpIO.performPairing]. Once it is
* created, it does not change until the pump is unpaired, at
* which point it is erased. This data is managed by the
* [PumpStateStore] class, which stores / retrieves it.
*
* @property clientPumpCipher This cipher is used for authenticating
* packets going to the Combo.
* @property pumpClientCipher This cipher is used for verifying
* packets coming from the Combo.
* @property keyResponseAddress The address byte of a previously
* received KEY_RESPONSE packet. The source and destination
* address values inside this address byte must have been
* reordered to match the order that outgoing packets expect.
* That is: Source address stored in the upper, destination
* address in the lower 4 bit of the byte. (In incoming
* packets - and KEY_RESPONSE is an incoming packet - these
* two are ordered the other way round.)
* @property pumpID The pump ID from the ID_RESPONSE packet.
* This is useful for displaying the pump in a UI, since the
* Bluetooth address itself may not be very clear to the user.
*/
data class InvariantPumpData(
val clientPumpCipher: Cipher,
val pumpClientCipher: Cipher,
val keyResponseAddress: Byte,
val pumpID: String
) {
companion object {
/**
* Convenience function to create an instance with default "null" values.
*
* Useful for an initial state.
*/
fun nullData() =
InvariantPumpData(
clientPumpCipher = Cipher(ByteArray(CIPHER_KEY_SIZE)),
pumpClientCipher = Cipher(ByteArray(CIPHER_KEY_SIZE)),
keyResponseAddress = 0x00.toByte(),
pumpID = ""
)
}
}
/**
* The current state of an ongoing TBR (if any).
*
* Due to limitations of the Combo, it is necessary to remember this
* current state in case the client crashes and restarts, otherwise
* it is not possible to deduce the starting timestamp (incl. the
* UTC offset at the time of the TBR start) and whether or not a
* previously started TBR finished in the meantime. To work around
* this limitation, we store that information in the [PumpStateStore]
* in a persistent fashion.
*/
sealed class CurrentTbrState {
/**
* No TBR is currently ongoing. If the main screen shows TBR info
* while this is the current TBR state it means that an unknown
* TBR was started, for example by the user while the client
* application that uses comboctl was down.
*/
object NoTbrOngoing : CurrentTbrState()
/**
* TBR is currently ongoing. This stores a [Tbr] instance with
* its timestamp specifying when the TBR started. If the main
* screen shows no TBR info while this is the current TBR state,
* it means that the TBR ended already.
*/
data class TbrStarted(val tbr: Tbr) : CurrentTbrState()
}
/**
* Exception thrown when accessing the stored state of a specific pump fails.
*
* @param pumpAddress Bluetooth address of the pump whose
* state could not be accessed or created.
* @param message The detail message.
* @param cause The exception that was thrown in the loop specifying
* want went wrong there.
*/
class PumpStateStoreAccessException(val pumpAddress: BluetoothAddress, message: String?, cause: Throwable?) :
ComboException(message, cause) {
constructor(pumpAddress: BluetoothAddress, message: String) : this(pumpAddress, message, null)
constructor(pumpAddress: BluetoothAddress, cause: Throwable) : this(pumpAddress, null, cause)
}
/**
* Exception thrown when trying to create a new pump state even though one already exists.
*
* @param pumpAddress Bluetooth address of the pump.
*/
class PumpStateAlreadyExistsException(val pumpAddress: BluetoothAddress) :
ComboException("Pump state for pump with address $pumpAddress already exists")
/**
* Exception thrown when trying to access new pump state that does not exist.
*
* @param pumpAddress Bluetooth address of the pump.
*/
class PumpStateDoesNotExistException(val pumpAddress: BluetoothAddress) :
ComboException("Pump state for pump with address $pumpAddress does not exist")
/**
* State store interface for a specific pump.
*
* This interface provides access to a store that persistently
* records the data of [InvariantPumpData] instances along with
* the current Tx nonce, UTC offset, and TBR state.
*
* As the name suggests, these states are recorded persistently,
* immediately, and ideally also atomically. If atomic storage cannot
* be guaranteed, then there must be some sort of data error detection
* in place to ensure that no corrupted data is retrieved. (For example,
* if the device running ComboCtl crashes or freezes while the data is
* being written into the store, the data may not be written completely.)
*
* There is one state for each paired pump. Each instance contains the
* [InvariantPumpData], which does not change after the pump was paired,
* the Tx nonce, which does change after each packet that is sent to
* the Combo, the UTC offset, which usually does not change often, and
* the TBR state, which changes whenever a TBR starts/ends. These parts
* of a pump's state are kept separate due to the difference in access
* patterns (that is, how often they are updated), since this allows for
* optimizations in implementations.
*
* Each state is associate with a pump via the pump's Bluetooth address.
*
* If a function or property access throws [PumpStateStoreAccessException],
* then the state is to be considered invalid, any existing connections
* to a pump associated with the state must be terminated, and the pump must
* be unpaired. This is because such an exception indicates an error in
* the underlying pump state store implementation that said implementation
* could not recover from. And this also implies that this pump's state inside
* the store is in an undefined state - it cannot be relied upon anymore.
* Internally, the implementation must delete any remaining state data when
* such an error occurs. Callers must then also unpair the pump at the Bluetooth
* level. The user must be told about this error, and instructed that the pump
* must be paired again.
*
* Different pump states can be accessed, created, deleted concurrently.
* However, operations on the same state must not happen concurrently.
* For example, it is valid to create a pump state while an existing [PumpIO]
* instance updates the Tx nonce of its associated state, but no two threads
* may update the Tx nonce at the same time, or try to access state data and
* delete the same state simultaneously, or access a pump state's Tx nonce
* while another thread writes a new UTC offset into the same pump state.
*
* The UTC offset that is stored for each pump here exists because the Combo
* is unaware of timezones or UTC offsets. All the time data it stores is
* in localtime. The UTC offset in this store specifies what UTC offset
* to associate any current Combo localtime timestamp with. Particularly
* for the bolus history this is very important, since it allows for properly
* handling daylight savings changes and timezone changes (for example because
* the user is on vacation in another timezone). The stored UTC offset is
* also necessary to be able to detect UTC offset changes even if they
* happen when the client is not running. The overall rule with regard
* to UTC offset changes and stored Combo localtime timestamps is that
* all currently stored timestamps use the currently stored UTC offset,
* and any timestamps that might be stored later on will use the new
* UTC offset. In practice, this means that all timestamps from the Combo's
* command mode history delta use the current UTC offset, and after the
* delta was fetched, the UTC offset is updated.
*
* Finally, the stored TBR state exists because of limitations in the Combo
* regarding ongoing TBR information. See [CurrentTbrState] for details.
*/
interface PumpStateStore {
/**
* Creates a new pump state and fills the state's invariant data.
*
* This is called during the pairing process. In regular
* connections, this is not used. It initializes a state for the pump
* with the given ID in the store. Before this call, trying to access
* the state with [getInvariantPumpData], [getCurrentTxNonce],
* [setCurrentTxNonce], [getCurrentUtcOffset], [getCurrentTbrState]
* fails with an exception. The new state's nonce is set to a null
* nonce (= all of its bytes set to zero). The UTC offset is set to
* the one from the current system timezone and system time. The
* TBR state is set to [CurrentTbrState.NoTbrOngoing].
*
* The state is removed by calling [deletePumpState].
*
* Subclasses must store the invariant pump data immediately and persistently.
*
* @param pumpAddress Bluetooth address of the pump to create a state for.
* @param invariantPumpData Invariant pump data to use in the new state.
* @param utcOffset Initial UTC offset value to use in the new state.
* @param tbrState Initial TBR state to use in the new state.
* @throws PumpStateAlreadyExistsException if there is already a state
* with the given Bluetooth address.
* @throws PumpStateStoreAccessException if writing the new state fails
* due to an error that occurred in the underlying implementation.
*/
fun createPumpState(
pumpAddress: BluetoothAddress,
invariantPumpData: InvariantPumpData,
utcOffset: UtcOffset,
tbrState: CurrentTbrState
)
/**
* Deletes a pump state that is associated with the given address.
*
* If there is no such state, this returns false.
*
* NOTE: This does not throw.
*
* @param pumpAddress Bluetooth address of the pump whose corresponding
* state in the store shall be deleted.
* @return true if there was such a state, false otherwise.
*/
fun deletePumpState(pumpAddress: BluetoothAddress): Boolean
/**
* Checks if there is a valid state associated with the given address.
*
* @return true if there is one, false otherwise.
*/
fun hasPumpState(pumpAddress: BluetoothAddress): Boolean
/**
* Returns a set of Bluetooth addresses of the states in this store.
*/
fun getAvailablePumpStateAddresses(): Set<BluetoothAddress>
/**
* Returns the [InvariantPumpData] from the state associated with the given address.
*
* @throws PumpStateDoesNotExistException if no pump state associated with
* the given address exists in the store.
* @throws PumpStateStoreAccessException if accessing the data fails
* due to an error that occurred in the underlying implementation.
*/
fun getInvariantPumpData(pumpAddress: BluetoothAddress): InvariantPumpData
/**
* Returns the current Tx [Nonce] from the state associated with the given address.
*
* @throws PumpStateDoesNotExistException if no pump state associated with
* the given address exists in the store.
* @throws PumpStateStoreAccessException if accessing the nonce fails
* due to an error that occurred in the underlying implementation.
*/
fun getCurrentTxNonce(pumpAddress: BluetoothAddress): Nonce
/**
* Sets the current Tx [Nonce] in the state associated with the given address.
*
* Subclasses must store the new Tx nonce immediately and persistently.
*
* @throws PumpStateDoesNotExistException if no pump state associated with
* the given address exists in the store.
* @throws PumpStateStoreAccessException if accessing the nonce fails
* due to an error that occurred in the underlying implementation.
*/
fun setCurrentTxNonce(pumpAddress: BluetoothAddress, currentTxNonce: Nonce)
/**
* Returns the current UTC offset that is to be used for all timestamps from now on.
*
* See the [PumpStateStore] documentation for details about this offset.
*
* @throws PumpStateDoesNotExistException if no pump state associated with
* the given address exists in the store.
* @throws PumpStateStoreAccessException if accessing the nonce fails
* due to an error that occurred in the underlying implementation.
*/
fun getCurrentUtcOffset(pumpAddress: BluetoothAddress): UtcOffset
/**
* Sets the current UTC offset that is to be used for all timestamps from now on.
*
* See the [PumpStateStore] documentation for details about this offset.
*
* @throws PumpStateDoesNotExistException if no pump state associated with
* the given address exists in the store.
* @throws PumpStateStoreAccessException if accessing the nonce fails
* due to an error that occurred in the underlying implementation.
*/
fun setCurrentUtcOffset(pumpAddress: BluetoothAddress, utcOffset: UtcOffset)
/**
* Returns the TBR state that is currently known for the pump with the given [pumpAddress].
*
* See the [CurrentTbrState] documentation for details about this offset.
*
* @throws PumpStateDoesNotExistException if no pump state associated with
* the given address exists in the store.
* @throws PumpStateStoreAccessException if accessing the nonce fails
* due to an error that occurred in the underlying implementation.
*/
fun getCurrentTbrState(pumpAddress: BluetoothAddress): CurrentTbrState
/**
* Sets the TBR state that is currently known for the pump with the given [pumpAddress].
*
* See the [CurrentTbrState] documentation for details about this offset.
*
* @throws PumpStateDoesNotExistException if no pump state associated with
* the given address exists in the store.
* @throws PumpStateStoreAccessException if accessing the nonce fails
* due to an error that occurred in the underlying implementation.
*/
fun setCurrentTbrState(pumpAddress: BluetoothAddress, currentTbrState: CurrentTbrState)
}
/*
* Increments the nonce of a pump state associated with the given address.
*
* @param pumpAddress Bluetooth address of the pump state.
* @param incrementAmount By how much the nonce is to be incremented.
* Must be at least 1.
*/
fun PumpStateStore.incrementTxNonce(pumpAddress: BluetoothAddress, incrementAmount: Int = 1): Nonce {
require(incrementAmount >= 1)
val currentTxNonce = this.getCurrentTxNonce(pumpAddress)
val newTxNonce = currentTxNonce.getIncrementedNonce(incrementAmount)
this.setCurrentTxNonce(pumpAddress, newTxNonce)
return newTxNonce
}

View file

@ -0,0 +1,101 @@
package info.nightscout.comboctl.base
import kotlinx.datetime.Instant
/**
* Data class containing details about a TBR (temporary basal rate).
*
* This is typically associated with some event or record about a TBR that just
* started or stopped. The timestamp is stored as an [Instant] to preserve the
* timezone offset that was used at the time when the TBR started / stopped.
*
* The valid TBR percentage range is 0-500. 100 would mean 100% and is not actually
* a TBR, but is sometimes used to communicate a TBR cancel operation. Only integer
* multiples of 10 are valid (for example, 210 is valid, 209 isn't).
*
* If [percentage] is 100, the [durationInMinutes] is ignored. Otherwise, this
* argument must be in the 15-1440 range (= 15 minutes to 24 hours), and must
* be an integer multiple of 15.
*
* The constructor checks that [percentage] is valid. [durationInMinutes] is
* not checked, however, since there are cases where this class is used with
* TBRs that have a duration that is not an integer multiple of 15. In particular,
* this is relevant when cancelled / aborted TBRs are reported; their duration
* typically isn't an integer multiple of 15. It is recommended to call
* [checkDurationForCombo] before using the values of this TBR for programming
* the Combo's TBR.
*
* @property timestamp Timestamp when the TBR started/stopped.
* @property percentage TBR percentage.
* @property durationInMinutes Duration of the TBR, in minutes.
* @property type Type of this TBR.
*/
data class Tbr(val timestamp: Instant, val percentage: Int, val durationInMinutes: Int, val type: Type) {
enum class Type(val stringId: String) {
/**
* Normal TBR.
* */
NORMAL("normal"),
/**
* 15-minute 0% TBR that is produced when the Combo is stopped.
* This communicates to callers that there is no insulin delivery
* when the pump is stopped.
*/
COMBO_STOPPED("comboStopped"),
/**
* Caller emulates a stopped pump by setting a special 0% TBR.
*
* Actually stopping the Combo has other side effects, so typically,
* if for example the pump's cannula is to be disconnected, this
* TBR type is used instead.
*/
EMULATED_COMBO_STOP("emulatedComboStop"),
/**
* Caller wanted to cancel a bolus but without actually setting a 100 percent TBR to avoid the W6 warning.
*
* Normally, a TBR is cancelled by replacing it with a 100% "TBR".
* Doing so however always triggers a W6 warning. As an alternative,
* for example, an alternating sequence of 90% and 100% TBRs can
* be used. Such TBRs would use this as their type.
*/
EMULATED_100_PERCENT("emulated100Percent"),
/**
* TBR set when a superbolus is delivered.
*/
SUPERBOLUS("superbolus");
companion object {
private val values = values()
/**
* Converts a string ID to a [Tbr.Type].
*
* @return TBR type, or null if there is no matching type.
*/
fun fromStringId(stringId: String) = values.firstOrNull { it.stringId == stringId }
}
}
init {
require((percentage >= 0) && (percentage <= 500)) { "Invalid percentage $percentage; must be in the 0-500 range" }
require((percentage % 10) == 0) { "Invalid percentage $percentage; must be integer multiple of 10" }
}
/**
* Checks the [durationInMinutes] value and throws an [IllegalArgumentException] if it is not suited for the Combo.
*
* [durationInMinutes] is considered unsuitable if it is not an integer
* multiple of 15 and/or if it is not in the 15-1440 range.
*/
fun checkDurationForCombo() {
if (percentage == 100)
return
require((durationInMinutes >= 15) && (durationInMinutes <= (24 * 60))) {
"Invalid duration $durationInMinutes; must be in the 15 - ${24 * 60} range"
}
require((durationInMinutes % 15) == 0) { "Invalid duration $durationInMinutes; must be integer multiple of 15" }
}
}

View file

@ -0,0 +1,656 @@
package info.nightscout.comboctl.base
/**
* Implementation of the Two-Fish symmetric block cipher.
*
* This is based on the Java Twofish code from the Android Jobb tool:
*
* https://android.googlesource.com/platform/tools/base/+/master/jobb/src/main/java/Twofish
*
* which in turn is based on the Twofish implementation from Bouncy Castle.
*
* The three public API functions of interest are [processKey], [blockEncrypt],
* and [blockDecrypt]. Note that the latter two always process 16-byte blocks.
*/
object Twofish {
/**********************
* INTERNAL CONSTANTS *
**********************/
private const val BLOCK_SIZE = 16 // bytes in a data-block
private const val MAX_ROUNDS = 16 // max # rounds (for allocating subkeys)
// Subkey array indices
private const val INPUT_WHITEN = 0
private const val OUTPUT_WHITEN = INPUT_WHITEN + BLOCK_SIZE / 4
private const val ROUND_SUBKEYS = OUTPUT_WHITEN + BLOCK_SIZE / 4 // 2*(# rounds)
private const val TOTAL_SUBKEYS = ROUND_SUBKEYS + 2 * MAX_ROUNDS
private const val SK_STEP = 0x02020202
private const val SK_BUMP = 0x01010101
private const val SK_ROTL = 9
// Fixed 8x8 permutation S-boxes
private val P = arrayOf(
intArrayOf(
// p0
0xA9, 0x67, 0xB3, 0xE8,
0x04, 0xFD, 0xA3, 0x76,
0x9A, 0x92, 0x80, 0x78,
0xE4, 0xDD, 0xD1, 0x38,
0x0D, 0xC6, 0x35, 0x98,
0x18, 0xF7, 0xEC, 0x6C,
0x43, 0x75, 0x37, 0x26,
0xFA, 0x13, 0x94, 0x48,
0xF2, 0xD0, 0x8B, 0x30,
0x84, 0x54, 0xDF, 0x23,
0x19, 0x5B, 0x3D, 0x59,
0xF3, 0xAE, 0xA2, 0x82,
0x63, 0x01, 0x83, 0x2E,
0xD9, 0x51, 0x9B, 0x7C,
0xA6, 0xEB, 0xA5, 0xBE,
0x16, 0x0C, 0xE3, 0x61,
0xC0, 0x8C, 0x3A, 0xF5,
0x73, 0x2C, 0x25, 0x0B,
0xBB, 0x4E, 0x89, 0x6B,
0x53, 0x6A, 0xB4, 0xF1,
0xE1, 0xE6, 0xBD, 0x45,
0xE2, 0xF4, 0xB6, 0x66,
0xCC, 0x95, 0x03, 0x56,
0xD4, 0x1C, 0x1E, 0xD7,
0xFB, 0xC3, 0x8E, 0xB5,
0xE9, 0xCF, 0xBF, 0xBA,
0xEA, 0x77, 0x39, 0xAF,
0x33, 0xC9, 0x62, 0x71,
0x81, 0x79, 0x09, 0xAD,
0x24, 0xCD, 0xF9, 0xD8,
0xE5, 0xC5, 0xB9, 0x4D,
0x44, 0x08, 0x86, 0xE7,
0xA1, 0x1D, 0xAA, 0xED,
0x06, 0x70, 0xB2, 0xD2,
0x41, 0x7B, 0xA0, 0x11,
0x31, 0xC2, 0x27, 0x90,
0x20, 0xF6, 0x60, 0xFF,
0x96, 0x5C, 0xB1, 0xAB,
0x9E, 0x9C, 0x52, 0x1B,
0x5F, 0x93, 0x0A, 0xEF,
0x91, 0x85, 0x49, 0xEE,
0x2D, 0x4F, 0x8F, 0x3B,
0x47, 0x87, 0x6D, 0x46,
0xD6, 0x3E, 0x69, 0x64,
0x2A, 0xCE, 0xCB, 0x2F,
0xFC, 0x97, 0x05, 0x7A,
0xAC, 0x7F, 0xD5, 0x1A,
0x4B, 0x0E, 0xA7, 0x5A,
0x28, 0x14, 0x3F, 0x29,
0x88, 0x3C, 0x4C, 0x02,
0xB8, 0xDA, 0xB0, 0x17,
0x55, 0x1F, 0x8A, 0x7D,
0x57, 0xC7, 0x8D, 0x74,
0xB7, 0xC4, 0x9F, 0x72,
0x7E, 0x15, 0x22, 0x12,
0x58, 0x07, 0x99, 0x34,
0x6E, 0x50, 0xDE, 0x68,
0x65, 0xBC, 0xDB, 0xF8,
0xC8, 0xA8, 0x2B, 0x40,
0xDC, 0xFE, 0x32, 0xA4,
0xCA, 0x10, 0x21, 0xF0,
0xD3, 0x5D, 0x0F, 0x00,
0x6F, 0x9D, 0x36, 0x42,
0x4A, 0x5E, 0xC1, 0xE0
),
intArrayOf(
// p1
0x75, 0xF3, 0xC6, 0xF4,
0xDB, 0x7B, 0xFB, 0xC8,
0x4A, 0xD3, 0xE6, 0x6B,
0x45, 0x7D, 0xE8, 0x4B,
0xD6, 0x32, 0xD8, 0xFD,
0x37, 0x71, 0xF1, 0xE1,
0x30, 0x0F, 0xF8, 0x1B,
0x87, 0xFA, 0x06, 0x3F,
0x5E, 0xBA, 0xAE, 0x5B,
0x8A, 0x00, 0xBC, 0x9D,
0x6D, 0xC1, 0xB1, 0x0E,
0x80, 0x5D, 0xD2, 0xD5,
0xA0, 0x84, 0x07, 0x14,
0xB5, 0x90, 0x2C, 0xA3,
0xB2, 0x73, 0x4C, 0x54,
0x92, 0x74, 0x36, 0x51,
0x38, 0xB0, 0xBD, 0x5A,
0xFC, 0x60, 0x62, 0x96,
0x6C, 0x42, 0xF7, 0x10,
0x7C, 0x28, 0x27, 0x8C,
0x13, 0x95, 0x9C, 0xC7,
0x24, 0x46, 0x3B, 0x70,
0xCA, 0xE3, 0x85, 0xCB,
0x11, 0xD0, 0x93, 0xB8,
0xA6, 0x83, 0x20, 0xFF,
0x9F, 0x77, 0xC3, 0xCC,
0x03, 0x6F, 0x08, 0xBF,
0x40, 0xE7, 0x2B, 0xE2,
0x79, 0x0C, 0xAA, 0x82,
0x41, 0x3A, 0xEA, 0xB9,
0xE4, 0x9A, 0xA4, 0x97,
0x7E, 0xDA, 0x7A, 0x17,
0x66, 0x94, 0xA1, 0x1D,
0x3D, 0xF0, 0xDE, 0xB3,
0x0B, 0x72, 0xA7, 0x1C,
0xEF, 0xD1, 0x53, 0x3E,
0x8F, 0x33, 0x26, 0x5F,
0xEC, 0x76, 0x2A, 0x49,
0x81, 0x88, 0xEE, 0x21,
0xC4, 0x1A, 0xEB, 0xD9,
0xC5, 0x39, 0x99, 0xCD,
0xAD, 0x31, 0x8B, 0x01,
0x18, 0x23, 0xDD, 0x1F,
0x4E, 0x2D, 0xF9, 0x48,
0x4F, 0xF2, 0x65, 0x8E,
0x78, 0x5C, 0x58, 0x19,
0x8D, 0xE5, 0x98, 0x57,
0x67, 0x7F, 0x05, 0x64,
0xAF, 0x63, 0xB6, 0xFE,
0xF5, 0xB7, 0x3C, 0xA5,
0xCE, 0xE9, 0x68, 0x44,
0xE0, 0x4D, 0x43, 0x69,
0x29, 0x2E, 0xAC, 0x15,
0x59, 0xA8, 0x0A, 0x9E,
0x6E, 0x47, 0xDF, 0x34,
0x35, 0x6A, 0xCF, 0xDC,
0x22, 0xC9, 0xC0, 0x9B,
0x89, 0xD4, 0xED, 0xAB,
0x12, 0xA2, 0x0D, 0x52,
0xBB, 0x02, 0x2F, 0xA9,
0xD7, 0x61, 0x1E, 0xB4,
0x50, 0x04, 0xF6, 0xC2,
0x16, 0x25, 0x86, 0x56,
0x55, 0x09, 0xBE, 0x91
)
)
// Define the fixed p0/p1 permutations used in keyed S-box lookup.
// By changing the following constant definitions, the S-boxes will
// automatically get changed in the Twofish engine.
private const val P_00 = 1
private const val P_01 = 0
private const val P_02 = 0
private const val P_03 = P_01 xor 1
private const val P_04 = 1
private const val P_10 = 0
private const val P_11 = 0
private const val P_12 = 1
private const val P_13 = P_11 xor 1
private const val P_14 = 0
private const val P_20 = 1
private const val P_21 = 1
private const val P_22 = 0
private const val P_23 = P_21 xor 1
private const val P_24 = 0
private const val P_30 = 0
private const val P_31 = 1
private const val P_32 = 1
private const val P_33 = P_31 xor 1
private const val P_34 = 1
// Primitive polynomial for GF(256)
private const val GF256_FDBK: Int = 0x169
private const val GF256_FDBK_2: Int = 0x169 / 2
private const val GF256_FDBK_4: Int = 0x169 / 4
private val MDS = Array(4) { IntArray(256) { 0 } }
private const val RS_GF_FDBK = 0x14D // field generator
/**********************
* INTERNAL FUNCTIONS *
**********************/
private fun LFSR1(x: Int): Int =
(x shr 1) xor (if ((x and 0x01) != 0) GF256_FDBK_2 else 0)
private fun LFSR2(x: Int): Int =
(x shr 2) xor
(if ((x and 0x02) != 0) GF256_FDBK_2 else 0) xor
(if ((x and 0x01) != 0) GF256_FDBK_4 else 0)
private fun Mx_1(x: Int): Int = x
private fun Mx_X(x: Int): Int = x xor LFSR2(x) // 5B
private fun Mx_Y(x: Int): Int = x xor LFSR1(x) xor LFSR2(x) // EF
// Reed-Solomon code parameters: (12, 8) reversible code:<p>
// <pre>
// g(x) = x**4 + (a + 1/a) x**3 + a x**2 + (a + 1/a) x + 1
// </pre>
// where a = primitive root of field generator 0x14D
private fun RS_rem(x: Int): Int {
val b = (x ushr 24) and 0xFF
val g2 = ((b shl 1) xor (if ((b and 0x80) != 0) RS_GF_FDBK else 0)) and 0xFF
val g3 = (b ushr 1) xor (if ((b and 0x01) != 0) (RS_GF_FDBK ushr 1) else 0) xor g2
return (x shl 8) xor (g3 shl 24) xor (g2 shl 16) xor (g3 shl 8) xor b
}
// Use (12, 8) Reed-Solomon code over GF(256) to produce a key S-box
// 32-bit entity from two key material 32-bit entities.
//
// @param k0 1st 32-bit entity.
// @param k1 2nd 32-bit entity.
// @return Remainder polynomial generated using RS code
private fun RS_MDS_Encode(k0: Int, k1: Int): Int {
var r = k1
for (i in 0 until 4) // shift 1 byte at a time
r = RS_rem(r)
r = r xor k0
for (i in 0 until 4)
r = RS_rem(r)
return r
}
private fun calcb0(x: Int) = x and 0xFF
private fun calcb1(x: Int) = (x ushr 8) and 0xFF
private fun calcb2(x: Int) = (x ushr 16) and 0xFF
private fun calcb3(x: Int) = (x ushr 24) and 0xFF
private fun F32(k64Cnt: Int, x: Int, k32: IntArray): Int {
var b0 = calcb0(x)
var b1 = calcb1(x)
var b2 = calcb2(x)
var b3 = calcb3(x)
val k0 = k32[0]
val k1 = k32[1]
val k2 = k32[2]
val k3 = k32[3]
var k64Cnt2LSB = k64Cnt and 3
if (k64Cnt2LSB == 1) {
return MDS[0][(P[P_01][b0] and 0xFF) xor calcb0(k0)] xor
MDS[1][(P[P_11][b1] and 0xFF) xor calcb1(k0)] xor
MDS[2][(P[P_21][b2] and 0xFF) xor calcb2(k0)] xor
MDS[3][(P[P_31][b3] and 0xFF) xor calcb3(k0)]
}
if (k64Cnt2LSB == 0) { // same as 4
b0 = (P[P_04][b0] and 0xFF) xor calcb0(k3)
b1 = (P[P_14][b1] and 0xFF) xor calcb1(k3)
b2 = (P[P_24][b2] and 0xFF) xor calcb2(k3)
b3 = (P[P_34][b3] and 0xFF) xor calcb3(k3)
k64Cnt2LSB = 3
}
if (k64Cnt2LSB == 3) {
b0 = (P[P_03][b0] and 0xFF) xor calcb0(k2)
b1 = (P[P_13][b1] and 0xFF) xor calcb1(k2)
b2 = (P[P_23][b2] and 0xFF) xor calcb2(k2)
b3 = (P[P_33][b3] and 0xFF) xor calcb3(k2)
k64Cnt2LSB = 2
}
if (k64Cnt2LSB == 2) { // 128-bit keys (optimize for this case)
return MDS[0][(P[P_01][(P[P_02][b0] and 0xFF) xor calcb0(k1)] and 0xFF) xor calcb0(k0)] xor
MDS[1][(P[P_11][(P[P_12][b1] and 0xFF) xor calcb1(k1)] and 0xFF) xor calcb1(k0)] xor
MDS[2][(P[P_21][(P[P_22][b2] and 0xFF) xor calcb2(k1)] and 0xFF) xor calcb2(k0)] xor
MDS[3][(P[P_31][(P[P_32][b3] and 0xFF) xor calcb3(k1)] and 0xFF) xor calcb3(k0)]
}
return 0
}
private fun Fe32(sBox: IntArray, x: Int, R: Int) =
sBox[0x000 + 2 * _b(x, R + 0) + 0] xor
sBox[0x000 + 2 * _b(x, R + 1) + 1] xor
sBox[0x200 + 2 * _b(x, R + 2) + 0] xor
sBox[0x200 + 2 * _b(x, R + 3) + 1]
private fun _b(x: Int, N: Int) =
when (N and 3) {
0 -> calcb0(x)
1 -> calcb1(x)
2 -> calcb2(x)
3 -> calcb3(x)
// NOTE: This else-branch is only here to shut up build errors.
// This case cannot occur because the bitwise AND above excludes
// all values outside of the 0-3 range.
else -> 0
}
/*************************
* STATIC INITIALIZATION *
*************************/
init {
// precompute the MDS matrix
val m1 = IntArray(2)
val mX = IntArray(2)
val mY = IntArray(2)
for (i in 0 until 256) {
// compute all the matrix elements
val j0 = P[0][i] and 0xFF
m1[0] = j0
mX[0] = Mx_X(j0) and 0xFF
mY[0] = Mx_Y(j0) and 0xFF
val j1 = P[1][i] and 0xFF
m1[1] = j1
mX[1] = Mx_X(j1) and 0xFF
mY[1] = Mx_Y(j1) and 0xFF
MDS[0][i] = (m1[P_00] shl 0) or
(mX[P_00] shl 8) or
(mY[P_00] shl 16) or
(mY[P_00] shl 24)
MDS[1][i] = (mY[P_10] shl 0) or
(mY[P_10] shl 8) or
(mX[P_10] shl 16) or
(m1[P_10] shl 24)
MDS[2][i] = (mX[P_20] shl 0) or
(mY[P_20] shl 8) or
(m1[P_20] shl 16) or
(mY[P_20] shl 24)
MDS[3][i] = (mX[P_30] shl 0) or
(m1[P_30] shl 8) or
(mY[P_30] shl 16) or
(mX[P_30] shl 24)
}
}
/******************
* KEY PROCESSING *
******************/
/**
* Class containing precomputed S-box and subkey values derived from a key.
*
* These values are computed by the [processKey] function.
* [blockEncrypt] and [blockDecrypt] expect an instance of this class,
* not a key directly.
*/
data class KeyObject(val sBox: IntArray, val subKeys: IntArray) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null) return false
if (this::class != other::class) return false
other as KeyObject
if (!sBox.contentEquals(other.sBox)) return false
if (!subKeys.contentEquals(other.subKeys)) return false
return true
}
override fun hashCode(): Int {
var result = sBox.contentHashCode()
result = 31 * result + subKeys.contentHashCode()
return result
}
}
/**
* Processes a Two-fish key and stores the computed values in the returned object.
*
* Since the S-box and subkey values stay the same during en/decryption, it
* makes sense to compute them once and store them for later reuse. This is
* what this function does
*
* @param key 64/128/192/256-bit key for processing.
* @return Object with the processed results.
*/
fun processKey(key: ByteArray): KeyObject {
require(key.size in intArrayOf(8, 16, 24, 32))
val k64Cnt = key.size / 8
val k32e = IntArray(4) // even 32-bit entities
val k32o = IntArray(4) // odd 32-bit entities
val sBoxKey = IntArray(4)
var offset = 0
// split user key material into even and odd 32-bit entities and
// compute S-box keys using (12, 8) Reed-Solomon code over GF(256)
for (i in 0 until 4) {
if (offset >= key.size)
break
val j = k64Cnt - 1 - i
k32e[i] = ((key[offset++].toPosInt() and 0xFF) shl 0) or
((key[offset++].toPosInt() and 0xFF) shl 8) or
((key[offset++].toPosInt() and 0xFF) shl 16) or
((key[offset++].toPosInt() and 0xFF) shl 24)
k32o[i] = ((key[offset++].toPosInt() and 0xFF) shl 0) or
((key[offset++].toPosInt() and 0xFF) shl 8) or
((key[offset++].toPosInt() and 0xFF) shl 16) or
((key[offset++].toPosInt() and 0xFF) shl 24)
sBoxKey[j] = RS_MDS_Encode(k32e[i], k32o[i]) // reverse order
}
// compute the round decryption subkeys for PHT. these same subkeys
// will be used in encryption but will be applied in reverse order.
var q = 0
val subKeys = IntArray(TOTAL_SUBKEYS)
for (i in 0 until (TOTAL_SUBKEYS / 2)) {
var A = F32(k64Cnt, q, k32e) // A uses even key entities
var B = F32(k64Cnt, q + SK_BUMP, k32o) // B uses odd key entities
B = (B shl 8) or (B ushr 24)
A += B
subKeys[2 * i + 0] = A // combine with a PHT
A += B
subKeys[2 * i + 1] = (A shl SK_ROTL) or (A ushr (32 - SK_ROTL))
q += SK_STEP
}
// fully expand the table for speed
val k0 = sBoxKey[0]
val k1 = sBoxKey[1]
val k2 = sBoxKey[2]
val k3 = sBoxKey[3]
val sBox = IntArray(4 * 256)
for (i in 0 until 256) {
var b0 = i
var b1 = i
var b2 = i
var b3 = i
var k64Cnt2LSB = k64Cnt and 3
if (k64Cnt2LSB == 1) {
sBox[0x000 + 2 * i + 0] = MDS[0][(P[P_01][b0] and 0xFF) xor calcb0(k0)]
sBox[0x000 + 2 * i + 1] = MDS[1][(P[P_11][b1] and 0xFF) xor calcb1(k0)]
sBox[0x200 + 2 * i + 0] = MDS[2][(P[P_21][b2] and 0xFF) xor calcb2(k0)]
sBox[0x200 + 2 * i + 1] = MDS[3][(P[P_31][b3] and 0xFF) xor calcb3(k0)]
break
}
if (k64Cnt2LSB == 0) {
b0 = (P[P_04][b0] and 0xFF) xor calcb0(k3)
b1 = (P[P_14][b1] and 0xFF) xor calcb1(k3)
b2 = (P[P_24][b2] and 0xFF) xor calcb2(k3)
b3 = (P[P_34][b3] and 0xFF) xor calcb3(k3)
k64Cnt2LSB = 3
}
if (k64Cnt2LSB == 3) {
b0 = (P[P_03][b0] and 0xFF) xor calcb0(k2)
b1 = (P[P_13][b1] and 0xFF) xor calcb1(k2)
b2 = (P[P_23][b2] and 0xFF) xor calcb2(k2)
b3 = (P[P_33][b3] and 0xFF) xor calcb3(k2)
k64Cnt2LSB = 2
}
if (k64Cnt2LSB == 2) {
sBox[0x000 + 2 * i + 0] = MDS[0][(P[P_01][(P[P_02][b0] and 0xFF) xor calcb0(k1)] and 0xFF) xor calcb0(k0)]
sBox[0x000 + 2 * i + 1] = MDS[1][(P[P_11][(P[P_12][b1] and 0xFF) xor calcb1(k1)] and 0xFF) xor calcb1(k0)]
sBox[0x200 + 2 * i + 0] = MDS[2][(P[P_21][(P[P_22][b2] and 0xFF) xor calcb2(k1)] and 0xFF) xor calcb2(k0)]
sBox[0x200 + 2 * i + 1] = MDS[3][(P[P_31][(P[P_32][b3] and 0xFF) xor calcb3(k1)] and 0xFF) xor calcb3(k0)]
}
}
return KeyObject(sBox = sBox, subKeys = subKeys)
}
/***************************
* EN/DECRYPTION FUNCTIONS *
***************************/
/**
* Encrypts a block of 16 plaintext bytes with the given key object.
*
* The 16 bytes are read from the given array at the given offset.
* This function always reads exactly 16 bytes.
*
* The key object is generated from a key by using [processKey].
*
* @param input Byte array with the input bytes of plaintext to encrypt.
* @param offset Offset in the input byte array to start reading bytes from.
* @param keyObject Key object to use for encryption.
* @return Byte array with the ciphertext version of the 16 input bytes.
*/
fun blockEncrypt(input: ByteArray, offset: Int, keyObject: KeyObject): ByteArray {
var inputOffset = offset
var x0 = ((input[inputOffset++].toPosInt() and 0xFF) shl 0) or
((input[inputOffset++].toPosInt() and 0xFF) shl 8) or
((input[inputOffset++].toPosInt() and 0xFF) shl 16) or
((input[inputOffset++].toPosInt() and 0xFF) shl 24)
var x1 = ((input[inputOffset++].toPosInt() and 0xFF) shl 0) or
((input[inputOffset++].toPosInt() and 0xFF) shl 8) or
((input[inputOffset++].toPosInt() and 0xFF) shl 16) or
((input[inputOffset++].toPosInt() and 0xFF) shl 24)
var x2 = ((input[inputOffset++].toPosInt() and 0xFF) shl 0) or
((input[inputOffset++].toPosInt() and 0xFF) shl 8) or
((input[inputOffset++].toPosInt() and 0xFF) shl 16) or
((input[inputOffset++].toPosInt() and 0xFF) shl 24)
var x3 = ((input[inputOffset++].toPosInt() and 0xFF) shl 0) or
((input[inputOffset++].toPosInt() and 0xFF) shl 8) or
((input[inputOffset++].toPosInt() and 0xFF) shl 16) or
((input[inputOffset].toPosInt() and 0xFF) shl 24)
val sBox = keyObject.sBox
val subKeys = keyObject.subKeys
x0 = x0 xor subKeys[INPUT_WHITEN + 0]
x1 = x1 xor subKeys[INPUT_WHITEN + 1]
x2 = x2 xor subKeys[INPUT_WHITEN + 2]
x3 = x3 xor subKeys[INPUT_WHITEN + 3]
var k = ROUND_SUBKEYS
for (R in 0 until MAX_ROUNDS step 2) {
var t0: Int
var t1: Int
t0 = Fe32(sBox, x0, 0)
t1 = Fe32(sBox, x1, 3)
x2 = x2 xor (t0 + t1 + subKeys[k++])
x2 = (x2 ushr 1) or (x2 shl 31)
x3 = (x3 shl 1) or (x3 ushr 31)
x3 = x3 xor (t0 + 2 * t1 + subKeys[k++])
t0 = Fe32(sBox, x2, 0)
t1 = Fe32(sBox, x3, 3)
x0 = x0 xor (t0 + t1 + subKeys[k++])
x0 = (x0 ushr 1) or (x0 shl 31)
x1 = (x1 shl 1) or (x1 ushr 31)
x1 = x1 xor (t0 + 2 * t1 + subKeys[k++])
}
x2 = x2 xor subKeys[OUTPUT_WHITEN + 0]
x3 = x3 xor subKeys[OUTPUT_WHITEN + 1]
x0 = x0 xor subKeys[OUTPUT_WHITEN + 2]
x1 = x1 xor subKeys[OUTPUT_WHITEN + 3]
return byteArrayOfInts(
x2, x2 ushr 8, x2 ushr 16, x2 ushr 24,
x3, x3 ushr 8, x3 ushr 16, x3 ushr 24,
x0, x0 ushr 8, x0 ushr 16, x0 ushr 24,
x1, x1 ushr 8, x1 ushr 16, x1 ushr 24
)
}
/**
* Decrypts a block of 16 ciphertext bytes with the given key object.
*
* The 16 bytes are read from the given array at the given offset.
* This function always reads exactly 16 bytes.
*
* The key object is generated from a key by using [processKey].
*
* @param input Byte array with the input bytes of ciphertext to decrypt.
* @param offset Offset in the input byte array to start reading bytes from.
* @param keyObject Key object to use for decryption.
* @return Byte array with the plaintext version of the 16 input bytes.
*/
fun blockDecrypt(input: ByteArray, offset: Int, keyObject: KeyObject): ByteArray {
var inputOffset = offset
var x2 = ((input[inputOffset++].toPosInt() and 0xFF) shl 0) or
((input[inputOffset++].toPosInt() and 0xFF) shl 8) or
((input[inputOffset++].toPosInt() and 0xFF) shl 16) or
((input[inputOffset++].toPosInt() and 0xFF) shl 24)
var x3 = ((input[inputOffset++].toPosInt() and 0xFF) shl 0) or
((input[inputOffset++].toPosInt() and 0xFF) shl 8) or
((input[inputOffset++].toPosInt() and 0xFF) shl 16) or
((input[inputOffset++].toPosInt() and 0xFF) shl 24)
var x0 = ((input[inputOffset++].toPosInt() and 0xFF) shl 0) or
((input[inputOffset++].toPosInt() and 0xFF) shl 8) or
((input[inputOffset++].toPosInt() and 0xFF) shl 16) or
((input[inputOffset++].toPosInt() and 0xFF) shl 24)
var x1 = ((input[inputOffset++].toPosInt() and 0xFF) shl 0) or
((input[inputOffset++].toPosInt() and 0xFF) shl 8) or
((input[inputOffset++].toPosInt() and 0xFF) shl 16) or
((input[inputOffset].toPosInt() and 0xFF) shl 24)
val sBox = keyObject.sBox
val subKeys = keyObject.subKeys
x2 = x2 xor subKeys[OUTPUT_WHITEN + 0]
x3 = x3 xor subKeys[OUTPUT_WHITEN + 1]
x0 = x0 xor subKeys[OUTPUT_WHITEN + 2]
x1 = x1 xor subKeys[OUTPUT_WHITEN + 3]
var k = TOTAL_SUBKEYS - 1
for (R in 0 until MAX_ROUNDS step 2) {
var t0: Int
var t1: Int
t0 = Fe32(sBox, x2, 0)
t1 = Fe32(sBox, x3, 3)
x1 = x1 xor (t0 + 2 * t1 + subKeys[k--])
x1 = (x1 ushr 1) or (x1 shl 31)
x0 = (x0 shl 1) or (x0 ushr 31)
x0 = x0 xor (t0 + t1 + subKeys[k--])
t0 = Fe32(sBox, x0, 0)
t1 = Fe32(sBox, x1, 3)
x3 = x3 xor (t0 + 2 * t1 + subKeys[k--])
x3 = (x3 ushr 1) or (x3 shl 31)
x2 = (x2 shl 1) or (x2 ushr 31)
x2 = x2 xor (t0 + t1 + subKeys[k--])
}
x0 = x0 xor subKeys[INPUT_WHITEN + 0]
x1 = x1 xor subKeys[INPUT_WHITEN + 1]
x2 = x2 xor subKeys[INPUT_WHITEN + 2]
x3 = x3 xor subKeys[INPUT_WHITEN + 3]
return byteArrayOfInts(
x0, x0 ushr 8, x0 ushr 16, x0 ushr 24,
x1, x1 ushr 8, x1 ushr 16, x1 ushr 24,
x2, x2 ushr 8, x2 ushr 16, x2 ushr 24,
x3, x3 ushr 8, x3 ushr 16, x3 ushr 24
)
}
}

View file

@ -0,0 +1,283 @@
package info.nightscout.comboctl.base
import kotlin.math.max
import kotlin.math.min
import kotlinx.datetime.Clock
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.atTime
// Utility function for cases when only the time and no date is known.
// monthNumber and dayOfMonth are set to 1 instead of 0 since 0 is
// outside of the valid range of these fields.
internal fun timeWithoutDate(hour: Int = 0, minute: Int = 0, second: Int = 0) =
LocalDateTime(
year = 0, monthNumber = 1, dayOfMonth = 1,
hour = hour, minute = minute, second = second, nanosecond = 0
)
internal fun combinedDateTime(date: LocalDate, time: LocalDateTime) =
date.atTime(hour = time.hour, minute = time.minute, second = time.second, nanosecond = time.nanosecond)
// IMPORTANT: Only use this with local dates that always lie in the past or present,
// never in the future. Read the comment block right below for an explanation why.
internal fun LocalDate.withFixedYearFrom(reference: LocalDate): LocalDate {
// In cases where the Combo does not show years (just months and days), we may have
// to fix the local date by inserting a year number from some other timestamp
// (typically the current system time).
// If we do this, we have to address a corner case: Suppose that the un-fixed date
// has month 12 and day 29, but the current date is 2024-01-02. Just inserting 2024
// into the first date yields the date 2024-12-29. The screens that only show
// months and days (and not years) are in virtually all cases used for historical
// data, meaning that these dates cannot be in the future. The example just shown
// would produce a future date though (since 2024-12-29 > 2024-01-02). Therefore,
// if we see that the newly constructed local date is in the future relative to
// the reference date, we subtract 1 from the year.
val date = LocalDate(year = reference.year, month = this.month, dayOfMonth = this.dayOfMonth)
return if (date > reference) {
LocalDate(year = reference.year - 1, month = this.month, dayOfMonth = this.dayOfMonth)
} else
date
}
/**
* Produces a ByteArray out of a sequence of integers.
*
* Producing a ByteArray with arrayOf() is only possible if the values
* are less than 128. For example, this is not possible, because 0xF0
* is >= 128:
*
* val b = byteArrayOf(0xF0, 0x01)
*
* This function allows for such cases.
*
* [Original code from here](https://stackoverflow.com/a/51404278).
*
* @param ints Integers to convert to bytes for the new array.
* @return The new ByteArray.
*/
internal fun byteArrayOfInts(vararg ints: Int) = ByteArray(ints.size) { pos -> ints[pos].toByte() }
/**
* Variant of [byteArrayOfInts] which produces an ArrayList instead of an array.
*
* @param ints Integers to convert to bytes for the new arraylist.
* @return The new arraylist.
*/
internal fun byteArrayListOfInts(vararg ints: Int) = ArrayList(ints.map { it.toByte() })
/**
* Produces a hexadecimal string representation of the bytes in the array.
*
* The string is formatted with a separator (one whitespace character by default)
* between the bytes. For example, the byte array 0x8F, 0xBC results in "8F BC".
*
* @return The string representation.
*/
internal fun ByteArray.toHexString(separator: String = " ") = this.joinToString(separator) { it.toHexString(width = 2, prependPrefix = false) }
/**
* Produces a hexadecimal string representation of the bytes in the list.
*
* The string is formatted with a separator (one whitespace character by default)
* between the bytes. For example, the byte list 0x8F, 0xBC results in "8F BC".
*
* @return The string representation.
*/
internal fun List<Byte>.toHexString(separator: String = " ") = this.joinToString(separator) { it.toHexString(width = 2, prependPrefix = false) }
/**
* Produces a hexadecimal string describing the "surroundings" of a byte in a list.
*
* This is useful for error messages about invalid bytes in data. For example,
* suppose that the 11th byte in this data block is invalid:
*
* 11 77 EE 44 77 EE 77 DD 00 77 DC 77 EE 55 CC
*
* Then with this function, it is possible to produce a string that highlights that
* byte, along with its surrounding bytes:
*
* "11 77 EE 44 77 EE 77 DD 00 77 [DC] 77 EE 55 CC"
*
* Such a surrounding is also referred to as a "context" in tools like GNU patch,
* which is why it is called like this here.
*
* @param offset Offset in the list where the byte is.
* @param contextSize The size of the context before and the one after the byte.
* For example, a size of 10 will include up to 10 bytes before and up
* to 10 bytes after the byte at offset (less if the byte is close to
* the beginning or end of the list).
* @return The string representation.
*/
internal fun List<Byte>.toHexStringWithContext(offset: Int, contextSize: Int = 10): String {
val byte = this[offset]
val beforeByteContext = this.subList(max(offset - contextSize, 0), offset)
val beforeByteContextStr = if (beforeByteContext.isEmpty()) "" else beforeByteContext.toHexString() + " "
val afterByteContext = this.subList(offset + 1, min(this.size, offset + 1 + contextSize))
val afterByteContextStr = if (afterByteContext.isEmpty()) "" else " " + afterByteContext.toHexString()
return "$beforeByteContextStr[${byte.toHexString(width = 2, prependPrefix = false)}]$afterByteContextStr"
}
/**
* Byte to Int conversion that treats all 8 bits of the byte as a positive value.
*
* Currently, support for unsigned byte (UByte) is still experimental
* in Kotlin. The existing Byte type is signed. This poses a problem
* when one needs to bitwise manipulate bytes, since the MSB will be
* interpreted as a sign bit, leading to unexpected outcomes. Example:
*
* Example byte: 0xA2 (in binary: 0b10100010)
*
* Code:
*
* val b = 0xA2.toByte()
* println("%08x".format(b.toInt()))
*
* Result:
*
* ffffffa2
*
*
* This is the result of the MSB of 0xA2 being interpreted as a sign
* bit. This in turn leads to 0xA2 being interpreted as the negative
* value -94. When cast to Int, a negative -94 Int value is produced.
* Due to the 2-complement logic, all upper bits are set, leading to
* the hex value 0xffffffa2. By masking out all except the lower
* 8 bits, the correct positive value is retained:
*
* println("%08x".format(b.toPosInt() xor 7))
*
* Result:
*
* 000000a2
*
* This is for example important when doing bit shifts:
*
* println("%08x".format(b.toInt() ushr 4))
* println("%08x".format(b.toPosInt() ushr 4))
* println("%08x".format(b.toInt() shl 4))
* println("%08x".format(b.toPosInt() shl 4))
*
* Result:
*
* 0ffffffa
* 0000000a
* fffffa20
* 00000a20
*
* toPosInt produces the correct results.
*/
internal fun Byte.toPosInt() = toInt() and 0xFF
/**
* Byte to Long conversion that treats all 8 bits of the byte as a positive value.
*
* This behaves identically to [Byte.toPosInt], except it produces a Long instead
* of an Int value.
*/
internal fun Byte.toPosLong() = toLong() and 0xFF
/**
* Int to Long conversion that treats all 32 bits of the Int as a positive value.
*
* This behaves just like [Byte.toPosLong], except it is applied on Int values,
* and extracts 32 bits instead of 8.
*/
internal fun Int.toPosLong() = toLong() and 0xFFFFFFFFL
/**
* Produces a hex string out of an Int.
*
* String.format() is JVM specific, so we can't use it in multiplatform projects.
* Hence the existence of this function.
*
* @param width Width of the hex string. If the actual hex string is shorter
* than this, the excess characters to the left (the leading characters)
* are filled with zeros. If a "0x" prefix is added, the prefix length is
* not considered part of the hex string. For example, a width of 4 and
* a hex string of 0x45 will produce 0045 with no prefix and 0x0045 with
* prefix.
* @param prependPrefix If true, the "0x" prefix is prepended.
* @return Hex string representation of the Int.
*/
internal fun Int.toHexString(width: Int, prependPrefix: Boolean = true): String {
val prefix = if (prependPrefix) "0x" else ""
val hexstring = this.toString(16)
val numLeadingChars = max(width - hexstring.length, 0)
return prefix + "0".repeat(numLeadingChars) + hexstring
}
/**
* Produces a hex string out of a Byte.
*
* String.format() is JVM specific, so we can't use it in multiplatform projects.
* Hence the existence of this function.
*
* @param width Width of the hex string. If the actual hex string is shorter
* than this, the excess characters to the left (the leading characters)
* are filled with zeros. If a "0x" prefix is added, the prefix length is
* not considered part of the hex string. For example, a width of 4 and
* a hex string of 0x45 will produce 0045 with no prefix and 0x0045 with
* prefix.
* @param prependPrefix If true, the "0x" prefix is prepended.
* @return Hex string representation of the Byte.
*/
internal fun Byte.toHexString(width: Int, prependPrefix: Boolean = true): String {
val intValue = this.toPosInt()
val prefix = if (prependPrefix) "0x" else ""
val hexstring = intValue.toString(16)
val numLeadingChars = max(width - hexstring.length, 0)
return prefix + "0".repeat(numLeadingChars) + hexstring
}
/**
* Converts the given integer to string, using the rightmost digits as decimals.
*
* This is useful for fixed-point decimals, that is, integers that actually
* store decimal values of fixed precision. These are used for insulin
* dosages. For example, the integer 155 may actually mean 1.55. In that case,
* [numDecimals] is set to 2, indicating that the 2 last digits are the
* fractional part.
*
* @param numDecimals How many of the rightmost digits make up the fraction
* portion of the decimal.
* @return String representation of the decimal value.
*/
internal fun Int.toStringWithDecimal(numDecimals: Int): String {
require(numDecimals >= 0)
val intStr = this.toString()
return when {
numDecimals == 0 -> intStr
intStr.length <= numDecimals -> "0." + "0".repeat(numDecimals - intStr.length) + intStr
else -> intStr.substring(0, intStr.length - numDecimals) + "." + intStr.substring(intStr.length - numDecimals)
}
}
/**
* Converts the given integer to string, using the rightmost digits as decimals.
*
* This behaves just like [Int.toStringWithDecimal], except it is applied on Long values.
*/
internal fun Long.toStringWithDecimal(numDecimals: Int): String {
require(numDecimals >= 0)
val longStr = this.toString()
return when {
numDecimals == 0 -> longStr
longStr.length <= numDecimals -> "0." + "0".repeat(numDecimals - longStr.length) + longStr
else -> longStr.substring(0, longStr.length - numDecimals) + "." + longStr.substring(longStr.length - numDecimals)
}
}
/**
* Returns the elapsed time in milliseconds.
*
* This measures the elapsed time that started at some arbitrary point
* (typically Epoch, or the moment the system was booted). It does _not_
* necessarily return the current wall-clock time, and is only intended
* to be used for calculating intervals and to add timestamps to events
* such as when log lines are produced.
*/
internal fun getElapsedTimeInMs(): Long = Clock.System.now().toEpochMilliseconds()

View file

@ -0,0 +1,101 @@
package info.nightscout.comboctl.main
import info.nightscout.comboctl.base.toStringWithDecimal
const val NUM_COMBO_BASAL_PROFILE_FACTORS = 24
/**
* Class containing the 24 basal profile factors.
*
* The factors are stored as integer-encoded-decimals. The
* last 3 digits of the integers make up the fractional portion.
* For example, integer factor 4100 actually means 4.1 IU.
*
* The Combo uses the following granularity:
* 0.00 IU to 0.05 IU : increment in 0.05 IU steps
* 0.05 IU to 1.00 IU : increment in 0.01 IU steps
* 1.00 IU to 10.00 IU : increment in 0.05 IU steps
* 10.00 IU and above : increment in 0.10 IU steps
*
* The [sourceFactors] argument must contain exactly 24
* integer-encoded-decimals. Any other amount will result
* in an [IllegalArgumentException]. Furthermore, all
* factors must be >= 0.
*
* [sourceFactors] is not taken as a reference. Instead,
* its 24 factors are copied into an internal list that
* is accessible via the [factors] property. If the factors
* from [sourceFactors] do not match the granularity mentioned
* above, they will be rounded before they are copied into
* the [factors] property. It is therefore advisable to
* look at that property after creating an instance of
* this class to see what the profile's factors that the
* Combo is using actually are like.
*
* @param sourceFactors The source for the basal profile's factors.
* @throws IllegalArgumentException if [sourceFactors] does not
* contain exactly 24 factors or if at least one of these
* factors is negative.
*/
class BasalProfile(sourceFactors: List<Int>) {
private val _factors = MutableList(NUM_COMBO_BASAL_PROFILE_FACTORS) { 0 }
/**
* Number of basal profile factors (always 24).
*
* This mainly exists to make this class compatible with
* code that operates on collections.
*/
val size = NUM_COMBO_BASAL_PROFILE_FACTORS
/**
* List with the basal profile factors.
*
* These are a copy of the source factors that were
* passed to the constructor, rounded if necessary.
* See the [BasalProfile] documentation for details.
*/
val factors: List<Int> = _factors
init {
require(sourceFactors.size == _factors.size)
sourceFactors.forEachIndexed { index, factor ->
require(factor >= 0) { "Source factor #$index has invalid negative value $factor" }
val granularity = when (factor) {
in 0..50 -> 50
in 50..1000 -> 10
in 1000..10000 -> 50
else -> 100
}
// Round the factor with integer math
// to conform to the Combo granularity.
_factors[index] = ((factor + granularity / 2) / granularity) * granularity
}
}
override fun toString() = factors.mapIndexed { index, factor ->
"hour $index: factor ${factor.toStringWithDecimal(3)}"
}.joinToString("; ")
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null) return false
if (this::class != other::class) return false
other as BasalProfile
if (factors != other.factors) return false
return true
}
override fun hashCode(): Int {
return factors.hashCode()
}
operator fun get(index: Int) = factors[index]
operator fun iterator() = factors.iterator()
}

View file

@ -0,0 +1,230 @@
package info.nightscout.comboctl.main
import info.nightscout.comboctl.base.DisplayFrame
import info.nightscout.comboctl.base.LogLevel
import info.nightscout.comboctl.base.Logger
import info.nightscout.comboctl.parser.AlertScreenException
import info.nightscout.comboctl.parser.ParsedScreen
import info.nightscout.comboctl.parser.parseDisplayFrame
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
private val logger = Logger.get("ParsedDisplayFrameStream")
/**
* Combination of a [DisplayFrame] and the [ParsedScreen] that is the result of parsing that frame.
*/
data class ParsedDisplayFrame(val displayFrame: DisplayFrame, val parsedScreen: ParsedScreen)
/**
* Class for parsing and processing a stream of incoming [DisplayFrame] data.
*
* This takes incoming [DisplayFrame] data through [feedDisplayFrame], parses these
* frames, and stores the frame along with its [ParsedScreen]. Consumers can get
* the result with [getParsedDisplayFrame]. If [feedDisplayFrame] is called before
* a previously parsed frame is retrieved, the previous un-retrieved copy gets
* overwritten. If no parsed frame is currently available, [getParsedDisplayFrame]
* suspends the calling coroutine until a parsed frame becomes available.
*
* [getParsedDisplayFrame] can also detect duplicate screens by comparing the
* last and current frame's [ParsedScreen] parsing results. In other words, duplicates
* are detected by comparing the parsed contents, not the frame pixels (unless both
* frames could not be parsed).
*
* [resetAll] resets all internal states and recreates the internal [Channel] that
* stores the last parsed frame. [resetDuplicate] resets the states associated with
* detecting duplicate screens.
*
* The [flow] offers a [SharedFlow] of the parsed display frames. This is useful
* for showing the frames on a GUI for example.
*
* During operation, [feedDisplayFrame], and [getParsedDisplayFrame]
* can be called concurrently, as can [resetAll] and [getParsedDisplayFrame].
* Other functions and call combinations lead to undefined behavior.
*
* This "stream" class is used instead of a more common Kotlin coroutine flow
* because the latter do not fit well in the whole Combo RT display dataflow
* model, where the ComboCtl code *pulls* parsed frames. Flows - specifically
* SharedFlows - are instead more suitable for *pushing* frames. Also, this
* class helps with diagnostics and debugging since it stores the actual
* frames along with their parsed screen counterparts, and there are no caches
* in between the display frames and the parsed screens which could lead to
* RT navigation errors due to the parsed screens indicating something that
* is not actually the current state and rather a past state instead.
*/
class ParsedDisplayFrameStream {
private val _flow = MutableSharedFlow<ParsedDisplayFrame?>(onBufferOverflow = BufferOverflow.DROP_OLDEST, replay = 1)
private var parsedDisplayFrameChannel = createChannel()
private var lastRetrievedParsedDisplayFrame: ParsedDisplayFrame? = null
/**
* [SharedFlow] publishing all incoming and newly parsed frames.
*
* This if [feedDisplayFrame] is called with a null argument.
*/
val flow: SharedFlow<ParsedDisplayFrame?> = _flow.asSharedFlow()
/**
* Resets all internal states back to the initial conditions.
*
* The [flow]'s replay cache is reset by this as well. This also
* resets all duplicate detection related states, so calling
* [resetDuplicate] after this is redundant.
*
* This aborts an ongoing suspending [getParsedDisplayFrame] call.
*/
fun resetAll() {
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
_flow.resetReplayCache()
parsedDisplayFrameChannel.close()
parsedDisplayFrameChannel = createChannel()
lastRetrievedParsedDisplayFrame = null
}
/**
* Sets the internal states to reflect a given error.
*
* This behaves similar to [resetAll]. However, the internal Channel
* for parsed display frames is closed with the specified [cause], and
* is _not_ reopened afterwards. [resetAll] has to be called after
* this function to be able to use the [ParsedDisplayFrameStream] again.
* This is intentional; it makes sure any attempts at getting parsed
* display frames etc. fail until the user explicitly resets this stream.
*
* @param cause The throwable that caused the error. Any currently
* suspended [getParsedDisplayFrame] call will be aborted and this
* cause will be thrown from that function.
*/
fun abortDueToError(cause: Throwable?) {
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
_flow.resetReplayCache()
parsedDisplayFrameChannel.close(cause)
}
/**
* Resets the states that are associated with duplicate screen detection.
*
* See [getParsedDisplayFrame] for details about duplicate screen detection.
*/
fun resetDuplicate() {
lastRetrievedParsedDisplayFrame = null
}
/**
* Feeds a new [DisplayFrame] into this stream, parses it, and stores the parsed frame.
*
* The parsed frame is stored as a [ParsedDisplayFrame] instance. If there is already
* such an instance stored, that previous one is overwritten. This also publishes
* the new [ParsedDisplayFrame] instance through [flow]. This can also stored a null
* reference to signal that frames are currently unavailable.
*
* [resetAll] erases the stored frame.
*
* This and [getParsedDisplayFrame] can be called concurrently.
*/
fun feedDisplayFrame(displayFrame: DisplayFrame?) {
val newParsedDisplayFrame = displayFrame?.let {
ParsedDisplayFrame(it, parseDisplayFrame(it))
}
parsedDisplayFrameChannel.trySend(newParsedDisplayFrame)
_flow.tryEmit(newParsedDisplayFrame)
}
/**
* Returns true if a frame has already been stored by a [feedDisplayFrame] call.
*
* [getParsedDisplayFrame] retrieves a stored frame, so after such a call, this
* would return false again until a new frame is stored with [feedDisplayFrame].
*
* This is not thread safe; it is not safe to call this and [feedDisplayFrame] /
* [getParsedDisplayFrame] simultaneously.
*/
fun hasStoredDisplayFrame(): Boolean =
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
!(parsedDisplayFrameChannel.isEmpty)
/**
* Retrieves the last [ParsedDisplayFrame] that was stored by [feedDisplayFrame].
*
* If no such frame was stored, this suspends until one is stored or [resetAll]
* is called. In the latter case, [ClosedReceiveChannelException] is thrown.
*
* If [filterDuplicates] is set to true, this function compares the current
* parsed frame with the last. If the currently stored frame is found to be
* equal to the last one, it is considered a duplicate, and gets silently
* dropped. This function then waits for a new frame, suspending the coroutine
* until [feedDisplayFrame] is called with a new frame.
*
* In some cases, the last frame that is stored in this class for purposes
* of duplicate detection is not valid anymore and will lead to incorrect
* duplicate detection behavior. In such cases, [resetDuplicate] can be called.
* This erases the internal last frame.
*
* [processAlertScreens] specifies whether this function should pre-check the
* contents of the [ParsedScreen]. If set to true, it will see if the parsed
* screen is a [ParsedScreen.AlertScreen]. If so, it extracts the contents of
* the alert screen and throws an [AlertScreenException]. If instead
* [processAlertScreens] is set to false, alert screens are treated just like
* any other ones.
*
* @return The last frame stored by [feedDisplayFrame].
* @throws ClosedReceiveChannelException if [resetAll] is called while this
* suspends the coroutine and waits for a new frame.
* @throws AlertScreenException if [processAlertScreens] is set to true and
* an alert screen is detected.
* @throws PacketReceiverException thrown when the [TransportLayer.IO] packet
* receiver loop failed due to an exception. Said exception is wrapped in
* a PacketReceiverException and forwarded all the way to this function
* call, which will keep throwing that cause until [resetAll] is called
* to reset the internal states.
*/
suspend fun getParsedDisplayFrame(filterDuplicates: Boolean = false, processAlertScreens: Boolean = true): ParsedDisplayFrame? {
while (true) {
val thisParsedDisplayFrame = parsedDisplayFrameChannel.receive()
val lastParsedDisplayFrame = lastRetrievedParsedDisplayFrame
if (filterDuplicates && (lastParsedDisplayFrame != null) && (thisParsedDisplayFrame != null)) {
val lastParsedScreen = lastParsedDisplayFrame.parsedScreen
val thisParsedScreen = thisParsedDisplayFrame.parsedScreen
val lastDisplayFrame = lastParsedDisplayFrame.displayFrame
val thisDisplayFrame = thisParsedDisplayFrame.displayFrame
// If both last and current screen could not be parsed, we can't compare
// any parsed contents. Resort to comparing pixels in that case instead.
// Normally though we compare contents, since this is faster, and sometimes,
// the pixels change but the contents don't (example: a frame showing the
// time with a blinking ":" character).
val isDuplicate = if ((lastParsedScreen is ParsedScreen.UnrecognizedScreen) && (thisParsedScreen is ParsedScreen.UnrecognizedScreen))
(lastDisplayFrame == thisDisplayFrame)
else
(lastParsedScreen == thisParsedScreen)
if (isDuplicate)
continue
}
lastRetrievedParsedDisplayFrame = thisParsedDisplayFrame
// Blinked-out screens are unusable; skip them, otherwise
// they may mess up RT navigation.
if ((thisParsedDisplayFrame != null) && thisParsedDisplayFrame.parsedScreen.isBlinkedOut) {
logger(LogLevel.DEBUG) { "Screen is blinked out (contents: ${thisParsedDisplayFrame.parsedScreen}); skipping" }
continue
}
if (processAlertScreens && (thisParsedDisplayFrame != null)) {
if (thisParsedDisplayFrame.parsedScreen is ParsedScreen.AlertScreen)
throw AlertScreenException(thisParsedDisplayFrame.parsedScreen.content)
}
return thisParsedDisplayFrame
}
}
private fun createChannel() =
Channel<ParsedDisplayFrame?>(capacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,476 @@
package info.nightscout.comboctl.main
import info.nightscout.comboctl.base.BasicProgressStage
import info.nightscout.comboctl.base.BluetoothAddress
import info.nightscout.comboctl.base.BluetoothInterface
import info.nightscout.comboctl.base.ComboException
import info.nightscout.comboctl.base.Constants
import info.nightscout.comboctl.base.LogLevel
import info.nightscout.comboctl.base.Logger
import info.nightscout.comboctl.base.PairingPIN
import info.nightscout.comboctl.base.ProgressReporter
import info.nightscout.comboctl.base.PumpIO
import info.nightscout.comboctl.base.PumpStateStore
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
private val logger = Logger.get("PumpManager")
/**
* Manager class for acquiring and creating [Pump] instances.
*
* This is the main class for accessing pumps. It manages a list
* of paired pumps and handles discovery and pairing. Applications
* use this class as the primary ComboCtl interface, along with [Pump].
* [bluetoothInterface] is used for device discovery and for creating
* Bluetooth device instances that are then passed to newly paired
* [Pump] instances. [pumpStateStore] contains the pump states for
* all paired pumps.
*
* Before an instance of this class can actually be used, [setup]
* must be called.
*/
class PumpManager(
private val bluetoothInterface: BluetoothInterface,
private val pumpStateStore: PumpStateStore
) {
// Coroutine mutex. This is used to prevent race conditions while
// accessing acquiredPumps and the pumpStateStore. The mutex is needed
// when acquiring pumps (accesses the store and the acquiredPumps map),
// releasing pumps (accesses the acquiredPumps map), when a new pump
// is found during discovery (accesses the store), and when a pump is
// unpaired (accesses the store).
// Note that a coroutine mutex is rather slow. But since the calls
// that use it are not used very often, this is not an issue.
private val pumpStateAccessMutex = Mutex()
// List of Pump instances acquired by acquirePump() calls.
private val acquiredPumps = mutableMapOf<BluetoothAddress, Pump>()
/**
* Stage for when discovery is aborted due to an error.
*/
object DiscoveryError : BasicProgressStage.Aborted("discoveryError")
/**
* Exception thrown when an attempt is made to acquire an already acquired pump.
*
* Pumps cannot be acquired multiple times simulatenously. This is a safety
* measure to prevent multiple [Pump] instances from accessing the same pump,
* which would lead to undefined behavior (partially also because this would
* cause chaos in [pumpStateStore]). See [PumpManager.acquirePump] for more.
*
* @param pumpAddress Bluetooth address of the pump that was already acquired.
*/
class PumpAlreadyAcquiredException(val pumpAddress: BluetoothAddress) :
ComboException("Pump with address $pumpAddress was already acquired")
/**
* Exception thrown when a pump has not been paired and a function requires a paired pump.
*
* @param pumpAddress Bluetooth address of the pump that's not paired.
*/
class PumpNotPairedException(val pumpAddress: BluetoothAddress) :
ComboException("Pump with address $pumpAddress has not been paired")
/**
* Possible results from a [pairWithNewPump] call.
*/
sealed class PairingResult {
data class Success(
val bluetoothAddress: BluetoothAddress,
val pumpID: String
) : PairingResult()
class ExceptionDuringPairing(val exception: Exception) : PairingResult()
object DiscoveryManuallyStopped : PairingResult()
object DiscoveryError : PairingResult()
object DiscoveryTimeout : PairingResult()
}
init {
logger(LogLevel.INFO) { "Pump manager started" }
// Install a filter to make sure we only ever get notified about Combo pumps.
bluetoothInterface.deviceFilterCallback = { deviceAddress -> isCombo(deviceAddress) }
}
/**
* Sets up this PumpManager instance.
*
* Once this is called, the [onPumpUnpaired] callback will be invoked
* whenever a pump is unpaired (this includes unpairing via the system's
* Bluetooth settings). Once this is invoked, the states associated with
* the unpaired pump will already have been wiped from the pump state store.
* That callback is mainly useful for UI updates. Note however that this
* callback is typically called from some background thread. Switching to
* a different context with [kotlinx.coroutines.withContext] may be necessary.
*
* This also checks the available states in the pump state store and compares
* this with the list of paired device addresses returned by the
* [BluetoothInterface.getPairedDeviceAddresses] function to check for pumps
* that may have been unpaired while ComboCtl was not running. This makes sure
* that there are no stale states inside the store which otherwise would impact
* the event handling and cause other IO issues (especially in future pairing
* attempts). If that check reveals states in [pumpStateStore] that do not
* have a corresponding device in the list of paired device addresses, then
* those stale pump states are erased.
*
* This must be called before using [pairWithNewPump] or [acquirePump].
*
* @param onPumpUnpaired Callback for when a previously paired pump is unpaired.
* This is typically called from some background thread. Switching to
* a different context with [withContext] may be necessary.
*/
fun setup(onPumpUnpaired: (pumpAddress: BluetoothAddress) -> Unit = { }) {
bluetoothInterface.onDeviceUnpaired = { deviceAddress ->
onPumpUnpaired(deviceAddress)
// Explicitly wipe the pump state to make sure that no stale pump state remains.
pumpStateStore.deletePumpState(deviceAddress)
}
val pairedDeviceAddresses = bluetoothInterface.getPairedDeviceAddresses()
logger(LogLevel.DEBUG) { "${pairedDeviceAddresses.size} paired Bluetooth device(s)" }
val availablePumpStates = pumpStateStore.getAvailablePumpStateAddresses()
logger(LogLevel.DEBUG) { "${availablePumpStates.size} available pump state(s)" }
for (deviceAddress in pairedDeviceAddresses) {
logger(LogLevel.DEBUG) { "Paired Bluetooth device: $deviceAddress" }
}
for (pumpStateAddress in availablePumpStates) {
logger(LogLevel.DEBUG) { "Got state for pump with address $pumpStateAddress" }
}
// We need to keep the list of paired devices and the list of pump states in sync
// to keep the pairing process working properly (avoiding pairing attempts when
// at Bluetooth level the pump is already paired but at the pump state store level
// it isn't) and to prevent incorrect connection attempts from happening
// (avoiding connection attempts when at Bluetooth level the pump isn't paired
// but at the pump state level it seems like it is).
// Check for pump states that correspond to those pumps that are no longer paired.
// This can happen if the user unpaired the Combo in the Bluetooth settings
// while the pump manager wasn't running.
for (pumpStateAddress in availablePumpStates) {
val pairedDevicePresent = pairedDeviceAddresses.contains(pumpStateAddress)
if (!pairedDevicePresent) {
logger(LogLevel.DEBUG) { "There is no paired device for pump state with address $pumpStateAddress; deleting state" }
pumpStateStore.deletePumpState(pumpStateAddress)
}
}
}
private val pairingProgressReporter = ProgressReporter(
listOf(
BasicProgressStage.ScanningForPumpStage::class,
BasicProgressStage.EstablishingBtConnection::class,
BasicProgressStage.PerformingConnectionHandshake::class,
BasicProgressStage.ComboPairingKeyAndPinRequested::class,
BasicProgressStage.ComboPairingFinishing::class
),
Unit
)
/**
* [kotlinx.coroutines.flow.StateFlow] for reporting progress during the [pairWithNewPump] call.
*
* See the [ProgressReporter] documentation for details.
*/
val pairingProgressFlow = pairingProgressReporter.progressFlow
/**
* Resets the state of the [pairingProgressFlow] back to [BasicProgressStage.Idle].
*
* This is useful in case the user wants to try again to pair with a pump.
* By resetting the state, it is easier to manage UI elements when such
* a pairing retry is attempted, especially if the UI orients itself on
* the stage field of the [pairingProgressFlow] value, which is
* a [info.nightscout.comboctl.base.ProgressReport].
*/
fun resetPairingProgress() = pairingProgressReporter.reset()
/**
* Starts device discovery and pairs with a pump once one is discovered.
*
* This function suspends the calling coroutine until a device is found,
* the coroutine is cancelled, an error happens during discovery, or
* discovery timeouts.
*
* This manages the Bluetooth device discovery and the pairing
* process with new pumps. Once an unpaired pump is discovered,
* the Bluetooth implementation pairs with it, using the
* [Constants.BT_PAIRING_PIN] PIN code (not to be confused with
* the 10-digit Combo PIN).
*
* When the Bluetooth-level pairing is done, additional processing is
* necessary: The Combo-level pairing must be performed, which also sets
* up a state in the [PumpStateStore] for the discovered pump.
* [onPairingPIN] is called when the Combo-level pairing process
* reaches a point where the user must be asked for the 10-digit PIN.
*
* Note that [onPairingPIN] is called by a coroutine that is run on
* a different thread than the one that called this function . With
* some UI frameworks like JavaFX, it is invalid to operate UI controls
* in coroutines that are not associated with a particular UI coroutine
* context. Consider using [kotlinx.coroutines.withContext] in
* [onPairingPIN] for this reason.
*
* Before the pairing starts, this function compares the list of paired
* Bluetooth device addresses with the list of pump states in the
* [pumpStateStore]. This is similar to the checks done in [setup], except
* it is reversed: Each paired device that has no corresponding pump
* state in the [pumpStateStore] is unpaired before the new pairing begins.
* This is useful to prevent situations where a Combo isn't actually paired,
* but [pairWithNewPump] doesn't detect them, because at a Bluetooth level,
* that Combo _is_ still paired (that is, the OS has it listed among its
* paired devices).
*
* @param discoveryDuration How long the discovery shall go on,
* in seconds. Must be a value between 1 and 300.
* @param onPairingPIN Suspending block that asks the user for
* the 10-digit pairing PIN during the pairing process.
* @throws info.nightscout.comboctl.base.BluetoothException if discovery
* fails due to an underlying Bluetooth issue.
*/
suspend fun pairWithNewPump(
discoveryDuration: Int,
onPairingPIN: suspend (newPumpAddress: BluetoothAddress, previousAttemptFailed: Boolean) -> PairingPIN
): PairingResult {
val deferred = CompletableDeferred<PairingResult>()
lateinit var result: PairingResult
// Before doing the actual pairing, unpair devices that have no corresponding pump state.
val pairedDeviceAddresses = bluetoothInterface.getPairedDeviceAddresses()
logger(LogLevel.DEBUG) { "${pairedDeviceAddresses.size} paired Bluetooth device(s)" }
val availablePumpStates = pumpStateStore.getAvailablePumpStateAddresses()
logger(LogLevel.DEBUG) { "${availablePumpStates.size} available pump state(s)" }
// Check for paired pumps that have no corresponding state. This can happen if
// the state was deleted and the application crashed before it could unpair the
// pump, or if some other application paired the pump. Those devices get unpaired.
for (pairedDeviceAddress in pairedDeviceAddresses) {
val pumpStatePresent = availablePumpStates.contains(pairedDeviceAddress)
if (!pumpStatePresent) {
if (isCombo(pairedDeviceAddress)) {
logger(LogLevel.DEBUG) { "There is no pump state for paired pump with address $pairedDeviceAddresses; unpairing" }
val bluetoothDevice = bluetoothInterface.getDevice(pairedDeviceAddress)
bluetoothDevice.unpair()
}
}
}
// Unpairing unknown devices done. Actual pairing of a new pump continues now.
pairingProgressReporter.reset(Unit)
// Spawn an internal coroutine scope since we need to launch new coroutines during discovery & pairing.
coroutineScope {
val thisScope = this
try {
pairingProgressReporter.setCurrentProgressStage(BasicProgressStage.ScanningForPumpStage)
bluetoothInterface.startDiscovery(
sdpServiceName = Constants.BT_SDP_SERVICE_NAME,
sdpServiceProvider = "ComboCtl SDP service",
sdpServiceDescription = "ComboCtl",
btPairingPin = Constants.BT_PAIRING_PIN,
discoveryDuration = discoveryDuration,
onDiscoveryStopped = { reason ->
when (reason) {
BluetoothInterface.DiscoveryStoppedReason.MANUALLY_STOPPED ->
deferred.complete(PairingResult.DiscoveryManuallyStopped)
BluetoothInterface.DiscoveryStoppedReason.DISCOVERY_ERROR ->
deferred.complete(PairingResult.DiscoveryError)
BluetoothInterface.DiscoveryStoppedReason.DISCOVERY_TIMEOUT ->
deferred.complete(PairingResult.DiscoveryTimeout)
}
},
onFoundNewPairedDevice = { deviceAddress ->
thisScope.launch {
pumpStateAccessMutex.withLock {
try {
logger(LogLevel.DEBUG) { "Found pump with address $deviceAddress" }
if (pumpStateStore.hasPumpState(deviceAddress)) {
logger(LogLevel.DEBUG) { "Skipping added pump since it has already been paired" }
} else {
performPairing(deviceAddress, onPairingPIN, pairingProgressReporter)
val pumpID = pumpStateStore.getInvariantPumpData(deviceAddress).pumpID
logger(LogLevel.DEBUG) { "Paired pump with address $deviceAddress ; pump ID = $pumpID" }
deferred.complete(PairingResult.Success(deviceAddress, pumpID))
}
} catch (e: Exception) {
logger(LogLevel.ERROR) { "Caught exception while pairing to pump with address $deviceAddress: $e" }
deferred.completeExceptionally(e)
throw e
}
}
}
}
)
result = deferred.await()
} catch (e: CancellationException) {
logger(LogLevel.DEBUG) { "Pairing cancelled" }
pairingProgressReporter.setCurrentProgressStage(BasicProgressStage.Cancelled)
throw e
} catch (e: Exception) {
pairingProgressReporter.setCurrentProgressStage(BasicProgressStage.Error(e))
result = PairingResult.ExceptionDuringPairing(e)
throw e
} finally {
bluetoothInterface.stopDiscovery()
}
}
when (result) {
// Report Finished/Aborted _after_ discovery was stopped
// (otherwise it isn't really finished/aborted yet).
is PairingResult.Success ->
pairingProgressReporter.setCurrentProgressStage(BasicProgressStage.Finished)
is PairingResult.DiscoveryTimeout ->
pairingProgressReporter.setCurrentProgressStage(BasicProgressStage.Timeout)
is PairingResult.DiscoveryManuallyStopped ->
pairingProgressReporter.setCurrentProgressStage(BasicProgressStage.Cancelled)
is PairingResult.DiscoveryError ->
pairingProgressReporter.setCurrentProgressStage(DiscoveryError)
// The other cases are covered by the catch clauses above.
else -> Unit
}
return result
}
/**
* Returns a set of Bluetooth addresses of the paired pumps.
*
* This equals the list of addresses of all the pump states in the
* [PumpStateStore] assigned to this PumpManager instance.
*/
fun getPairedPumpAddresses() = pumpStateStore.getAvailablePumpStateAddresses()
/**
* Returns the ID of the paired pump with the given address.
*
* @return String with the pump ID.
* @throws PumpStateDoesNotExistException if no pump state associated with
* the given address exists in the store.
* @throws PumpStateStoreAccessException if accessing the data fails
* due to an error that occurred in the underlying implementation.
*/
fun getPumpID(pumpAddress: BluetoothAddress) =
pumpStateStore.getInvariantPumpData(pumpAddress).pumpID
/**
* Acquires a Pump instance for a pump with the given Bluetooth address.
*
* Pumps can only be acquired once at a time. This is a safety measure to
* prevent multiple [Pump] instances from accessing the same pump, which
* would lead to undefined behavior. An acquired pump must be un-acquired
* by calling [releasePump]. Attempting to acquire an already acquired
* pump is an error and will cause this function to throw an exception
* ([PumpAlreadyAcquiredException]).
*
* The pump must have been paired before it can be acquired. If this is
* not done, an [PumpNotPairedException] is thrown.
*
* For details about [initialBasalProfile] and [onEvent], consult the
* [Pump] documentation.
*
* @param pumpAddress Bluetooth address of the pump to acquire.
* @param initialBasalProfile Basal profile to use as the initial profile,
* or null if no initial profile shall be used.
* @param onEvent Callback to inform caller about events that happen
* during a connection, like when the battery is going low, or when
* a TBR started.
* @throws PumpAlreadyAcquiredException if the pump was already acquired.
* @throws PumpNotPairedException if the pump was not yet paired.
* @throws info.nightscout.comboctl.base.BluetoothException if getting
* a [info.nightscout.comboctl.base.BluetoothDevice] for this pump fails.
*/
suspend fun acquirePump(
pumpAddress: BluetoothAddress,
initialBasalProfile: BasalProfile? = null,
onEvent: (event: Pump.Event) -> Unit = { }
) =
pumpStateAccessMutex.withLock {
if (acquiredPumps.contains(pumpAddress))
throw PumpAlreadyAcquiredException(pumpAddress)
logger(LogLevel.DEBUG) { "Getting Pump instance for pump $pumpAddress" }
if (!pumpStateStore.hasPumpState(pumpAddress))
throw PumpNotPairedException(pumpAddress)
val bluetoothDevice = bluetoothInterface.getDevice(pumpAddress)
val pump = Pump(bluetoothDevice, pumpStateStore, initialBasalProfile, onEvent)
acquiredPumps[pumpAddress] = pump
pump // Return the Pump instance
}
/**
* Releases (= un-acquires) a previously acquired pump with the given address.
*
* If no such pump was previously acquired, this function does nothing.
*
* @param acquiredPumpAddress Bluetooth address of the pump to release.
*/
suspend fun releasePump(acquiredPumpAddress: BluetoothAddress) {
pumpStateAccessMutex.withLock {
if (!acquiredPumps.contains(acquiredPumpAddress)) {
logger(LogLevel.DEBUG) { "A pump with address $acquiredPumpAddress wasn't previously acquired; ignoring call" }
return@withLock
}
acquiredPumps.remove(acquiredPumpAddress)
}
}
// Filter for Combo devices based on their address.
// The first 3 bytes of a Combo are always the same.
private fun isCombo(deviceAddress: BluetoothAddress) =
(deviceAddress[0] == 0x00.toByte()) &&
(deviceAddress[1] == 0x0E.toByte()) &&
(deviceAddress[2] == 0x2F.toByte())
private suspend fun performPairing(
pumpAddress: BluetoothAddress,
onPairingPIN: suspend (newPumpAddress: BluetoothAddress, previousAttemptFailed: Boolean) -> PairingPIN,
progressReporter: ProgressReporter<Unit>?
) {
// NOTE: Pairing can be aborted either by calling stopDiscovery()
// or by cancelling the coroutine that runs this functions.
logger(LogLevel.DEBUG) { "About to perform pairing with pump $pumpAddress" }
val bluetoothDevice = bluetoothInterface.getDevice(pumpAddress)
logger(LogLevel.DEBUG) { "Got Bluetooth device instance for pump" }
val pumpIO = PumpIO(pumpStateStore, bluetoothDevice, onNewDisplayFrame = {}, onPacketReceiverException = {})
if (pumpIO.isPaired()) {
logger(LogLevel.INFO) { "Not pairing discovered pump $pumpAddress since it is already paired" }
return
}
logger(LogLevel.DEBUG) { "Pump instance ready for pairing" }
pumpIO.performPairing(bluetoothInterface.getAdapterFriendlyName(), progressReporter, onPairingPIN)
logger(LogLevel.DEBUG) { "Successfully paired with pump $pumpAddress" }
}
}

View file

@ -0,0 +1,15 @@
package info.nightscout.comboctl.parser
/**
* Reservoir state as shown on display.
*/
enum class ReservoirState {
EMPTY,
LOW,
FULL
}
/**
* Data class with the contents of the RT quickinfo screen.
*/
data class Quickinfo(val availableUnits: Int, val reservoirState: ReservoirState)

View file

@ -0,0 +1,319 @@
package info.nightscout.comboctl.parser
/**
* IDs of known titles.
*
* Used during parsing to identify the titles in a language-
* independent manner by using the parsed screen title as a
* key in the [knownScreenTitles] table below.
*/
enum class TitleID {
QUICK_INFO,
TBR_PERCENTAGE,
TBR_DURATION,
HOUR,
MINUTE,
YEAR,
MONTH,
DAY,
BOLUS_DATA,
ERROR_DATA,
DAILY_TOTALS,
TBR_DATA
}
/**
* Known screen titles in various languages, associated to corresponding IDs.
*
* This table is useful for converting parsed screen titles to
* IDs, which are language-independent and thus considerably
* more useful for identifying screens.
*
* The titles are written in uppercase, since this shows
* subtle nuances in characters better.
*/
val knownScreenTitles = mapOf(
// English
"QUICK INFO" to TitleID.QUICK_INFO,
"TBR PERCENTAGE" to TitleID.TBR_PERCENTAGE,
"TBR DURATION" to TitleID.TBR_DURATION,
"HOUR" to TitleID.HOUR,
"MINUTE" to TitleID.MINUTE,
"YEAR" to TitleID.YEAR,
"MONTH" to TitleID.MONTH,
"DAY" to TitleID.DAY,
"BOLUS DATA" to TitleID.BOLUS_DATA,
"ERROR DATA" to TitleID.ERROR_DATA,
"DAILY TOTALS" to TitleID.DAILY_TOTALS,
"TBR DATA" to TitleID.TBR_DATA,
// Spanish
"QUICK INFO" to TitleID.QUICK_INFO,
"PORCENTAJE DBT" to TitleID.TBR_PERCENTAGE,
"DURACIÓN DE DBT" to TitleID.TBR_DURATION,
"HORA" to TitleID.HOUR,
"MINUTO" to TitleID.MINUTE,
"AÑO" to TitleID.YEAR,
"MES" to TitleID.MONTH,
"DÍA" to TitleID.DAY,
"DATOS DE BOLO" to TitleID.BOLUS_DATA,
"DATOS DE ERROR" to TitleID.ERROR_DATA,
"TOTALES DIARIOS" to TitleID.DAILY_TOTALS,
"DATOS DE DBT" to TitleID.TBR_DATA,
// French
"QUICK INFO" to TitleID.QUICK_INFO,
"VALEUR DU DBT" to TitleID.TBR_PERCENTAGE,
"DURÉE DU DBT" to TitleID.TBR_DURATION,
"HEURE" to TitleID.HOUR,
"MINUTES" to TitleID.MINUTE,
"ANNÉE" to TitleID.YEAR,
"MOIS" to TitleID.MONTH,
"JOUR" to TitleID.DAY,
"BOLUS" to TitleID.BOLUS_DATA,
"ERREURS" to TitleID.ERROR_DATA,
"QUANTITÉS JOURN." to TitleID.DAILY_TOTALS,
"DBT" to TitleID.TBR_DATA,
// Italian
"QUICK INFO" to TitleID.QUICK_INFO,
"PERCENTUALE PBT" to TitleID.TBR_PERCENTAGE,
"DURATA PBT" to TitleID.TBR_DURATION,
"IMPOSTARE ORA" to TitleID.HOUR,
"IMPOSTARE MINUTI" to TitleID.MINUTE,
"IMPOSTARE ANNO" to TitleID.YEAR,
"IMPOSTARE MESE" to TitleID.MONTH,
"IMPOSTARE GIORNO" to TitleID.DAY,
"MEMORIA BOLI" to TitleID.BOLUS_DATA,
"MEMORIA ALLARMI" to TitleID.ERROR_DATA,
"TOTALI GIORNATA" to TitleID.DAILY_TOTALS,
"MEMORIA PBT" to TitleID.TBR_DATA,
// Russian
"QUICK INFO" to TitleID.QUICK_INFO,
"ПPOЦEHT BБC" to TitleID.TBR_PERCENTAGE,
"ПPOДOЛЖИT. BБC" to TitleID.TBR_DURATION,
"ЧА" to TitleID.HOUR,
"МИHУ" to TitleID.MINUTE,
"ГOД" to TitleID.YEAR,
"МECЯЦ" to TitleID.MONTH,
"ДEHЬ" to TitleID.DAY,
"ДАHHЫE O БOЛЮCE" to TitleID.BOLUS_DATA,
"ДАHHЫE OБ O ИБ." to TitleID.ERROR_DATA,
"CУTOЧHЫE ДOЗЫ" to TitleID.DAILY_TOTALS,
"ДАHHЫE O BБC" to TitleID.TBR_DATA,
// Turkish
"QUICK INFO" to TitleID.QUICK_INFO,
"GBH YÜZDESİ" to TitleID.TBR_PERCENTAGE,
"GBH SÜRESİ" to TitleID.TBR_DURATION,
"SAAT" to TitleID.HOUR,
"DAKİKA" to TitleID.MINUTE,
"YIL" to TitleID.YEAR,
"AY" to TitleID.MONTH,
"GÜN" to TitleID.DAY,
"BOLUS VERİLERİ" to TitleID.BOLUS_DATA,
"HATA VERİLERİ" to TitleID.ERROR_DATA,
"GÜNLÜK TOPLAM" to TitleID.DAILY_TOTALS,
"GBH VERİLERİ" to TitleID.TBR_DATA,
// Polish
"QUICK INFO" to TitleID.QUICK_INFO,
"PROCENT TDP" to TitleID.TBR_PERCENTAGE,
"CZAS TRWANIA TDP" to TitleID.TBR_DURATION,
"GODZINA" to TitleID.HOUR,
"MINUTA" to TitleID.MINUTE,
"ROK" to TitleID.YEAR,
"MIESIĄC" to TitleID.MONTH,
"DZIEŃ" to TitleID.DAY,
"DANE BOLUSA" to TitleID.BOLUS_DATA,
"DANE BŁĘDU" to TitleID.ERROR_DATA,
"DZIEN. D. CAŁK." to TitleID.DAILY_TOTALS,
"DANE TDP" to TitleID.TBR_DATA,
// Czech
"QUICK INFO" to TitleID.QUICK_INFO,
"PROCENTO DBD" to TitleID.TBR_PERCENTAGE,
"TRVÁNÍ DBD" to TitleID.TBR_DURATION,
"HODINA" to TitleID.HOUR,
"MINUTA" to TitleID.MINUTE,
"ROK" to TitleID.YEAR,
"MĚSÍC" to TitleID.MONTH,
"DEN" to TitleID.DAY,
"ÚDAJE BOLUSŮ" to TitleID.BOLUS_DATA,
"ÚDAJE CHYB" to TitleID.ERROR_DATA,
"CELK. DEN. DÁVKY" to TitleID.DAILY_TOTALS,
"ÚDAJE DBD" to TitleID.TBR_DATA,
// Hungarian
"QUICK INFO" to TitleID.QUICK_INFO,
"TBR SZÁZALÉK" to TitleID.TBR_PERCENTAGE,
"TBR IDŐTARTAM" to TitleID.TBR_DURATION,
"ÓRA" to TitleID.HOUR,
"PERC" to TitleID.MINUTE,
"ÉV" to TitleID.YEAR,
"HÓNAP" to TitleID.MONTH,
"NAP" to TitleID.DAY,
"BÓLUSADATOK" to TitleID.BOLUS_DATA,
"HIBAADATOK" to TitleID.ERROR_DATA,
"NAPI TELJES" to TitleID.DAILY_TOTALS,
"TBR-ADATOK" to TitleID.TBR_DATA,
// Slovak
"QUICK INFO" to TitleID.QUICK_INFO,
"PERCENTO DBD" to TitleID.TBR_PERCENTAGE,
"TRVANIE DBD" to TitleID.TBR_DURATION,
"HODINA" to TitleID.HOUR,
"MINÚTA" to TitleID.MINUTE,
"ROK" to TitleID.YEAR,
"MESIAC" to TitleID.MONTH,
"DEŇ" to TitleID.DAY,
"BOLUSOVÉ DÁTA" to TitleID.BOLUS_DATA,
"DÁTA O CHYBÁCH" to TitleID.ERROR_DATA,
"SÚČTY DŇA" to TitleID.DAILY_TOTALS,
"DBD DÁTA" to TitleID.TBR_DATA,
// Romanian
"QUICK INFO" to TitleID.QUICK_INFO,
"PROCENT RBT" to TitleID.TBR_PERCENTAGE,
"DURATA RBT" to TitleID.TBR_DURATION,
"ORĂ" to TitleID.HOUR,
"MINUT" to TitleID.MINUTE,
"AN" to TitleID.YEAR,
"LUNĂ" to TitleID.MONTH,
"ZI" to TitleID.DAY,
"DATE BOLUS" to TitleID.BOLUS_DATA,
"DATE EROARE" to TitleID.ERROR_DATA,
"TOTALURI ZILNICE" to TitleID.DAILY_TOTALS,
"DATE RBT" to TitleID.TBR_DATA,
// Croatian
"QUICK INFO" to TitleID.QUICK_INFO,
"POSTOTAK PBD-A" to TitleID.TBR_PERCENTAGE,
"TRAJANJE PBD-A" to TitleID.TBR_DURATION,
"SAT" to TitleID.HOUR,
"MINUTE" to TitleID.MINUTE,
"GODINA" to TitleID.YEAR,
"MJESEC" to TitleID.MONTH,
"DAN" to TitleID.DAY,
"PODACI O BOLUSU" to TitleID.BOLUS_DATA,
"PODACI O GREŠK." to TitleID.ERROR_DATA,
"UKUPNE DNEV.DOZE" to TitleID.DAILY_TOTALS,
"PODACI O PBD-U" to TitleID.TBR_DATA,
// Dutch
"QUICK INFO" to TitleID.QUICK_INFO,
"TBD-PERCENTAGE" to TitleID.TBR_PERCENTAGE,
"TBD-DUUR" to TitleID.TBR_DURATION,
"UREN" to TitleID.HOUR,
"MINUTEN" to TitleID.MINUTE,
"JAAR" to TitleID.YEAR,
"MAAND" to TitleID.MONTH,
"DAG" to TitleID.DAY,
"BOLUSGEGEVENS" to TitleID.BOLUS_DATA,
"FOUTENGEGEVENS" to TitleID.ERROR_DATA,
"DAGTOTALEN" to TitleID.DAILY_TOTALS,
"TBD-GEGEVENS" to TitleID.TBR_DATA,
// Greek
"QUICK INFO" to TitleID.QUICK_INFO,
"ПOΣOΣTO П.B.P." to TitleID.TBR_PERCENTAGE,
"ΔIАPKEIА П.B.P." to TitleID.TBR_DURATION,
"ΩPА" to TitleID.HOUR,
"ΛEПTO" to TitleID.MINUTE,
"ETOΣ" to TitleID.YEAR,
"МHNАΣ" to TitleID.MONTH,
"HМEPА" to TitleID.DAY,
"ΔEΔOМENА ΔOΣEΩN" to TitleID.BOLUS_DATA,
"ΔEΔOМ. ΣΦАΛМАTΩN" to TitleID.ERROR_DATA,
"HМEPHΣIO ΣΥNOΛO" to TitleID.DAILY_TOTALS,
"ΔEΔOМENА П.B.P." to TitleID.TBR_DATA,
// Finnish
"QUICK INFO" to TitleID.QUICK_INFO,
"TBA - PROSENTTI" to TitleID.TBR_PERCENTAGE,
"TBA - KESTO" to TitleID.TBR_DURATION,
"TUNTI" to TitleID.HOUR,
"MINUUTTI" to TitleID.MINUTE,
"VUOSI" to TitleID.YEAR,
"KUUKAUSI" to TitleID.MONTH,
"PÄIVÄ" to TitleID.DAY,
"BOLUSTIEDOT" to TitleID.BOLUS_DATA,
"HÄLYTYSTIEDOT" to TitleID.ERROR_DATA,
"PÄIV. KOK.ANNOS" to TitleID.DAILY_TOTALS,
"TBA - TIEDOT" to TitleID.TBR_DATA,
// Norwegian
"QUICK INFO" to TitleID.QUICK_INFO,
"MBD-PROSENT" to TitleID.TBR_PERCENTAGE,
"MBD-VARIGHET" to TitleID.TBR_DURATION,
"TIME" to TitleID.HOUR,
"MINUTT" to TitleID.MINUTE,
"ÅR" to TitleID.YEAR,
"MÅNED" to TitleID.MONTH,
"DAG" to TitleID.DAY,
"BOLUSDATA" to TitleID.BOLUS_DATA,
"FEILDATA" to TitleID.ERROR_DATA,
"DØGNMENGDE" to TitleID.DAILY_TOTALS,
"MBD-DATA" to TitleID.TBR_DATA,
// Portuguese
"QUICK INFO" to TitleID.QUICK_INFO,
"DBT PERCENTAGEM" to TitleID.TBR_PERCENTAGE,
"DBT DURAÇÃO" to TitleID.TBR_DURATION,
"HORA" to TitleID.HOUR,
"MINUTO" to TitleID.MINUTE,
"ANO" to TitleID.YEAR,
"MÊS" to TitleID.MONTH,
"DIA" to TitleID.DAY,
"DADOS DE BOLUS" to TitleID.BOLUS_DATA,
// on some newer pumps translations have changed, so a menu can have multiple names
"DADOS DE ERROS" to TitleID.ERROR_DATA, "DADOS DE ALARMES" to TitleID.ERROR_DATA,
"TOTAIS DIÁRIOS" to TitleID.DAILY_TOTALS,
"DADOS DBT" to TitleID.TBR_DATA,
// Swedish
"QUICK INFO" to TitleID.QUICK_INFO,
"TBD PROCENT" to TitleID.TBR_PERCENTAGE,
"TBD DURATION" to TitleID.TBR_DURATION,
"TIMME" to TitleID.HOUR,
"MINUT" to TitleID.MINUTE,
"ÅR" to TitleID.YEAR,
"MÅNAD" to TitleID.MONTH,
"DAG" to TitleID.DAY,
"BOLUSDATA" to TitleID.BOLUS_DATA,
"FELDATA" to TitleID.ERROR_DATA,
"DYGNSHISTORIK" to TitleID.DAILY_TOTALS,
"TBD DATA" to TitleID.TBR_DATA,
// Danish
"QUICK INFO" to TitleID.QUICK_INFO,
"MBR-PROCENT" to TitleID.TBR_PERCENTAGE,
"MBR-VARIGHED" to TitleID.TBR_DURATION,
"TIME" to TitleID.HOUR,
"MINUT" to TitleID.MINUTE,
"ÅR" to TitleID.YEAR,
"MÅNED" to TitleID.MONTH,
"DAG" to TitleID.DAY,
"BOLUSDATA" to TitleID.BOLUS_DATA,
"FEJLDATA" to TitleID.ERROR_DATA,
"DAGLIG TOTAL" to TitleID.DAILY_TOTALS,
"MBR-DATA" to TitleID.TBR_DATA,
// German
"QUICK INFO" to TitleID.QUICK_INFO,
"TBR WERT" to TitleID.TBR_PERCENTAGE,
"TBR DAUER" to TitleID.TBR_DURATION,
"STUNDE" to TitleID.HOUR,
"MINUTE" to TitleID.MINUTE,
"JAHR" to TitleID.YEAR,
"MONAT" to TitleID.MONTH,
"TAG" to TitleID.DAY,
"BOLUSINFORMATION" to TitleID.BOLUS_DATA,
"FEHLERMELDUNGEN" to TitleID.ERROR_DATA,
"TAGESGESAMTMENGE" to TitleID.DAILY_TOTALS,
"TBR-INFORMATION" to TitleID.TBR_DATA,
// Some pumps came preconfigured with a different quick info name
"ACCU CHECK SPIRIT" to TitleID.QUICK_INFO
)

View file

@ -0,0 +1,222 @@
package info.nightscout.comboctl.parser
import info.nightscout.comboctl.base.DISPLAY_FRAME_HEIGHT
import info.nightscout.comboctl.base.DISPLAY_FRAME_WIDTH
import info.nightscout.comboctl.base.DisplayFrame
import kotlin.math.sign
/**
* Structure containing details about a match discovered in a [DisplayFrame].
*
* The match is referred to as a "token", similar to lexical tokens
* in lexical analyzers.
*
* This is the result of a pattern search in a display frame.
*
* @property pattern The pattern for which a match was found.
* @property glyph [Glyph] associated with the pattern.
* @property x X-coordinate of the location of the token in the display frame.
* @property y Y-coordinate of the location of the token in the display frame.
*/
data class Token(
val pattern: Pattern,
val glyph: Glyph,
val x: Int,
val y: Int
)
/**
* List of tokens found in the display frame by the [findTokens] function.
*/
typealias Tokens = List<Token>
/**
* Checks if the region at the given coordinates matches the given pattern.
*
* This is used for finding tokerns in a frame.
*
* @param displayFrame [DisplayFrame] that contains the region to match the pattern against.
* @param pattern Pattern to match with the region in the display frame.
* @param x X-coordinate of the region in the display frame.
* @param y Y-coordinate of the region in the display frame.
* @return true if the region matches the pattern. false in case of mismatch
* or if the coordinates would place the pattern (partially) outside
* of the bounds of the display frame.
*/
fun checkIfPatternMatchesAt(displayFrame: DisplayFrame, pattern: Pattern, x: Int, y: Int): Boolean {
if ((x < 0) || (y < 0) ||
((x + pattern.width) > DISPLAY_FRAME_WIDTH) ||
((y + pattern.height) > DISPLAY_FRAME_HEIGHT))
return false
// Simple naive brute force match.
// TODO: See if a two-dimensional variant of the Boyer-Moore algorithm can be used instead.
for (py in 0 until pattern.height) {
for (px in 0 until pattern.width) {
val patternPixel = pattern.pixels[px + py * pattern.width]
val framePixel = displayFrame.getPixelAt(
x + px,
y + py
)
if (patternPixel != framePixel)
return false
}
}
return true
}
/**
* Look for regions in the display frame that can be turned into tokens.
*
* This will first do a pattern matching search, and then try to filter out matches that
* overlap with other matches and are considered to be unnecessary / undesirable by the
* internal heuristic (for example, a part of the multiwave bolus pattern also looks like
* the character L, but in case of such an overlap, we are interested in the former).
* The remaining matches are output as tokens.
*
* @param displayFrame [DisplayFrame] to search for tokens.
* @return Tokens found in this frame.
*/
fun findTokens(displayFrame: DisplayFrame): Tokens {
val tokens = mutableListOf<Token>()
// Scan through the display frame and look for tokens.
var y = 0
while (y < DISPLAY_FRAME_HEIGHT) {
var x = 0
while (x < DISPLAY_FRAME_WIDTH) {
for ((glyph, pattern) in glyphPatterns) {
if (checkIfPatternMatchesAt(displayFrame, pattern, x, y)) {
// Current region in the display frame matches this pattern.
// Create a token out of the pattern, glyph, and coordinates,
// add the token to the list of found tokens, and move past the
// matched pattern horizontally. (There's no point in advancing
// pixel by pixel horizontally since the next pattern.width pixels
// are guaranteed to be part of the already discovered token).
tokens.add(Token(pattern, glyph, x, y))
x += pattern.width - 1 // -1 since the x value is incremented below.
break
}
}
x++
}
y++
}
// Check for overlaps. The pattern matching is not automatically unambiguous.
// For example, one of the corners of the multiwave bolus icon also matches
// the small 'L' character pattern. Try to resolve overlaps here and remove
// tokens if required. (In the example above, the 'L' pattern match is not
// needed and can be discarded - the match of interest there is the multiwave
// bolus icon pattern match.)
val tokensToRemove = mutableSetOf<Token>()
// First, determine what tokens to remove.
for (tokenB in tokens) {
for (tokenA in tokens) {
// Get the coordinates of the top-left (x1,y1) and bottom-right (x2,y2)
// corners of the bounding rectangles of both matches. The (x2,y2)
// coordinates are inclusive, that is, still inside the rectangle, and
// at the rectangle's bottom right corner. (That's why there's the -1
// in the calculations below; it avoids a fencepost error.)
val tokenAx1 = tokenA.x
val tokenAy1 = tokenA.y
val tokenAx2 = tokenA.x + tokenA.pattern.width - 1
val tokenAy2 = tokenA.y + tokenA.pattern.height - 1
val tokenBx1 = tokenB.x
val tokenBy1 = tokenB.y
val tokenBx2 = tokenB.x + tokenB.pattern.width - 1
val tokenBy2 = tokenB.y + tokenB.pattern.height - 1
/* Overlap detection:
Given two rectangles A and B:
Example of non-overlap:
< A >
< B >
|---xd2---|
|-----------------------------------------xd1-----------------------------------------|
Example of overlap:
< A >
< B >
|----------xd2----------|
|------------------------xd1------------------------|
xd1 = distance from A.x1 to B.x2
xd2 = distance from A.x2 to B.x1
If B is fully to the right of A, then both xd1 and xd2 are positive.
If B is fully to the left of A, then both xd1 and xd2 are negative.
If xd1 is positive and xd2 is negative (or vice versa), then A and B are overlapping in the X direction.
The same tests are done in Y direction.
If A and B overlap in both X and Y direction, they overlap overall.
It follows that:
if (xd1 is positive and xd2 is negative) or (xd1 is negative and xd2 is positive) and
(yd1 is positive and yd2 is negative) or (yd1 is negative and yd2 is positive) -> A and B overlap.
The (xd1 is positive and xd2 is negative) or (xd1 is negative and xd2 is positive) check
can be shorted to: (sign(xd1) != sign(xd2)). The same applies to the checks in the Y direction.
-> Final check: if (sign(xd1) != sign(xd2)) and (sign(yd1) != sign(yd2)) -> A and B overlap.
*/
val xd1 = (tokenBx2 - tokenAx1)
val xd2 = (tokenBx1 - tokenAx2)
val yd1 = (tokenBy2 - tokenAy1)
val yd2 = (tokenBy1 - tokenAy2)
val tokensOverlap = (xd1.sign != xd2.sign) && (yd1.sign != yd2.sign)
if (tokensOverlap) {
// Heuristic for checking if one of the two overlapping tokens
// needs to be removed:
//
// 1. If one token has a large pattern and the other doesn't,
// keep the large one and discard the smaller one. Parts of larger
// patterns can be misintepreted as some of the smaller patterns,
// which is the reason for this heuristic.
// 2. If one token has a larger numSetPixels value than the other,
// pick that one. A higher number of set pixels is considered to
// indicate a more "complex" or "informative" pattern. For example,
// the 2 blocks of 2x2 pixels at the top ends of the large 'U'
// character token can also be interpreted as a large dot token.
// However, the large dot token has 4 set pixels, while the large
// 'U' character token has many more, so the latter "wins".
if (tokenA.glyph.isLarge && !tokenB.glyph.isLarge)
tokensToRemove.add(tokenB)
else if (!tokenA.glyph.isLarge && tokenB.glyph.isLarge)
tokensToRemove.add(tokenA)
else if (tokenA.pattern.numSetPixels > tokenB.pattern.numSetPixels)
tokensToRemove.add(tokenB)
else if (tokenA.pattern.numSetPixels < tokenB.pattern.numSetPixels)
tokensToRemove.add(tokenA)
}
}
}
// The actual token removal.
if (tokensToRemove.isNotEmpty())
tokens.removeAll(tokensToRemove)
return tokens
}

View file

@ -0,0 +1,16 @@
package info.nightscout.comboctl.base
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.text.Charsets
class CRCTest {
@Test
fun verifyChecksum() {
val inputData = "0123456789abcdef".toByteArray(Charsets.UTF_8).toList()
val expectedChecksum = 0x02A2
val actualChecksum = calculateCRC16MCRF4XX(inputData)
assertEquals(expectedChecksum, actualChecksum)
}
}

View file

@ -0,0 +1,57 @@
package info.nightscout.comboctl.base
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.text.Charsets
class CipherTest {
@Test
fun checkWeakKeyGeneration() {
// Generate a weak key out of the PIN 012-345-6789.
val PIN = PairingPIN(intArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9))
try {
val expectedWeakKey = byteArrayListOfInts(
0x30, 0x31, 0x32, 0x33,
0x34, 0x35, 0x36, 0x37,
0x38, 0x39, 0xcf, 0xce,
0xcd, 0xcc, 0xcb, 0xca
)
val actualWeakKey = generateWeakKeyFromPIN(PIN)
assertEquals(expectedWeakKey, actualWeakKey.toList())
} catch (ex: Exception) {
ex.printStackTrace()
throw Error("Unexpected exception: $ex")
}
}
@Test
fun checkEncryptDecrypt() {
// Encrypt and decrypt the text "0123456789abcdef".
// Verify that the encrypted version is what we expect,
// and that decrypting that version yields the original text.
// For this test, we use a key that is simply the value 48
// (= ASCII index of the character '0'), repeated 16 times.
// (16 is the number of bytes in a 128-bit key.)
val inputData = "0123456789abcdef".toByteArray(Charsets.UTF_8)
val key = ByteArray(CIPHER_KEY_SIZE)
key.fill('0'.code.toByte())
val cipher = Cipher(key)
val expectedEncryptedData = byteArrayListOfInts(
0xb3, 0x58, 0x09, 0xd0,
0xe3, 0xb4, 0xa0, 0x2e,
0x1a, 0xbb, 0x6b, 0x1a,
0xfa, 0xeb, 0x31, 0xc8
)
val actualEncryptedData = cipher.encrypt(inputData)
assertEquals(expectedEncryptedData, actualEncryptedData.toList())
val decryptedData = cipher.decrypt(actualEncryptedData)
assertEquals(inputData.toList(), decryptedData.toList())
}
}

View file

@ -0,0 +1,207 @@
package info.nightscout.comboctl.base
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
// Payload which contains some bytes that equal "special" or "reserved" bytes.
// These bytes are 0xCC and 0x77.
val payloadDataWithSpecialBytes = byteArrayListOfInts(
0x11, 0x22, 0x11,
0xCC,
0x11,
0x77,
0x44,
0x77, 0xCC,
0x00,
0xCC, 0x77,
0x55
)
// The frame version of the payload above, with the frame delimiter 0xCC at
// the beginning and end, plus the colliding payload bytes in escaped form.
val frameDataWithEscapedSpecialBytes = byteArrayListOfInts(
0xCC,
0x11, 0x22, 0x11,
0x77, 0xDD, // 0x77 0xDD is the escaped form of 0xCC
0x11,
0x77, 0xEE, // 0xEE 0xDD is the escaped form of 0x77
0x44,
0x77, 0xEE, 0x77, 0xDD,
0x00,
0x77, 0xDD, 0x77, 0xEE,
0x55,
0xCC
)
class ComboFrameTest {
@Test
fun produceEscapedFrameData() {
// Frame the payload and check that the framing is done correctly.
val producedEscapedFrameData = payloadDataWithSpecialBytes.toComboFrame()
assertEquals(frameDataWithEscapedSpecialBytes, producedEscapedFrameData)
}
@Test
fun parseEscapedFrameData() {
// Parse escaped frame data and check that the original payload is recovered.
val parser = ComboFrameParser()
parser.pushData(frameDataWithEscapedSpecialBytes)
val parsedPayloadData = parser.parseFrame()
assertTrue(parsedPayloadData != null)
assertEquals(payloadDataWithSpecialBytes, parsedPayloadData)
}
@Test
fun parsePartialFrameData() {
// Frame data can come in partial chunks. The parser has to accumulate the
// chunks and try to find a complete frame within the accumulated data.
// If none can be found, parseFrame() returns null. Otherwise, it extracts
// the data within the two delimiters of the frame, un-escapes any escaped
// bytes, and returns the recovered payload from that frame.
val parser = ComboFrameParser()
// Three chunks of partial frame data. Only after all three have been pushed
// into the parser can it find a complete frame. In fact, it then has
// accumulated data containing even _two_ complete frames.
// The first chunk, which starts the first frame. No complete frame yet.
val partialFrameData1 = byteArrayListOfInts(
0xCC,
0x11, 0x22, 0x33
)
// Next chunk, contains more bytes of the first frame, but still no end
// of that frame.
val partialFrameData2 = byteArrayListOfInts(
0x44, 0x55
)
// Last chunk. It not only contains the second delimiter of the first frame,
// but also a complete second frame.
val partialFrameData3 = byteArrayListOfInts(
0xCC,
0xCC,
0x66, 0x88, 0x99,
0xCC
)
// The two frames contained in the three chunks above have these two payloads.
val payloadFromPartialData1 = byteArrayListOfInts(0x11, 0x22, 0x33, 0x44, 0x55)
val payloadFromPartialData2 = byteArrayListOfInts(0x66, 0x88, 0x99)
// Push the first chunk into the parsed frame. We don't expect
// it to actually parse something yet.
parser.pushData(partialFrameData1)
var parsedPayloadData = parser.parseFrame()
assertEquals(null, parsedPayloadData)
// Push the second chunk. We still don't expect a parsed frame,
// since the second chunk does not complete the first frame yet.
parser.pushData(partialFrameData2)
parsedPayloadData = parser.parseFrame()
assertEquals(null, parsedPayloadData)
// Push the last chunk. With that chunk, the parser accumulated
// enough data to parse the first frame and an additional frame.
// Therefore, we expect the next two parseFrame() calls to
// return a non-null value - the expected payloads.
parser.pushData(partialFrameData3)
parsedPayloadData = parser.parseFrame()
assertEquals(payloadFromPartialData1, parsedPayloadData!!)
parsedPayloadData = parser.parseFrame()
assertEquals(payloadFromPartialData2, parsedPayloadData!!)
// There is no accumulated data left for parsing, so we
// expect the parseFrame() call to return null.
parsedPayloadData = parser.parseFrame()
assertEquals(null, parsedPayloadData)
}
@Test
fun parsePartialFrameDataWithSpecialBytes() {
// Test the parser with partial chunks again. But this time,
// the payload within the frames contain special bytes, so
// the chunks contain escaped bytes.
val parser = ComboFrameParser()
// First partial chunk. It ends with an escape byte (0x77). The
// parser cannot do anything with that escape byte alone, since
// only with a followup byte can it be determined what byte was
// escaped.
val partialFrameDataWithSpecialBytes1 = byteArrayListOfInts(
0xCC,
0x11, 0x22, 0x77
)
// Second partial chunk. Completes the frame, and provides the
// missing byte that, together with the escape byte from the
// previous chunk, combines to 0x77 0xEE, which is the escaped
// form of the payload byte 0x77.
val partialFrameDataWithSpecialBytes2 = byteArrayListOfInts(
0xEE, 0x33, 0xCC
)
// The payload in the frame that is transported over the chunks.
val payloadFromPartialDataWithSpecialBytes = byteArrayListOfInts(0x11, 0x22, 0x77, 0x33)
// Push the first chunk. We don't expect the parser to return
// anything yet.
parser.pushData(partialFrameDataWithSpecialBytes1)
var parsedPayloadData = parser.parseFrame()
assertEquals(null, parsedPayloadData)
// Push the second chunk. The frame is now complete. The parser
// should now find the frame and extract the payload, in correct
// un-escaped form.
parser.pushData(partialFrameDataWithSpecialBytes2)
parsedPayloadData = parser.parseFrame()
assertEquals(payloadFromPartialDataWithSpecialBytes, parsedPayloadData!!)
// There is no accumulated data left for parsing, so we
// expect the parseFrame() call to return null.
parsedPayloadData = parser.parseFrame()
assertEquals(null, parsedPayloadData)
}
@Test
fun parseNonDelimiterOutsideOfFrame() {
// Outside of frames, only the frame delimiter byte 0x77 is expected.
// That's because the Combo frames are tightly packed, like this:
//
// 0xCC <frame 1 bytes> 0xCC 0xCC <frame 2 bytes> 0xCC ...
val parser = ComboFrameParser()
// This is invalid data, since 0x11 lies before the 0xCC frame delimiter,
// meaning that the 0x11 byte is "outside of a frame".
val frameDataWithNonDelimiterOutsideOfFrame = byteArrayListOfInts(
0x11, 0xCC
)
parser.pushData(frameDataWithNonDelimiterOutsideOfFrame)
assertFailsWith<FrameParseException> { parser.parseFrame() }
}
@Test
fun parseInvalidEscapeByteCombination() {
// In frame data, the escape byte 0x77 is followed by the byte 0xDD
// or 0xEE (the escaped form of bytes 0xCC and 0x77, respectively).
// Any other byte immediately following 0x77 is invalid, since no
// escaping is defined then. For example, 0x77 0x88 does not describe
// anything.
val parser = ComboFrameParser()
// 0x77 0xAA is an invalid sequence.
val frameDataWithInvalidEscapeByteCombination = byteArrayListOfInts(
0xCC, 0x77, 0xAA, 0xCC
)
parser.pushData(frameDataWithInvalidEscapeByteCombination)
assertFailsWith<FrameParseException> { parser.parseFrame() }
}
}

View file

@ -0,0 +1,236 @@
package info.nightscout.comboctl.base
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
// The frame contents contained in the original frame rows from the Combo below.
// This is used as a reference to see if frame conversion and assembly is correct.
val referenceDisplayFramePixels = listOf(
" ███ ███ ███ █████ ███ ",
" █ █ █ █ █ █ █ █ █ █ ",
"█ █ █ █ █ ████ █ █ ",
"█ ██ █ █ █ █ ████ ",
"█ █ █ █ █ █ ",
" █ █ █ █ █ █ █ ",
" ███ █████ █████ ███ ██ ",
" ",
" ████ ████ ████ ",
" ███████ ██ ██ ██ ██ ██ ██ ",
" ███████ ██ ██ ██ ██ ██ ██ ",
" ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ",
" ██ ███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ",
" ██ ███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ",
"███████ ██ ██ ██ ██ ████ ████ ██ ██ ██ ██ ",
"███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ █████ ",
"██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ███ ██ ",
"██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ",
"██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ",
"██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ",
"██ ██ ██ ██ █ ██ ██ ███ ██ ██ ██ ██ ██ ██ ██ ██ ██ ",
"██ ██ ██ ██ █ ██ ██ ███ ██ ██ ██ ██ ██ ██ ██ ██ ██ ",
"██ ██ ██ ██ ███ ████ ███ ████ ████ ████ ██ ██ ██ ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" "
)
val originalRtDisplayFrameRows = listOf(
byteArrayListOfInts(
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1E, 0x29,
0x49, 0x49, 0x06, 0x00, 0x39, 0x45, 0x45, 0x45, 0x27, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x46, 0x49, 0x51, 0x61, 0x42, 0x00, 0x46, 0x49,
0x51, 0x61, 0x42, 0x00, 0x00, 0x1C, 0x22, 0x49, 0x4F, 0x41, 0x22, 0x1C
),
byteArrayListOfInts(
0x00, 0x00, 0x00, 0x80, 0x80, 0x80, 0xF8, 0xF8, 0x00, 0x38, 0xF8, 0xC0,
0x00, 0x00, 0x00, 0xF8, 0xF8, 0x00, 0x00, 0xF8, 0xF8, 0x00, 0x00, 0x00,
0x00, 0x00, 0x1C, 0xBE, 0xE3, 0x41, 0x41, 0xE3, 0xBE, 0x1C, 0x00, 0x00,
0x00, 0x00, 0x1C, 0xBE, 0xE3, 0x41, 0x41, 0xE3, 0xBE, 0x1C, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFC, 0xFE, 0x03, 0x01,
0x01, 0x03, 0xFE, 0xFC, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0xF0, 0x30, 0x30, 0x30,
0xFE, 0xFE, 0x06, 0x06, 0x06, 0xFE, 0xFE, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0
),
byteArrayListOfInts(
0x00, 0x00, 0x7F, 0x7F, 0x00, 0x01, 0x7F, 0x7F, 0x00, 0x00, 0x01, 0x0F,
0x7E, 0x70, 0x00, 0x3F, 0x7F, 0x40, 0x40, 0x7F, 0x3F, 0x00, 0x00, 0x00,
0x00, 0x00, 0x1F, 0x3F, 0x60, 0x40, 0x40, 0x60, 0x3F, 0x1F, 0x00, 0x00,
0x00, 0x00, 0x1F, 0x3F, 0x60, 0x40, 0x40, 0x60, 0x3F, 0x1F, 0x00, 0x00,
0x00, 0x00, 0x70, 0x70, 0x70, 0x00, 0x00, 0x00, 0x1F, 0x3F, 0x60, 0x40,
0x40, 0x60, 0x3F, 0x1F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x40, 0x7F, 0x42, 0x00, 0x00, 0x7F, 0x7F, 0x00, 0x00, 0x00,
0x7F, 0x7F, 0x00, 0x00, 0x00, 0x7F, 0x7F, 0x00, 0x00, 0x00, 0x7F, 0x7F
),
byteArrayListOfInts(
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
)
)
class DisplayFrameTest {
@Test
fun checkPixelAddressing() {
// Construct a simple display frame with 2 pixels set and the rest
// being empty. Pixel 1 is at coordinates (x 1 y 0), pixel 2 is at
// coordinates (x 0 y 1).
val framePixels = BooleanArray(NUM_DISPLAY_FRAME_PIXELS) { false }
framePixels[1 + 0 * DISPLAY_FRAME_WIDTH] = true
framePixels[0 + 1 * DISPLAY_FRAME_WIDTH] = true
val displayFrame = DisplayFrame(framePixels)
// Verify that all pixels except the two specific ones are empty.
for (y in 0 until DISPLAY_FRAME_HEIGHT) {
for (x in 0 until DISPLAY_FRAME_WIDTH) {
when (Pair(x, y)) {
Pair(1, 0) -> assertEquals(true, displayFrame.getPixelAt(x, y))
Pair(0, 1) -> assertEquals(true, displayFrame.getPixelAt(x, y))
else -> assertEquals(false, displayFrame.getPixelAt(x, y))
}
}
}
}
@Test
fun checkDisplayFrameAssembly() {
val assembler = DisplayFrameAssembler()
var displayFrame: DisplayFrame?
// The Combo splits a frame into 4 rows and transmits in one application layer
// RT_DISPLAY packet each. We simulate this by keeping the actual pixel data
// of these packets in the originalRtDisplayFrameRows array. If the assembler
// works correctly, then feeding these four byte lists into it will produce
// a complete display frame.
//
// The index here is 0x17. The index is how we can see if a row belongs to
// the same frame, or if a new frame started. If we get a row with an index
// that is different than the previous one, then we have to discard any
// previously collected rows and start anew. Here, we supply 4 rows of the
// same index, so we expect a complete frame.
displayFrame = assembler.processRTDisplayPayload(0x17, 0, originalRtDisplayFrameRows[0])
assertTrue(displayFrame == null)
displayFrame = assembler.processRTDisplayPayload(0x17, 1, originalRtDisplayFrameRows[1])
assertTrue(displayFrame == null)
displayFrame = assembler.processRTDisplayPayload(0x17, 2, originalRtDisplayFrameRows[2])
assertTrue(displayFrame == null)
displayFrame = assembler.processRTDisplayPayload(0x17, 3, originalRtDisplayFrameRows[3])
assertFalse(displayFrame == null)
// Check that the assembled frame is correct.
compareWithReference(displayFrame)
}
@Test
fun checkChangingRTDisplayPayloadIndex() {
val assembler = DisplayFrameAssembler()
var displayFrame: DisplayFrame?
// This is similar to the test above, except that we only provide 3 rows
// of the frame with index 0x17, and then suddenly deliver a row with index
// 0x18. We expect the assembler to discard any previously collected rows
// and restart from scratch. We do provide four rows with index 0x18, so
// the assembler is supposed to deliver a completed frame then.
// The first rows with index 0x17.
displayFrame = assembler.processRTDisplayPayload(0x17, 0, originalRtDisplayFrameRows[0])
assertTrue(displayFrame == null)
displayFrame = assembler.processRTDisplayPayload(0x17, 1, originalRtDisplayFrameRows[1])
assertTrue(displayFrame == null)
displayFrame = assembler.processRTDisplayPayload(0x17, 2, originalRtDisplayFrameRows[2])
assertTrue(displayFrame == null)
// First row with index 0x18. This should reset the assembler's contents,
// restarting its assembly from scratch.
displayFrame = assembler.processRTDisplayPayload(0x18, 0, originalRtDisplayFrameRows[0])
assertTrue(displayFrame == null)
displayFrame = assembler.processRTDisplayPayload(0x18, 1, originalRtDisplayFrameRows[1])
assertTrue(displayFrame == null)
displayFrame = assembler.processRTDisplayPayload(0x18, 2, originalRtDisplayFrameRows[2])
assertTrue(displayFrame == null)
displayFrame = assembler.processRTDisplayPayload(0x18, 3, originalRtDisplayFrameRows[3])
assertFalse(displayFrame == null)
// Check that the completed frame is OK.
compareWithReference(displayFrame)
}
@Test
fun checkDisplayFrameOutOfOrderAssembly() {
val assembler = DisplayFrameAssembler()
var displayFrame: DisplayFrame?
// Similar to the checkDisplayFrameAssembly, except that rows
// are supplied to the assembler out-of-order.
displayFrame = assembler.processRTDisplayPayload(0x17, 2, originalRtDisplayFrameRows[2])
assertTrue(displayFrame == null)
displayFrame = assembler.processRTDisplayPayload(0x17, 1, originalRtDisplayFrameRows[1])
assertTrue(displayFrame == null)
displayFrame = assembler.processRTDisplayPayload(0x17, 3, originalRtDisplayFrameRows[3])
assertTrue(displayFrame == null)
displayFrame = assembler.processRTDisplayPayload(0x17, 0, originalRtDisplayFrameRows[0])
assertFalse(displayFrame == null)
// Check that the assembled frame is correct.
compareWithReference(displayFrame)
}
private fun dumpDisplayFrameContents(displayFrame: DisplayFrame) {
for (y in 0 until DISPLAY_FRAME_HEIGHT) {
for (x in 0 until DISPLAY_FRAME_WIDTH) {
val displayFramePixel = displayFrame.getPixelAt(x, y)
print(if (displayFramePixel) '█' else ' ')
}
println("")
}
}
private fun compareWithReference(displayFrame: DisplayFrame) {
for (y in 0 until DISPLAY_FRAME_HEIGHT) {
for (x in 0 until DISPLAY_FRAME_WIDTH) {
val referencePixel = (referenceDisplayFramePixels[y][x] != ' ')
val displayFramePixel = displayFrame.getPixelAt(x, y)
val equal = (referencePixel == displayFramePixel)
if (!equal) {
println("Mismatch at x $x y $y")
dumpDisplayFrameContents(displayFrame)
}
assertTrue(equal)
}
}
}
}

View file

@ -0,0 +1,153 @@
package info.nightscout.comboctl.base
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertSame
class GraphTest {
@Test
fun checkGraphConstruction() {
// Check basic graph construction. Create 4 nodes, with values 1 through 4.
// Connect:
// - node 1 to node 2
// - node 2 to nodes 1 and 3
// - node 3 to node 4
// - node 4 to node 2
//
// Then check (a) the number of nodes in the graph,
// (b) how many edges each node has, and (c) what
// nodes the edges lead to.
Graph<Int, String>().apply {
val n1 = node(1)
val n2 = node(2)
val n3 = node(3)
val n4 = node(4)
n1.connectTo(n2, "e12")
n2.connectTo(n1, "e21")
n2.connectTo(n3, "e23")
n3.connectTo(n4, "e34")
n4.connectTo(n2, "e42")
// Check number of nodes.
assertEquals(4, nodes.size)
// Check number of edges per node.
assertEquals(1, n1.edges.size)
assertEquals(2, n2.edges.size)
assertEquals(1, n3.edges.size)
assertEquals(1, n4.edges.size)
// Check the nodes the edges lead to.
assertSame(n2, n1.edges[0].targetNode)
assertSame(n1, n2.edges[0].targetNode)
assertSame(n3, n2.edges[1].targetNode)
assertSame(n4, n3.edges[0].targetNode)
assertSame(n2, n4.edges[0].targetNode)
}
}
@Test
fun checkShortestPath() {
// Check the result of findShortestPath(). For this,
// construct a graph with cycles and multiple ways
// to get from one node to another. This graph has
// 4 nodes, and one ways to get from node 1 to node 4
// & _two_ ways from node 4 to 1.
//
// Path from node 1 to 4: n1 -> n2 -> n3 -> n4
// First path from node 4 to 1: n4 -> n3 -> n2 -> n1
// Second path from node 4 to 1: n4 -> n2 -> n1
//
// findShortestPath() should find the second path,
// since it is the shortest one.
Graph<Int, String>().apply {
val n1 = node(1)
val n2 = node(2)
val n3 = node(3)
val n4 = node(4)
n1.connectTo(n2, "e12")
n2.connectTo(n1, "e21")
n2.connectTo(n3, "e23")
n3.connectTo(n4, "e34")
n4.connectTo(n2, "e42")
n4.connectTo(n3, "e43")
n3.connectTo(n2, "e32")
val pathFromN1ToN4 = findShortestPath(1, 4)!!
assertEquals(3, pathFromN1ToN4.size)
assertEquals("e12", pathFromN1ToN4[0].edgeValue)
assertEquals(2, pathFromN1ToN4[0].targetNodeValue)
assertEquals("e23", pathFromN1ToN4[1].edgeValue)
assertEquals(3, pathFromN1ToN4[1].targetNodeValue)
assertEquals("e34", pathFromN1ToN4[2].edgeValue)
assertEquals(4, pathFromN1ToN4[2].targetNodeValue)
val pathFromN4ToN1 = findShortestPath(4, 1)!!
assertEquals(2, pathFromN4ToN1.size)
assertEquals("e42", pathFromN4ToN1[0].edgeValue)
assertEquals(2, pathFromN4ToN1[0].targetNodeValue)
assertEquals("e21", pathFromN4ToN1[1].edgeValue)
assertEquals(1, pathFromN4ToN1[1].targetNodeValue)
}
}
@Test
fun checkNonExistentShortestPath() {
// Check what happens when trying to find a shortest path
// between two nodes that have no path that connects the two.
// The test graph connects node 1 to nodes 2 and 3, but since
// the edges are directional, getting from nodes 2 and 3 to
// node 1 is not possible. Consequently, a path from node 2
// to node 3 cannot be found. findShortestPath() should
// detect this and return null.
Graph<Int, String>().apply {
val n1 = node(1)
val n2 = node(2)
val n3 = node(3)
n1.connectTo(n2, "e12")
n1.connectTo(n3, "e13")
val path = findShortestPath(2, 3)
assertNull(path)
}
}
@Test
fun checkShortestPathSearchEdgePredicate() {
// Check the effect of an edge predicate. Establisch a small
// 3-node graph with nodes 1,2,3 and add a shortcut from
// node 1 to node 3. Try to find the shortest path from
// 1 to 3, without and with a predicate. We expect the
// predicate to skip the edge that goes from node 1 to 3.
Graph<Int, String>().apply {
val n1 = node(1)
val n2 = node(2)
val n3 = node(3)
n1.connectTo(n2, "e12")
n2.connectTo(n3, "e23")
n1.connectTo(n3, "e13")
val pathWithoutPredicate = findShortestPath(1, 3)
assertNotNull(pathWithoutPredicate)
assertEquals(1, pathWithoutPredicate.size)
assertEquals("e13", pathWithoutPredicate[0].edgeValue)
assertEquals(3, pathWithoutPredicate[0].targetNodeValue)
val pathWithPredicate = findShortestPath(1, 3) { it != "e13" }
assertNotNull(pathWithPredicate)
assertEquals(2, pathWithPredicate.size)
assertEquals("e12", pathWithPredicate[0].edgeValue)
assertEquals(2, pathWithPredicate[0].targetNodeValue)
assertEquals("e23", pathWithPredicate[1].edgeValue)
assertEquals(3, pathWithPredicate[1].targetNodeValue)
}
}
}

View file

@ -0,0 +1,43 @@
package info.nightscout.comboctl.base
import kotlin.test.Test
import kotlin.test.assertEquals
class NonceTest {
@Test
fun checkDefaultNonceIncrement() {
// Increment the nonce by the default amount of 1.
val firstNonce = Nonce(listOf(0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
val secondNonce = firstNonce.getIncrementedNonce()
val expectedSecondNonce = Nonce(listOf(0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
assertEquals(expectedSecondNonce, secondNonce)
}
@Test
fun checkExplicitNonceIncrement() {
// Increment the nonce by the explicit amount of 76000.
val firstNonce = Nonce(listOf(0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
val secondNonce = firstNonce.getIncrementedNonce(incrementAmount = 76000)
val expectedSecondNonce = Nonce(listOf(0xF0.toByte(), 0x29, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
assertEquals(expectedSecondNonce, secondNonce)
}
@Test
fun checkNonceWraparound() {
// Increment a nonce that is high enough to cause a wrap-around.
val firstNonce = Nonce(listOf(
0xFA.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(),
0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(),
0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte()
))
val secondNonce = firstNonce.getIncrementedNonce(incrementAmount = 10)
val expectedSecondNonce = Nonce(listOf(0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
assertEquals(expectedSecondNonce, secondNonce)
}
}

View file

@ -0,0 +1,461 @@
package info.nightscout.comboctl.base
import info.nightscout.comboctl.base.testUtils.TestBluetoothDevice
import info.nightscout.comboctl.base.testUtils.TestPumpStateStore
import info.nightscout.comboctl.base.testUtils.runBlockingWithWatchdog
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.fail
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.Channel
class PairingSessionTest {
enum class PacketDirection {
SEND,
RECEIVE
}
data class PairingTestSequenceEntry(val direction: PacketDirection, val packet: TransportLayer.Packet) {
override fun toString(): String {
return if (packet.command == TransportLayer.Command.DATA) {
try {
// Use the ApplicationLayer.Packet constructor instead
// of the toAppLayerPacket() function, since the latter
// performs additional sanity checks. These checks are
// unnecessary here - we just want to dump the packet
// contents to a string.
val appLayerPacket = ApplicationLayer.Packet(packet)
"direction: $direction app layer packet: $appLayerPacket"
} catch (ignored: Throwable) {
"direction: $direction tp layer packet: $packet"
}
} else
"direction: $direction tp layer packet: $packet"
}
}
private class PairingTestComboIO(val pairingTestSequence: List<PairingTestSequenceEntry>) : ComboIO {
private var curSequenceIndex = 0
private val barrier = Channel<Unit>(capacity = Channel.CONFLATED)
var expectedEndOfSequenceReached: Boolean = false
private set
var testErrorOccurred: Boolean = false
private set
// The pairingTestSequence contains entries for when a packet
// is expected to be sent and to be received in this simulated
// Combo<->Client communication. When the "sender" transmits
// packets, the "receiver" is supposed to wait. This is accomplished
// by letting getNextSequenceEntry() suspend its coroutine until
// _another_ getNextSequenceEntry() call advances the sequence
// so that the first call's expected packet direction matches.
// For example: coroutine A simulates the receiver, B the sender.
// A calls getNextSequenceEntry(). The next sequence entry has
// "SEND" as its packet direction, meaning that at this point, the
// sender is supposed to be active. Consequently, A is suspended
// by getNextSequenceEntry(). B calls getNextSequenceEntry() and
// advances the sequence until an entry is reached with packet
// direction "RECEIVE". This now suspends B. A is woken up by
// the barrier and resumes its work etc.
// The "barrier" is actually a Channel which "transmits" Unit
// values. We aren't actually interested in these "values", just
// in the ability of Channel to suspend coroutines.
private suspend fun getNextSequenceEntry(expectedPacketDirection: PacketDirection): PairingTestSequenceEntry {
while (true) {
// Suspend indefinitely if we reached the expected
// end of sequence. See send() below for details.
if (expectedEndOfSequenceReached)
barrier.receive()
if (curSequenceIndex >= pairingTestSequence.size) {
testErrorOccurred = true
throw ComboException("End of test sequence unexpectedly reached")
}
val sequenceEntry = pairingTestSequence[curSequenceIndex]
if (sequenceEntry.direction != expectedPacketDirection) {
// Wait until we get the signal from a send() or receive()
// call that we can resume here.
barrier.receive()
continue
}
curSequenceIndex++
return sequenceEntry
}
}
override suspend fun send(dataToSend: List<Byte>) {
try {
val sequenceEntry = getNextSequenceEntry(PacketDirection.SEND)
System.err.println("Next sequence entry: $sequenceEntry")
val expectedPacketData = sequenceEntry.packet.toByteList()
assertEquals(expectedPacketData, dataToSend)
// Check if this is the last packet in the sequence.
// That's CTRL_DISCONNECT. If it is, switch to a
// special mode that suspends receive() calls indefinitely.
// This is necessary because the packet receiver inside
// the transport layer IO class will keep trying to receive
// packets from the Combo even though our sequence here
// ended and thus has no more data that can be "received".
if (sequenceEntry.packet.command == TransportLayer.Command.DATA) {
try {
// Don't use toAppLayerPacket() here. Instead, use
// the ApplicationLayer.Packet constructor directly.
// This way we circumvent error code checks, which
// are undesirable in this very case.
val appLayerPacket = ApplicationLayer.Packet(sequenceEntry.packet)
if (appLayerPacket.command == ApplicationLayer.Command.CTRL_DISCONNECT)
expectedEndOfSequenceReached = true
} catch (ignored: Throwable) {
}
}
// Signal to the other, suspended coroutine that it can resume now.
barrier.trySend(Unit)
} catch (e: CancellationException) {
throw e
} catch (t: Throwable) {
testErrorOccurred = true
throw t
}
}
override suspend fun receive(): List<Byte> {
try {
val sequenceEntry = getNextSequenceEntry(PacketDirection.RECEIVE)
System.err.println("Next sequence entry: $sequenceEntry")
// Signal to the other, suspended coroutine that it can resume now.
barrier.trySend(Unit)
return sequenceEntry.packet.toByteList()
} catch (e: CancellationException) {
throw e
} catch (t: Throwable) {
testErrorOccurred = true
throw t
}
}
}
@Test
fun verifyPairingProcess() {
// Test the pairing coroutine by feeding in data that was recorded from
// pairing an actual Combo with Ruffy (using an nVidia SHIELD Tablet as
// client). Check that the outgoing packets match those that Ruffy sent
// to the Combo.
val testBtFriendlyName = "SHIELD Tablet"
val testPIN = PairingPIN(intArrayOf(2, 6, 0, 6, 8, 1, 9, 2, 7, 3))
val expectedTestSequence = listOf(
PairingTestSequenceEntry(
PacketDirection.SEND,
TransportLayer.Packet(
command = TransportLayer.Command.REQUEST_PAIRING_CONNECTION,
version = 0x10.toByte(),
sequenceBit = false,
reliabilityBit = false,
address = 0xf0.toByte(),
nonce = Nonce(byteArrayListOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(0xB2, 0x11),
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
)
),
PairingTestSequenceEntry(
PacketDirection.RECEIVE,
TransportLayer.Packet(
command = TransportLayer.Command.PAIRING_CONNECTION_REQUEST_ACCEPTED,
version = 0x10.toByte(),
sequenceBit = false,
reliabilityBit = false,
address = 0x0f.toByte(),
nonce = Nonce(byteArrayListOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(0x00, 0xF0, 0x6D),
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
)
),
PairingTestSequenceEntry(
PacketDirection.SEND,
TransportLayer.Packet(
command = TransportLayer.Command.REQUEST_KEYS,
version = 0x10.toByte(),
sequenceBit = false,
reliabilityBit = false,
address = 0xf0.toByte(),
nonce = Nonce(byteArrayListOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(0x81, 0x41),
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
)
),
PairingTestSequenceEntry(
PacketDirection.SEND,
TransportLayer.Packet(
command = TransportLayer.Command.GET_AVAILABLE_KEYS,
version = 0x10.toByte(),
sequenceBit = false,
reliabilityBit = false,
address = 0xf0.toByte(),
nonce = Nonce(byteArrayListOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(0x90, 0x71),
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
)
),
PairingTestSequenceEntry(
PacketDirection.RECEIVE,
TransportLayer.Packet(
command = TransportLayer.Command.KEY_RESPONSE,
version = 0x10.toByte(),
sequenceBit = false,
reliabilityBit = false,
address = 0x01.toByte(),
nonce = Nonce(byteArrayListOfInts(0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(
0x54, 0x9E, 0xF7, 0x7D, 0x8D, 0x27, 0x48, 0x0C, 0x1D, 0x11, 0x43, 0xB8, 0xF7, 0x08, 0x92, 0x7B,
0xF0, 0xA3, 0x75, 0xF3, 0xB4, 0x5F, 0xE2, 0xF3, 0x46, 0x63, 0xCD, 0xDD, 0xC4, 0x96, 0x37, 0xAC),
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x25, 0xA0, 0x26, 0x47, 0x29, 0x37, 0xFF, 0x66))
)
),
PairingTestSequenceEntry(
PacketDirection.SEND,
TransportLayer.Packet(
command = TransportLayer.Command.REQUEST_ID,
version = 0x10.toByte(),
sequenceBit = false,
reliabilityBit = false,
address = 0x10.toByte(),
nonce = Nonce(byteArrayListOfInts(0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(
0x08, 0x29, 0x00, 0x00, 0x53, 0x48, 0x49, 0x45, 0x4C, 0x44, 0x20, 0x54, 0x61, 0x62, 0x6C, 0x65, 0x74),
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x99, 0xED, 0x58, 0x29, 0x54, 0x6A, 0xBB, 0x35))
)
),
PairingTestSequenceEntry(
PacketDirection.RECEIVE,
TransportLayer.Packet(
command = TransportLayer.Command.ID_RESPONSE,
version = 0x10.toByte(),
sequenceBit = false,
reliabilityBit = false,
address = 0x01.toByte(),
nonce = Nonce(byteArrayListOfInts(0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(
0x59, 0x99, 0xD4, 0x01, 0x50, 0x55, 0x4D, 0x50, 0x5F, 0x31, 0x30, 0x32, 0x33, 0x30, 0x39, 0x34, 0x37),
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x6E, 0xF4, 0x4D, 0xFE, 0x35, 0x6E, 0xFE, 0xB4))
)
),
PairingTestSequenceEntry(
PacketDirection.SEND,
TransportLayer.Packet(
command = TransportLayer.Command.REQUEST_REGULAR_CONNECTION,
version = 0x10.toByte(),
sequenceBit = false,
reliabilityBit = false,
address = 0x10.toByte(),
nonce = Nonce(byteArrayListOfInts(0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(),
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0xCF, 0xEE, 0x61, 0xF2, 0x83, 0xD3, 0xDC, 0x39))
)
),
PairingTestSequenceEntry(
PacketDirection.RECEIVE,
TransportLayer.Packet(
command = TransportLayer.Command.REGULAR_CONNECTION_REQUEST_ACCEPTED,
version = 0x10.toByte(),
sequenceBit = false,
reliabilityBit = false,
address = 0x01.toByte(),
nonce = Nonce(byteArrayListOfInts(0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(),
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x40, 0x00, 0xB3, 0x41, 0x84, 0x55, 0x5F, 0x12))
)
),
// Application layer CTRL_CONNECT
PairingTestSequenceEntry(
PacketDirection.SEND,
TransportLayer.Packet(
command = TransportLayer.Command.DATA,
version = 0x10.toByte(),
sequenceBit = false,
reliabilityBit = true,
address = 0x10.toByte(),
nonce = Nonce(byteArrayListOfInts(0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(0x10, 0x00, 0x55, 0x90, 0x39, 0x30, 0x00, 0x00),
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0xEF, 0xB9, 0x9E, 0xB6, 0x7B, 0x30, 0x7A, 0xCB))
)
),
// Application layer CTRL_CONNECT
PairingTestSequenceEntry(
PacketDirection.RECEIVE,
TransportLayer.Packet(
command = TransportLayer.Command.DATA,
version = 0x10.toByte(),
sequenceBit = false,
reliabilityBit = true,
address = 0x01.toByte(),
nonce = Nonce(byteArrayListOfInts(0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(0x10, 0x00, 0x55, 0xA0, 0x00, 0x00),
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0xF4, 0x4D, 0xB8, 0xB3, 0xC1, 0x2E, 0xDE, 0x97))
)
),
// Response due to the last packet's reliability bit set to true
PairingTestSequenceEntry(
PacketDirection.SEND,
TransportLayer.Packet(
command = TransportLayer.Command.ACK_RESPONSE,
version = 0x10.toByte(),
sequenceBit = false,
reliabilityBit = false,
address = 0x10.toByte(),
nonce = Nonce(byteArrayListOfInts(0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(),
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x76, 0x01, 0xB6, 0xAB, 0x48, 0xDB, 0x4E, 0x87))
)
),
// Application layer CTRL_CONNECT
PairingTestSequenceEntry(
PacketDirection.SEND,
TransportLayer.Packet(
command = TransportLayer.Command.DATA,
version = 0x10.toByte(),
sequenceBit = true,
reliabilityBit = true,
address = 0x10.toByte(),
nonce = Nonce(byteArrayListOfInts(0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(0x10, 0x00, 0x65, 0x90, 0xB7),
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0xEC, 0xA6, 0x4D, 0x59, 0x1F, 0xD3, 0xF4, 0xCD))
)
),
// Application layer CTRL_CONNECT_RESPONSE
PairingTestSequenceEntry(
PacketDirection.RECEIVE,
TransportLayer.Packet(
command = TransportLayer.Command.DATA,
version = 0x10.toByte(),
sequenceBit = true,
reliabilityBit = true,
address = 0x01.toByte(),
nonce = Nonce(byteArrayListOfInts(0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(0x10, 0x00, 0x65, 0xA0, 0x00, 0x00, 0x01, 0x00),
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x9D, 0xB3, 0x3F, 0x84, 0x87, 0x49, 0xE3, 0xAC))
)
),
// Response due to the last packet's reliability bit set to true
PairingTestSequenceEntry(
PacketDirection.SEND,
TransportLayer.Packet(
command = TransportLayer.Command.ACK_RESPONSE,
version = 0x10.toByte(),
sequenceBit = true,
reliabilityBit = false,
address = 0x10.toByte(),
nonce = Nonce(byteArrayListOfInts(0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(),
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x15, 0xA9, 0x9A, 0x64, 0x9C, 0x57, 0xD2, 0x72))
)
),
// Application layer CTRL_BIND
PairingTestSequenceEntry(
PacketDirection.SEND,
TransportLayer.Packet(
command = TransportLayer.Command.DATA,
version = 0x10.toByte(),
sequenceBit = false,
reliabilityBit = true,
address = 0x10.toByte(),
nonce = Nonce(byteArrayListOfInts(0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(0x10, 0x00, 0x95, 0x90, 0x48),
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x39, 0x8E, 0x57, 0xCC, 0xEE, 0x68, 0x41, 0xBB))
)
),
// Application layer CTRL_BIND_RESPONSE
PairingTestSequenceEntry(
PacketDirection.RECEIVE,
TransportLayer.Packet(
command = TransportLayer.Command.DATA,
version = 0x10.toByte(),
sequenceBit = false,
reliabilityBit = true,
address = 0x01.toByte(),
nonce = Nonce(byteArrayListOfInts(0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(0x10, 0x00, 0x95, 0xA0, 0x00, 0x00, 0x48),
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0xF0, 0x49, 0xD4, 0x91, 0x01, 0x26, 0x33, 0xEF))
)
),
// Response due to the last packet's reliability bit set to true
PairingTestSequenceEntry(
PacketDirection.SEND,
TransportLayer.Packet(
command = TransportLayer.Command.ACK_RESPONSE,
version = 0x10.toByte(),
sequenceBit = false,
reliabilityBit = false,
address = 0x10.toByte(),
nonce = Nonce(byteArrayListOfInts(0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(),
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x38, 0x3D, 0x52, 0x56, 0x73, 0xBF, 0x59, 0xD8))
)
),
PairingTestSequenceEntry(
PacketDirection.SEND,
TransportLayer.Packet(
command = TransportLayer.Command.REQUEST_REGULAR_CONNECTION,
version = 0x10.toByte(),
sequenceBit = false,
reliabilityBit = false,
address = 0x10.toByte(),
nonce = Nonce(byteArrayListOfInts(0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(),
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x1D, 0xD4, 0xD5, 0xC6, 0x03, 0x3E, 0x0A, 0xBE))
)
),
PairingTestSequenceEntry(
PacketDirection.RECEIVE,
TransportLayer.Packet(
command = TransportLayer.Command.REGULAR_CONNECTION_REQUEST_ACCEPTED,
version = 0x10.toByte(),
sequenceBit = false,
reliabilityBit = false,
address = 0x01.toByte(),
nonce = Nonce(byteArrayListOfInts(0x0A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(),
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x34, 0xD2, 0x8B, 0x40, 0x27, 0x44, 0x82, 0x89))
)
),
// Application layer CTRL_DISCONNECT
PairingTestSequenceEntry(
PacketDirection.SEND,
TransportLayer.Packet(
command = TransportLayer.Command.DATA,
version = 0x10.toByte(),
sequenceBit = false,
reliabilityBit = true,
address = 0x10.toByte(),
nonce = Nonce(byteArrayListOfInts(0x0A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(0x10, 0x00, 0x5A, 0x00, 0x03, 0x00),
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x9D, 0xF4, 0x0F, 0x24, 0x44, 0xE3, 0x52, 0x03))
)
)
)
val testIO = PairingTestComboIO(expectedTestSequence)
val testPumpStateStore = TestPumpStateStore()
val testBluetoothDevice = TestBluetoothDevice(testIO)
val pumpIO = PumpIO(testPumpStateStore, testBluetoothDevice, onNewDisplayFrame = {}, onPacketReceiverException = {})
runBlockingWithWatchdog(6000) {
pumpIO.performPairing(
testBtFriendlyName,
null
) { _, _ -> testPIN }
}
if (testIO.testErrorOccurred)
fail("Failure in background coroutine")
}
}

View file

@ -0,0 +1,176 @@
package info.nightscout.comboctl.base
import kotlin.test.Test
import kotlin.test.assertEquals
class ProgressReporterTest {
// NOTE: In the tests here, the progress sequences are fairly
// arbitrary, and do _not_ reflect how actual sequences used
// in pairing etc. look like.
@Test
fun testBasicProgress() {
val progressReporter = ProgressReporter<Unit>(
listOf(
BasicProgressStage.EstablishingBtConnection::class,
BasicProgressStage.PerformingConnectionHandshake::class,
BasicProgressStage.ComboPairingKeyAndPinRequested::class,
BasicProgressStage.ComboPairingFinishing::class
),
Unit
)
assertEquals(
ProgressReport(0, 4, BasicProgressStage.Idle, 0.0),
progressReporter.progressFlow.value
)
progressReporter.setCurrentProgressStage(
BasicProgressStage.EstablishingBtConnection(1, 3)
)
assertEquals(
ProgressReport(0, 4, BasicProgressStage.EstablishingBtConnection(1, 3), 0.0 / 4.0),
progressReporter.progressFlow.value
)
progressReporter.setCurrentProgressStage(
BasicProgressStage.EstablishingBtConnection(2, 3)
)
assertEquals(
ProgressReport(0, 4, BasicProgressStage.EstablishingBtConnection(2, 3), 0.0 / 4.0),
progressReporter.progressFlow.value
)
progressReporter.setCurrentProgressStage(BasicProgressStage.PerformingConnectionHandshake)
assertEquals(
ProgressReport(1, 4, BasicProgressStage.PerformingConnectionHandshake, 1.0 / 4.0),
progressReporter.progressFlow.value
)
progressReporter.setCurrentProgressStage(BasicProgressStage.ComboPairingKeyAndPinRequested)
assertEquals(
ProgressReport(2, 4, BasicProgressStage.ComboPairingKeyAndPinRequested, 2.0 / 4.0),
progressReporter.progressFlow.value
)
progressReporter.setCurrentProgressStage(BasicProgressStage.ComboPairingFinishing)
assertEquals(
ProgressReport(3, 4, BasicProgressStage.ComboPairingFinishing, 3.0 / 4.0),
progressReporter.progressFlow.value
)
progressReporter.setCurrentProgressStage(BasicProgressStage.Finished)
assertEquals(
ProgressReport(4, 4, BasicProgressStage.Finished, 4.0 / 4.0),
progressReporter.progressFlow.value
)
}
@Test
fun testSkippedSteps() {
val progressReporter = ProgressReporter<Unit>(
listOf(
BasicProgressStage.EstablishingBtConnection::class,
BasicProgressStage.PerformingConnectionHandshake::class,
BasicProgressStage.ComboPairingKeyAndPinRequested::class,
BasicProgressStage.ComboPairingFinishing::class
),
Unit
)
assertEquals(
ProgressReport(0, 4, BasicProgressStage.Idle, 0.0),
progressReporter.progressFlow.value
)
progressReporter.setCurrentProgressStage(
BasicProgressStage.EstablishingBtConnection(1, 3)
)
assertEquals(
ProgressReport(0, 4, BasicProgressStage.EstablishingBtConnection(1, 3), 0.0 / 4.0),
progressReporter.progressFlow.value
)
progressReporter.setCurrentProgressStage(BasicProgressStage.ComboPairingFinishing)
assertEquals(
ProgressReport(3, 4, BasicProgressStage.ComboPairingFinishing, 3.0 / 4.0),
progressReporter.progressFlow.value
)
progressReporter.setCurrentProgressStage(BasicProgressStage.Finished)
assertEquals(
ProgressReport(4, 4, BasicProgressStage.Finished, 4.0 / 4.0),
progressReporter.progressFlow.value
)
}
@Test
fun testBackwardsProgress() {
val progressReporter = ProgressReporter<Unit>(
listOf(
BasicProgressStage.EstablishingBtConnection::class,
BasicProgressStage.PerformingConnectionHandshake::class,
BasicProgressStage.ComboPairingKeyAndPinRequested::class,
BasicProgressStage.ComboPairingFinishing::class
),
Unit
)
assertEquals(
ProgressReport(0, 4, BasicProgressStage.Idle, 0.0),
progressReporter.progressFlow.value
)
progressReporter.setCurrentProgressStage(BasicProgressStage.ComboPairingFinishing)
assertEquals(
ProgressReport(3, 4, BasicProgressStage.ComboPairingFinishing, 3.0 / 4.0),
progressReporter.progressFlow.value
)
progressReporter.setCurrentProgressStage(
BasicProgressStage.EstablishingBtConnection(1, 3)
)
assertEquals(
ProgressReport(0, 4, BasicProgressStage.EstablishingBtConnection(1, 3), 0.0 / 4.0),
progressReporter.progressFlow.value
)
progressReporter.setCurrentProgressStage(BasicProgressStage.Finished)
assertEquals(
ProgressReport(4, 4, BasicProgressStage.Finished, 4.0 / 4.0),
progressReporter.progressFlow.value
)
}
@Test
fun testAbort() {
val progressReporter = ProgressReporter<Unit>(
listOf(
BasicProgressStage.EstablishingBtConnection::class,
BasicProgressStage.PerformingConnectionHandshake::class,
BasicProgressStage.ComboPairingKeyAndPinRequested::class,
BasicProgressStage.ComboPairingFinishing::class
),
Unit
)
assertEquals(
ProgressReport(0, 4, BasicProgressStage.Idle, 0.0),
progressReporter.progressFlow.value
)
progressReporter.setCurrentProgressStage(
BasicProgressStage.EstablishingBtConnection(1, 3)
)
assertEquals(
ProgressReport(0, 4, BasicProgressStage.EstablishingBtConnection(1, 3), 0.0 / 4.0),
progressReporter.progressFlow.value
)
progressReporter.setCurrentProgressStage(BasicProgressStage.Cancelled)
assertEquals(
ProgressReport(4, 4, BasicProgressStage.Cancelled, 4.0 / 4.0),
progressReporter.progressFlow.value
)
}
}

View file

@ -0,0 +1,659 @@
package info.nightscout.comboctl.base
import info.nightscout.comboctl.base.testUtils.TestBluetoothDevice
import info.nightscout.comboctl.base.testUtils.TestComboIO
import info.nightscout.comboctl.base.testUtils.TestPumpStateStore
import info.nightscout.comboctl.base.testUtils.TestRefPacketItem
import info.nightscout.comboctl.base.testUtils.checkTestPacketSequence
import info.nightscout.comboctl.base.testUtils.produceTpLayerPacket
import info.nightscout.comboctl.base.testUtils.runBlockingWithWatchdog
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlinx.coroutines.delay
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.UtcOffset
class PumpIOTest {
// Common test code.
class TestStates(setupInvariantPumpData: Boolean) {
var testPumpStateStore: TestPumpStateStore
val testBluetoothDevice: TestBluetoothDevice
var testIO: TestComboIO
var pumpIO: PumpIO
init {
Logger.threshold = LogLevel.VERBOSE
// Set up the invariant pump data to be able to test regular connections.
testPumpStateStore = TestPumpStateStore()
testIO = TestComboIO()
testIO.respondToRTKeypressWithConfirmation = true
testBluetoothDevice = TestBluetoothDevice(testIO)
if (setupInvariantPumpData) {
val invariantPumpData = InvariantPumpData(
keyResponseAddress = 0x10,
clientPumpCipher = Cipher(byteArrayOfInts(
0x5a, 0x25, 0x0b, 0x75, 0xa9, 0x02, 0x21, 0xfa,
0xab, 0xbd, 0x36, 0x4d, 0x5c, 0xb8, 0x37, 0xd7)),
pumpClientCipher = Cipher(byteArrayOfInts(
0x2a, 0xb0, 0xf2, 0x67, 0xc2, 0x7d, 0xcf, 0xaa,
0x32, 0xb2, 0x48, 0x94, 0xe1, 0x6d, 0xe9, 0x5c)),
pumpID = "testPump"
)
testPumpStateStore.createPumpState(testBluetoothDevice.address, invariantPumpData, UtcOffset.ZERO, CurrentTbrState.NoTbrOngoing)
testIO.pumpClientCipher = invariantPumpData.pumpClientCipher
}
pumpIO = PumpIO(testPumpStateStore, testBluetoothDevice, onNewDisplayFrame = {}, onPacketReceiverException = {})
}
// Tests that a long button press is handled correctly.
// We expect an initial RT_BUTTON_STATUS packet with its
// buttonStatusChanged flag set to true, followed by
// a series of similar packet with the buttonStatusChanged
// flag set to false, and finished by an RT_BUTTON_STATUS
// packet whose button code is NO_BUTTON.
fun checkLongRTButtonPressPacketSequence(appLayerButton: ApplicationLayer.RTButton) {
assertTrue(
testIO.sentPacketData.size >= 3,
"Expected at least 3 items in sentPacketData list, got ${testIO.sentPacketData.size}"
)
checkRTButtonStatusPacketData(
testIO.sentPacketData.first(),
appLayerButton,
true
)
testIO.sentPacketData.removeAt(0)
checkDisconnectPacketData(testIO.sentPacketData.last())
testIO.sentPacketData.removeAt(testIO.sentPacketData.size - 1)
checkRTButtonStatusPacketData(
testIO.sentPacketData.last(),
ApplicationLayer.RTButton.NO_BUTTON,
true
)
testIO.sentPacketData.removeAt(testIO.sentPacketData.size - 1)
for (packetData in testIO.sentPacketData) {
checkRTButtonStatusPacketData(
packetData,
appLayerButton,
false
)
}
}
// Feeds initial connection setup packets into the test IO
// that would normally be sent by the Combo during connection
// setup. In that setup, the Combo is instructed to switch to
// the RT mode, so this also feeds a CTRL_ACTIVATE_SERVICE_RESPONSE
// packet into the IO.
suspend fun feedInitialPackets() {
val invariantPumpData = testPumpStateStore.getInvariantPumpData(testBluetoothDevice.address)
testIO.feedIncomingData(
produceTpLayerPacket(
TransportLayer.OutgoingPacketInfo(
command = TransportLayer.Command.REGULAR_CONNECTION_REQUEST_ACCEPTED
),
invariantPumpData.pumpClientCipher
).toByteList()
)
testIO.feedIncomingData(
produceTpLayerPacket(
ApplicationLayer.Packet(
command = ApplicationLayer.Command.CTRL_CONNECT_RESPONSE
).toTransportLayerPacketInfo(),
invariantPumpData.pumpClientCipher
).toByteList()
)
testIO.feedIncomingData(
produceTpLayerPacket(
ApplicationLayer.Packet(
command = ApplicationLayer.Command.CTRL_ACTIVATE_SERVICE_RESPONSE,
payload = byteArrayListOfInts(1, 2, 3, 4, 5)
).toTransportLayerPacketInfo(),
invariantPumpData.pumpClientCipher
).toByteList()
)
}
// This removes initial connection setup packets that are
// normally sent to the Combo. Outgoing packets are recorded
// in the testIO.sentPacketData list. In the tests here, we
// are not interested in these initial packets. This function
// gets rid of them.
fun checkAndRemoveInitialSentPackets() {
val expectedInitialPacketSequence = listOf(
TestRefPacketItem.TransportLayerPacketItem(
TransportLayer.createRequestRegularConnectionPacketInfo()
),
TestRefPacketItem.ApplicationLayerPacketItem(
ApplicationLayer.createCTRLConnectPacket()
),
TestRefPacketItem.ApplicationLayerPacketItem(
ApplicationLayer.createCTRLActivateServicePacket(ApplicationLayer.ServiceID.RT_MODE)
)
)
checkTestPacketSequence(expectedInitialPacketSequence, testIO.sentPacketData)
for (i in expectedInitialPacketSequence.indices)
testIO.sentPacketData.removeAt(0)
}
fun checkRTButtonStatusPacketData(
packetData: List<Byte>,
rtButton: ApplicationLayer.RTButton,
buttonStatusChangedFlag: Boolean
) {
val appLayerPacket = ApplicationLayer.Packet(packetData.toTransportLayerPacket())
assertEquals(ApplicationLayer.Command.RT_BUTTON_STATUS, appLayerPacket.command, "Application layer packet command mismatch")
assertEquals(rtButton.id.toByte(), appLayerPacket.payload[2], "RT_BUTTON_STATUS button byte mismatch")
assertEquals((if (buttonStatusChangedFlag) 0xB7 else 0x48).toByte(), appLayerPacket.payload[3], "RT_BUTTON_STATUS status flag mismatch")
}
fun checkDisconnectPacketData(packetData: List<Byte>) {
val appLayerPacket = ApplicationLayer.Packet(packetData.toTransportLayerPacket())
assertEquals(ApplicationLayer.Command.CTRL_DISCONNECT, appLayerPacket.command, "Application layer packet command mismatch")
}
}
@Test
fun checkShortButtonPress() {
// Check that a short button press is handled correctly.
// Short button presses are performed by sending two RT_BUTTON_STATUS
// packets. The first one contains the actual button code, the second
// one contains a NO_BUTTON code. We send two short button presses.
// This amounts to 2 pairs of RT_BUTTON_STATUS packets plus the
// final CTRL_DISCONNECT packets, for a total of 5 packets.
runBlockingWithWatchdog(6000) {
val testStates = TestStates(true)
val pumpIO = testStates.pumpIO
val testIO = testStates.testIO
testStates.feedInitialPackets()
pumpIO.connect(runHeartbeat = false)
pumpIO.sendShortRTButtonPress(ApplicationLayer.RTButton.UP)
delay(200L)
pumpIO.sendShortRTButtonPress(ApplicationLayer.RTButton.UP)
delay(200L)
pumpIO.disconnect()
testStates.checkAndRemoveInitialSentPackets()
// 4 RT packets from the sendShortRTButtonPress() calls
// above plus the final CTRL_DISCONNECT packet -> 5 packets.
assertEquals(5, testIO.sentPacketData.size)
// The two RT_BUTTON_STATUS packets (first one with button
// code UP, second one with button code NO_BUTTON) that
// were sent by the first sendShortRTButtonPress() call.
testStates.checkRTButtonStatusPacketData(
testIO.sentPacketData[0],
ApplicationLayer.RTButton.UP,
true
)
testStates.checkRTButtonStatusPacketData(
testIO.sentPacketData[1],
ApplicationLayer.RTButton.NO_BUTTON,
true
)
// The two RT_BUTTON_STATUS packets (first one with button
// code UP, second one with button code NO_BUTTON) that
// were sent by the second sendShortRTButtonPress() call.
testStates.checkRTButtonStatusPacketData(
testIO.sentPacketData[2],
ApplicationLayer.RTButton.UP,
true
)
testStates.checkRTButtonStatusPacketData(
testIO.sentPacketData[3],
ApplicationLayer.RTButton.NO_BUTTON,
true
)
// The final CTRL_DISCONNECT packet.
testStates.checkDisconnectPacketData(testIO.sentPacketData[4])
}
}
@Test
fun checkUpDownLongRTButtonPress() {
// Basic long press test. After connecting to the simulated Combo,
// the UP button is long-pressed. Then, the client reconnects to the
// Combo, and the same is done with the DOWN button. This tests that
// no states remain from a previous connection, and also of course
// tests that long-presses are handled correctly.
// The connection is established with the RT Keep-alive loop disabled
// to avoid having to deal with RT_KEEP_ALIVE packets in the
// testIO.sentPacketData list.
runBlockingWithWatchdog(6000) {
val testStates = TestStates(true)
val testIO = testStates.testIO
val pumpIO = testStates.pumpIO
// First, test long UP button press.
testStates.feedInitialPackets()
pumpIO.connect(runHeartbeat = false)
var counter = 0
pumpIO.startLongRTButtonPress(ApplicationLayer.RTButton.UP) {
// Return true the first time, false the second time.
// This way, we inform the function that it should
// send a button status to the Combo once (= when
// we return true).
counter++
counter <= 1
}
pumpIO.waitForLongRTButtonPressToFinish()
pumpIO.disconnect()
testStates.checkAndRemoveInitialSentPackets()
testStates.checkLongRTButtonPressPacketSequence(ApplicationLayer.RTButton.UP)
// Next, test long DOWN button press. Use stopLongRTButtonPress()
// instead of waitForLongRTButtonPressToFinish() here to also
// test that function. Waiting for a while and calling it should
// amount to the same behavior as calling waitForLongRTButtonPressToFinish().
testIO.resetSentPacketData()
testIO.resetIncomingPacketDataChannel()
testStates.feedInitialPackets()
pumpIO.connect(runHeartbeat = false)
pumpIO.startLongRTButtonPress(ApplicationLayer.RTButton.DOWN)
delay(500L)
pumpIO.stopLongRTButtonPress()
delay(500L)
pumpIO.disconnect()
testStates.checkAndRemoveInitialSentPackets()
testStates.checkLongRTButtonPressPacketSequence(ApplicationLayer.RTButton.DOWN)
}
}
@Test
fun checkDoubleLongButtonPress() {
// Check what happens if the user issues redundant startLongRTButtonPress()
// calls. The second call here should be ignored.
runBlockingWithWatchdog(6000) {
val testStates = TestStates(true)
val pumpIO = testStates.pumpIO
testStates.feedInitialPackets()
pumpIO.connect(runHeartbeat = false)
var counter = 0
pumpIO.startLongRTButtonPress(ApplicationLayer.RTButton.UP) {
// Return true the first time, false the second time.
// This way, we inform the function that it should
// send a button status to the Combo once (= when
// we return true).
counter++
counter <= 1
}
pumpIO.startLongRTButtonPress(ApplicationLayer.RTButton.UP)
pumpIO.waitForLongRTButtonPressToFinish()
pumpIO.disconnect()
testStates.checkAndRemoveInitialSentPackets()
testStates.checkLongRTButtonPressPacketSequence(ApplicationLayer.RTButton.UP)
}
}
@Test
fun checkDoubleLongButtonRelease() {
// Check what happens if the user issues redundant waitForLongRTButtonPressToFinish()
// calls. The second call here should be ignored.
runBlockingWithWatchdog(6000) {
val testStates = TestStates(true)
val pumpIO = testStates.pumpIO
testStates.feedInitialPackets()
pumpIO.connect(runHeartbeat = false)
var counter = 0
pumpIO.startLongRTButtonPress(ApplicationLayer.RTButton.UP) {
// Return true the first time, false the second time.
// This way, we inform the function that it should
// send a button status to the Combo once (= when
// we return true).
counter++
counter <= 1
}
pumpIO.waitForLongRTButtonPressToFinish()
pumpIO.waitForLongRTButtonPressToFinish()
pumpIO.disconnect()
testStates.checkAndRemoveInitialSentPackets()
testStates.checkLongRTButtonPressPacketSequence(ApplicationLayer.RTButton.UP)
}
}
@Test
fun checkRTSequenceNumberAssignment() {
// Check that PumpIO fills in correctly the RT sequence
// in outgoing RT packets. We use sendShortRTButtonPress()
// for this purpose, since each call produces 2 RT packets.
// We look at the transmitted RT packets and check if their
// RT sequence numbers are monotonically increasing, which
// is the correct behavior.
runBlockingWithWatchdog(6000) {
val testStates = TestStates(true)
val pumpIO = testStates.pumpIO
val testIO = testStates.testIO
testStates.feedInitialPackets()
pumpIO.connect(runHeartbeat = false)
pumpIO.sendShortRTButtonPress(ApplicationLayer.RTButton.UP)
delay(200L)
pumpIO.sendShortRTButtonPress(ApplicationLayer.RTButton.UP)
delay(200L)
pumpIO.sendShortRTButtonPress(ApplicationLayer.RTButton.UP)
delay(200L)
pumpIO.disconnect()
testStates.checkAndRemoveInitialSentPackets()
// 6 RT packets from the sendShortRTButtonPress() calls
// above plus the final CTRL_DISCONNECT packet -> 7 packets.
assertEquals(7, testIO.sentPacketData.size)
// The 3 sendShortRTButtonPress() calls each sent two
// packets, so we look at the first six packets here.
// The last one is the CTRL_DISCONNECT packet, which
// we verify below. The first 6 packets are RT packets,
// and their sequence numbers must be monotonically
// increasing, as explained above.
for (index in 0 until 6) {
val appLayerPacket = ApplicationLayer.Packet(testIO.sentPacketData[index].toTransportLayerPacket())
val rtSequenceNumber = (appLayerPacket.payload[0].toPosInt() shl 0) or (appLayerPacket.payload[1].toPosInt() shl 8)
assertEquals(index, rtSequenceNumber)
}
testStates.checkDisconnectPacketData(testIO.sentPacketData[6])
}
}
@Test
fun cmdCMDReadErrorWarningStatus() {
runBlockingWithWatchdog(6000) {
// Check that a simulated CMD error/warning status retrieval is performed successfully.
// Feed in raw data bytes into the test IO. These raw bytes are packets that contain
// error/warning status data. Check that these packets are correctly parsed and that
// the retrieved status is correct.
val testStates = TestStates(setupInvariantPumpData = false)
val pumpIO = testStates.pumpIO
val testIO = testStates.testIO
// Need to set up custom keys since the test data was
// created with those instead of the default test keys.
val invariantPumpData = InvariantPumpData(
keyResponseAddress = 0x10,
clientPumpCipher = Cipher(byteArrayOfInts(
0x12, 0xe2, 0x4a, 0xb6, 0x67, 0x50, 0xe5, 0xb4,
0xc4, 0xea, 0x10, 0xa7, 0x55, 0x11, 0x61, 0xd4)),
pumpClientCipher = Cipher(byteArrayOfInts(
0x8e, 0x0d, 0x35, 0xe3, 0x7c, 0xd7, 0x20, 0x55,
0x57, 0x2b, 0x05, 0x50, 0x34, 0x43, 0xc9, 0x8d)),
pumpID = "testPump"
)
testStates.testPumpStateStore.createPumpState(
testStates.testBluetoothDevice.address, invariantPumpData, UtcOffset.ZERO, CurrentTbrState.NoTbrOngoing)
testIO.pumpClientCipher = invariantPumpData.pumpClientCipher
testStates.feedInitialPackets()
pumpIO.connect(
initialMode = PumpIO.Mode.COMMAND,
runHeartbeat = false
)
val errorWarningStatusData = byteArrayListOfInts(
0x10, 0x23, 0x08, 0x00, 0x01, 0x39, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0xb7, 0xa5, 0xaa,
0x00, 0x00, 0x48, 0xb7, 0xa0, 0xea, 0x70, 0xc3, 0xd4, 0x42, 0x61, 0xd7
)
testIO.feedIncomingData(errorWarningStatusData)
val errorWarningStatus = pumpIO.readCMDErrorWarningStatus()
pumpIO.disconnect()
assertEquals(
ApplicationLayer.CMDErrorWarningStatus(errorOccurred = false, warningOccurred = true),
errorWarningStatus
)
}
}
@Test
fun checkCMDHistoryDeltaRetrieval() {
runBlockingWithWatchdog(6000) {
// Check that a simulated CMD history delta retrieval is performed successfully.
// Feed in raw data bytes into the test IO. These raw bytes are packets that
// contain history data with a series of events inside. Check that these packets
// are correctly parsed and that the retrieved history is correct.
val testStates = TestStates(setupInvariantPumpData = false)
val pumpIO = testStates.pumpIO
val testIO = testStates.testIO
// Need to set up custom keys since the test data was
// created with those instead of the default test keys.
val invariantPumpData = InvariantPumpData(
keyResponseAddress = 0x10,
clientPumpCipher = Cipher(byteArrayOfInts(
0x75, 0xb8, 0x88, 0xa8, 0xe7, 0x68, 0xc9, 0x25,
0x66, 0xc9, 0x3c, 0x4b, 0xd8, 0x09, 0x27, 0xd8)),
pumpClientCipher = Cipher(byteArrayOfInts(
0xb8, 0x75, 0x8c, 0x54, 0x88, 0x71, 0x78, 0xed,
0xad, 0xb7, 0xb7, 0xc1, 0x48, 0x37, 0xf3, 0x07)),
pumpID = "testPump"
)
testStates.testPumpStateStore.createPumpState(
testStates.testBluetoothDevice.address, invariantPumpData, UtcOffset.ZERO, CurrentTbrState.NoTbrOngoing)
testIO.pumpClientCipher = invariantPumpData.pumpClientCipher
val historyBlockPacketData = listOf(
// CMD_READ_HISTORY_BLOCK_RESPONSE
byteArrayListOfInts(
0x10, 0xa3, 0x65, 0x00, 0x01, 0x08, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x10, 0xb7, 0x96, 0xa9, 0x00, 0x00, 0x10, 0x00, 0x48, 0xb7, 0x05, 0xaa, 0x0d, 0x93, 0x54, 0x0f, 0x00, 0x00,
0x00, 0x04, 0x00, 0x6b, 0xf3, 0x09, 0x3b, 0x01, 0x00, 0x92, 0x4c, 0xb1, 0x0d, 0x93, 0x54, 0x0f, 0x00, 0x00,
0x00, 0x05, 0x00, 0xa1, 0x25, 0x0b, 0x3b, 0x01, 0x00, 0xe4, 0x75, 0x46, 0x0e, 0x93, 0x54, 0x1d, 0x00, 0x00,
0x00, 0x06, 0x00, 0xb7, 0xda, 0x0d, 0x3b, 0x01, 0x00, 0x7e, 0x3e, 0x54, 0x0e, 0x93, 0x54, 0x1d, 0x00, 0x00,
0x00, 0x07, 0x00, 0x73, 0x49, 0x0f, 0x3b, 0x01, 0x00, 0x08, 0x07, 0x77, 0x0e, 0x93, 0x54, 0x05, 0x00, 0x00,
0x00, 0x04, 0x00, 0x2f, 0xd8, 0x11, 0x3b, 0x01, 0x00, 0xeb, 0x6a, 0x81, 0xf5, 0x6c, 0x43, 0xf0, 0x88, 0x15, 0x3b
),
// CMD_CONFIRM_HISTORY_BLOCK_RESPONSE
byteArrayListOfInts(
0x10, 0x23, 0x06, 0x00, 0x01, 0x0a, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x10, 0xb7, 0x99, 0xa9, 0x00, 0x00, 0x8f, 0xec, 0xfa, 0xa7, 0xf5, 0x0d, 0x01, 0x6c
),
// CMD_READ_HISTORY_BLOCK_RESPONSE
byteArrayListOfInts(
0x10, 0xa3, 0x65, 0x00, 0x01, 0x0c, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x10, 0xb7, 0x96, 0xa9, 0x00, 0x00, 0x0b, 0x00, 0x48, 0xb7, 0x05, 0x79, 0x0e, 0x93, 0x54, 0x05, 0x00, 0x00,
0x00, 0x05, 0x00, 0x0c, 0x40, 0x13, 0x3b, 0x01, 0x00, 0x9d, 0x53, 0xad, 0x0e, 0x93, 0x54, 0x12, 0x00, 0x00,
0x00, 0x06, 0x00, 0x46, 0xa5, 0x15, 0x3b, 0x01, 0x00, 0x07, 0x18, 0xb6, 0x0e, 0x93, 0x54, 0x12, 0x00, 0x00,
0x00, 0x07, 0x00, 0x8c, 0x73, 0x17, 0x3b, 0x01, 0x00, 0x71, 0x21, 0x13, 0x10, 0x93, 0x54, 0xb1, 0x00, 0x0f,
0x00, 0x08, 0x00, 0xbb, 0x78, 0x1a, 0x3b, 0x01, 0x00, 0xfe, 0xaa, 0xd2, 0x13, 0x93, 0x54, 0xb1, 0x00, 0x0f,
0x00, 0x09, 0x00, 0xce, 0x68, 0x1c, 0x3b, 0x01, 0x00, 0x64, 0xe1, 0x2c, 0xc8, 0x37, 0xb3, 0xe5, 0xb7, 0x7c, 0xc4
),
// CMD_CONFIRM_HISTORY_BLOCK_RESPONSE
byteArrayListOfInts(
0x10, 0x23, 0x06, 0x00, 0x01, 0x0e, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x10, 0xb7, 0x99, 0xa9, 0x00, 0x00, 0xe5, 0xab, 0x11, 0x6d, 0xfc, 0x60, 0xfb, 0xee
),
// CMD_READ_HISTORY_BLOCK_RESPONSE
byteArrayListOfInts(
0x10, 0xa3, 0x65, 0x00, 0x01, 0x10, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x10, 0xb7, 0x96, 0xa9, 0x00, 0x00, 0x06, 0x00, 0x48, 0xb7, 0x05, 0x5f, 0x15, 0x93, 0x54, 0xc1, 0x94, 0xe0,
0x01, 0x0a, 0x00, 0x76, 0x3b, 0x1e, 0x3b, 0x01, 0x00, 0x12, 0xd8, 0xc8, 0x1c, 0x93, 0x54, 0xc1, 0x94, 0xe0,
0x01, 0x0b, 0x00, 0xc8, 0xa4, 0x20, 0x3b, 0x01, 0x00, 0xa2, 0x3a, 0x59, 0x20, 0x93, 0x54, 0x40, 0x30, 0x93,
0x54, 0x18, 0x00, 0xbb, 0x0c, 0x23, 0x3b, 0x01, 0x00, 0x6f, 0x1f, 0x40, 0x30, 0x93, 0x54, 0x00, 0x00, 0x00,
0x00, 0x19, 0x00, 0x2b, 0x80, 0x24, 0x3b, 0x01, 0x00, 0x4e, 0x48, 0x85, 0x30, 0x93, 0x54, 0x14, 0x00, 0x00,
0x00, 0x04, 0x00, 0xe8, 0x98, 0x2b, 0x3b, 0x01, 0x00, 0xb7, 0xfa, 0x0e, 0x32, 0x37, 0x19, 0xb6, 0x59, 0x5a, 0xb1
),
// CMD_CONFIRM_HISTORY_BLOCK_RESPONSE
byteArrayListOfInts(
0x10, 0x23, 0x06, 0x00, 0x01, 0x12, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x10, 0xb7, 0x99, 0xa9, 0x00, 0x00, 0xae, 0xaa, 0xa7, 0x3a, 0xbc, 0x82, 0x8c, 0x15
),
// CMD_READ_HISTORY_BLOCK_RESPONSE
byteArrayListOfInts(
0x10, 0xa3, 0x1d, 0x00, 0x01, 0x14, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x10, 0xb7, 0x96, 0xa9, 0x00, 0x00, 0x01, 0x00, 0xb7, 0xb7, 0x01, 0x8f, 0x30, 0x93, 0x54, 0x14, 0x00, 0x00,
0x00, 0x05, 0x00, 0x57, 0xb0, 0x2d, 0x3b, 0x01, 0x00, 0x2d, 0xb1, 0x29, 0x32, 0xde, 0x3c, 0xa0, 0x80, 0x33, 0xd3
),
// CMD_CONFIRM_HISTORY_BLOCK_RESPONSE
byteArrayListOfInts(
0x10, 0x23, 0x06, 0x00, 0x01, 0x16, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x10, 0xb7, 0x99, 0xa9, 0x00, 0x00, 0x15, 0x63, 0xa5, 0x60, 0x3d, 0x75, 0xff, 0xfc
)
)
val expectedHistoryDeltaEvents = listOf(
ApplicationLayer.CMDHistoryEvent(
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 16, minute = 54, second = 42),
80649,
ApplicationLayer.CMDHistoryEventDetail.QuickBolusRequested(15)
),
ApplicationLayer.CMDHistoryEvent(
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 16, minute = 54, second = 49),
80651,
ApplicationLayer.CMDHistoryEventDetail.QuickBolusInfused(15)
),
ApplicationLayer.CMDHistoryEvent(
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 16, minute = 57, second = 6),
80653,
ApplicationLayer.CMDHistoryEventDetail.StandardBolusRequested(29, true)
),
ApplicationLayer.CMDHistoryEvent(
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 16, minute = 57, second = 20),
80655,
ApplicationLayer.CMDHistoryEventDetail.StandardBolusInfused(29, true)
),
ApplicationLayer.CMDHistoryEvent(
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 16, minute = 57, second = 55),
80657,
ApplicationLayer.CMDHistoryEventDetail.QuickBolusRequested(5)
),
ApplicationLayer.CMDHistoryEvent(
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 16, minute = 57, second = 57),
80659,
ApplicationLayer.CMDHistoryEventDetail.QuickBolusInfused(5)
),
ApplicationLayer.CMDHistoryEvent(
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 16, minute = 58, second = 45),
80661,
ApplicationLayer.CMDHistoryEventDetail.StandardBolusRequested(18, true)
),
ApplicationLayer.CMDHistoryEvent(
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 16, minute = 58, second = 54),
80663,
ApplicationLayer.CMDHistoryEventDetail.StandardBolusInfused(18, true)
),
ApplicationLayer.CMDHistoryEvent(
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 17, minute = 0, second = 19),
80666,
ApplicationLayer.CMDHistoryEventDetail.ExtendedBolusStarted(177, 15)
),
ApplicationLayer.CMDHistoryEvent(
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 17, minute = 15, second = 18),
80668,
ApplicationLayer.CMDHistoryEventDetail.ExtendedBolusEnded(177, 15)
),
ApplicationLayer.CMDHistoryEvent(
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 17, minute = 21, second = 31),
80670,
ApplicationLayer.CMDHistoryEventDetail.MultiwaveBolusStarted(193, 37, 30)
),
ApplicationLayer.CMDHistoryEvent(
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 17, minute = 51, second = 8),
80672,
ApplicationLayer.CMDHistoryEventDetail.MultiwaveBolusEnded(193, 37, 30)
),
ApplicationLayer.CMDHistoryEvent(
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 18, minute = 1, second = 25),
80675,
ApplicationLayer.CMDHistoryEventDetail.NewDateTimeSet(
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 19, minute = 1, second = 0)
)
),
ApplicationLayer.CMDHistoryEvent(
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 19, minute = 2, second = 5),
80683,
ApplicationLayer.CMDHistoryEventDetail.QuickBolusRequested(20)
),
ApplicationLayer.CMDHistoryEvent(
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 19, minute = 2, second = 15),
80685,
ApplicationLayer.CMDHistoryEventDetail.QuickBolusInfused(20)
)
)
testStates.feedInitialPackets()
pumpIO.connect(
initialMode = PumpIO.Mode.COMMAND,
runHeartbeat = false
)
historyBlockPacketData.forEach { testIO.feedIncomingData(it) }
val historyDelta = pumpIO.getCMDHistoryDelta(100)
pumpIO.disconnect()
assertEquals(expectedHistoryDeltaEvents.size, historyDelta.size)
for (events in expectedHistoryDeltaEvents.zip(historyDelta))
assertEquals(events.first, events.second)
}
}
}

View file

@ -0,0 +1,411 @@
package info.nightscout.comboctl.base
import info.nightscout.comboctl.base.testUtils.TestComboIO
import info.nightscout.comboctl.base.testUtils.TestPumpStateStore
import info.nightscout.comboctl.base.testUtils.WatchdogTimeoutException
import info.nightscout.comboctl.base.testUtils.coroutineScopeWithWatchdog
import info.nightscout.comboctl.base.testUtils.runBlockingWithWatchdog
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlinx.coroutines.Job
import kotlinx.datetime.UtcOffset
class TransportLayerTest {
@Test
fun parsePacketData() {
// Test the packet parser by parsing hardcoded packet data
// and verifying the individual packet property values.
val packetDataWithCRCPayload = byteArrayListOfInts(
0x10, // version
0x09, // request_pairing_connection command (sequence and data reliability bit set to 0)
0x02, 0x00, // payload length
0xF0, // address
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // nonce
0x99, 0x44, // payload
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 // nullbyte MAC
)
// The actual parsing.
val packet = packetDataWithCRCPayload.toTransportLayerPacket()
// Check the individual properties.
assertEquals(0x10, packet.version)
assertFalse(packet.sequenceBit)
assertFalse(packet.reliabilityBit)
assertEquals(TransportLayer.Command.REQUEST_PAIRING_CONNECTION, packet.command)
assertEquals(0xF0.toByte(), packet.address)
assertEquals(Nonce.nullNonce(), packet.nonce)
assertEquals(byteArrayListOfInts(0x99, 0x44), packet.payload)
assertEquals(NullMachineAuthCode, packet.machineAuthenticationCode)
}
@Test
fun createPacketData() {
// Create packet, and check that it is correctly converted to a byte list.
val packet = TransportLayer.Packet(
command = TransportLayer.Command.REQUEST_PAIRING_CONNECTION,
version = 0x42,
sequenceBit = true,
reliabilityBit = false,
address = 0x45,
nonce = Nonce(byteArrayListOfInts(0x0A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0B)),
payload = byteArrayListOfInts(0x50, 0x60, 0x70),
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08))
)
val byteList = packet.toByteList()
val expectedPacketData = byteArrayListOfInts(
0x42, // version
0x80 or 0x09, // command 0x09 with sequence bit enabled
0x03, 0x00, // payload length
0x45, // address,
0x0A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0B, // nonce
0x50, 0x60, 0x70, // payload
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08 // MAC
)
assertEquals(byteList, expectedPacketData)
}
@Test
fun verifyPacketDataIntegrityWithCRC() {
// Create packet and verify that the CRC check detects data corruption.
val packet = TransportLayer.Packet(
command = TransportLayer.Command.REQUEST_PAIRING_CONNECTION,
version = 0x42,
sequenceBit = true,
reliabilityBit = false,
address = 0x45,
nonce = Nonce(byteArrayListOfInts(0x0A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0B)),
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
)
// Check that the computed CRC is correct.
packet.computeCRC16Payload()
val expectedCRCPayload = byteArrayListOfInts(0xE1, 0x7B)
assertEquals(expectedCRCPayload, packet.payload)
// The CRC should match, since it was just computed.
assertTrue(packet.verifyCRC16Payload())
// Simulate data corruption by altering the CRC itself.
// This should produce a CRC mismatch, since the check
// will recompute the CRC from the header data.
packet.payload[0] = (packet.payload[0].toPosInt() xor 0xFF).toByte()
assertFalse(packet.verifyCRC16Payload())
}
@Test
fun verifyPacketDataIntegrityWithMAC() {
// Create packet and verify that the MAC check detects data corruption.
val key = ByteArray(CIPHER_KEY_SIZE).apply { fill('0'.code.toByte()) }
val cipher = Cipher(key)
val packet = TransportLayer.Packet(
command = TransportLayer.Command.REQUEST_PAIRING_CONNECTION,
version = 0x42,
sequenceBit = true,
reliabilityBit = false,
address = 0x45,
nonce = Nonce(byteArrayListOfInts(0x0A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0B)),
payload = byteArrayListOfInts(0x00, 0x00)
)
// Check that the computed MAC is correct.
packet.authenticate(cipher)
val expectedMAC = MachineAuthCode(byteArrayListOfInts(0x00, 0xC5, 0x48, 0xB3, 0xA8, 0xE6, 0x97, 0x76))
assertEquals(expectedMAC, packet.machineAuthenticationCode)
// The MAC should match, since it was just computed.
assertTrue(packet.verifyAuthentication(cipher))
// Simulate data corruption by altering the payload.
// This should produce a MAC mismatch.
packet.payload[0] = 0xFF.toByte()
assertFalse(packet.verifyAuthentication(cipher))
}
@Test
fun checkBasicTransportLayerSequence() {
// Run a basic sequence of IO operations and verify
// that they produce the expected results. We use
// the connection setup sequence, since this one does
// not require an existing pump state.
// The calls must be run from within a coroutine scope.
// Starts a blocking scope with a watchdog that fails
// the test if it does not finish within 5 seconds
// (in case the tested code hangs).
runBlockingWithWatchdog(5000) {
val testPumpStateStore = TestPumpStateStore()
val testComboIO = TestComboIO()
val testBluetoothAddress = BluetoothAddress(byteArrayListOfInts(1, 2, 3, 4, 5, 6))
val tpLayerIO = TransportLayer.IO(testPumpStateStore, testBluetoothAddress, testComboIO) {}
// We'll simulate sending a REQUEST_PAIRING_CONNECTION packet and
// receiving a PAIRING_CONNECTION_REQUEST_ACCEPTED packet.
val pairingConnectionRequestAcceptedPacket = TransportLayer.Packet(
command = TransportLayer.Command.PAIRING_CONNECTION_REQUEST_ACCEPTED,
version = 0x10.toByte(),
sequenceBit = false,
reliabilityBit = false,
address = 0x0f.toByte(),
nonce = Nonce(byteArrayListOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(0x00, 0xF0, 0x6D),
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
)
// Set up transport layer IO and forward all packets to receive() calls.
tpLayerIO.start(packetReceiverScope = this) { TransportLayer.IO.ReceiverBehavior.FORWARD_PACKET }
// Send a REQUEST_PAIRING_CONNECTION and simulate Combo reaction
// to it by feeding the simulated Combo response into the IO object.
tpLayerIO.send(TransportLayer.createRequestPairingConnectionPacketInfo())
testComboIO.feedIncomingData(pairingConnectionRequestAcceptedPacket.toByteList())
// Receive the simulated response.
val receivedPacket = tpLayerIO.receive()
tpLayerIO.stop()
// IO is done. We expect 1 packet to have been sent by the transport layer IO.
// Also, we expect to have received the PAIRING_CONNECTION_REQUEST_ACCEPTED
// packet. Check for this, and verify that the sent packet data and the
// received packet data are correct.
val expectedReqPairingConnectionPacket = TransportLayer.Packet(
command = TransportLayer.Command.REQUEST_PAIRING_CONNECTION,
version = 0x10.toByte(),
sequenceBit = false,
reliabilityBit = false,
address = 0xf0.toByte(),
nonce = Nonce(byteArrayListOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(0xB2, 0x11),
machineAuthenticationCode = MachineAuthCode(byteArrayListOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
)
assertEquals(1, testComboIO.sentPacketData.size)
assertEquals(expectedReqPairingConnectionPacket.toByteList(), testComboIO.sentPacketData[0])
assertEquals(pairingConnectionRequestAcceptedPacket.toByteList(), receivedPacket.toByteList())
}
}
@Test
fun checkPacketReceiverExceptionHandling() {
// Test how exceptions in TransportLayer.IO are handled.
// We expect that the coroutine inside IO is stopped by
// an exception thrown inside.
// Subsequent send and receive call attempts need to throw
// a PacketReceiverException which in turn contains the
// exception that caused the failure.
runBlockingWithWatchdog(5000) {
val testPumpStateStore = TestPumpStateStore()
val testComboIO = TestComboIO()
val testBluetoothAddress = BluetoothAddress(byteArrayListOfInts(1, 2, 3, 4, 5, 6))
var expectedError: Throwable? = null
val waitingForExceptionJob = Job()
val tpLayerIO = TransportLayer.IO(testPumpStateStore, testBluetoothAddress, testComboIO) { exception ->
expectedError = exception
waitingForExceptionJob.complete()
}
// Initialize pump state for the ERROR_RESPONSE packet, since
// that one is authenticated via its MAC and this pump state.
val testDecryptedCPKey =
byteArrayListOfInts(0x5a, 0x25, 0x0b, 0x75, 0xa9, 0x02, 0x21, 0xfa, 0xab, 0xbd, 0x36, 0x4d, 0x5c, 0xb8, 0x37, 0xd7)
val testDecryptedPCKey =
byteArrayListOfInts(0x2a, 0xb0, 0xf2, 0x67, 0xc2, 0x7d, 0xcf, 0xaa, 0x32, 0xb2, 0x48, 0x94, 0xe1, 0x6d, 0xe9, 0x5c)
val testAddress = 0x10.toByte()
testPumpStateStore.createPumpState(
testBluetoothAddress,
InvariantPumpData(
clientPumpCipher = Cipher(testDecryptedCPKey.toByteArray()),
pumpClientCipher = Cipher(testDecryptedPCKey.toByteArray()),
keyResponseAddress = testAddress,
pumpID = "testPump"
),
UtcOffset.ZERO, CurrentTbrState.NoTbrOngoing
)
val errorResponsePacket = TransportLayer.Packet(
command = TransportLayer.Command.ERROR_RESPONSE,
version = 0x10.toByte(),
sequenceBit = false,
reliabilityBit = false,
address = 0x01.toByte(),
nonce = Nonce(byteArrayListOfInts(0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(0x0F)
)
errorResponsePacket.authenticate(Cipher(testDecryptedPCKey.toByteArray()))
// Start IO, and "receive" the error response packet (which
// normally would be sent by the Combo to the client) by feeding
// it into the test IO object. Since this packet contains an
// error report by the simulated Combo, an exception is thrown
// in the packet receiver coroutine in the IO class.
tpLayerIO.start(packetReceiverScope = this) { TransportLayer.IO.ReceiverBehavior.FORWARD_PACKET }
testComboIO.feedIncomingData(errorResponsePacket.toByteList())
// Wait until an exception is thrown in the packet receiver
// and we get notified about it.
waitingForExceptionJob.join()
System.err.println(
"Exception thrown by in packet receiver (this exception was expected by the test): $expectedError"
)
assertNotNull(expectedError)
assertIs<TransportLayer.ErrorResponseException>(expectedError!!.cause)
// At this point, the packet receiver is not running anymore
// due to the exception. Attempts at sending and receiving
// must fail and throw the exception that caused the failure
// in the packet receiver. This allows for propagating the
// error in a POSIX-esque style, where return codes inform
// about a failure that previously happened.
val exceptionThrownBySendCall = assertFailsWith<TransportLayer.PacketReceiverException> {
// The actual packet does not matter here. We just
// use createRequestPairingConnectionPacketInfo() to
// be able to use send(). Might as well use any
// other create*PacketInfo function.
tpLayerIO.send(TransportLayer.createRequestPairingConnectionPacketInfo())
}
System.err.println(
"Exception thrown by send() call (this exception was expected by the test): $exceptionThrownBySendCall"
)
assertIs<TransportLayer.ErrorResponseException>(exceptionThrownBySendCall.cause)
val exceptionThrownByReceiveCall = assertFailsWith<TransportLayer.PacketReceiverException> {
tpLayerIO.receive()
}
System.err.println(
"Exception thrown by receive() call (this exception was expected by the test): $exceptionThrownByReceiveCall"
)
assertIs<TransportLayer.ErrorResponseException>(exceptionThrownByReceiveCall.cause)
tpLayerIO.stop()
}
}
@Test
fun checkCustomIncomingPacketFiltering() {
// Test the custom incoming packet processing feature and
// its ability to drop packets. We simulate 3 incoming packets,
// one of which is a DATA packet. This one we want to drop
// before it ever reaches a receive() call. Consequently, we
// expect only 2 packets to ever reach receive(), while a third
// attempt at receiving should cause that third call to be
// suspended indefinitely. Also, we expect our tpLayerIO.start()
// callback to see all 3 packets. We count the number of DATA
// packets observed to confirm that the expected single DATA
// paket is in fact received by the TransportLayer IO.
runBlockingWithWatchdog(6000) {
// Setup.
val testPumpStateStore = TestPumpStateStore()
val testBluetoothAddress = BluetoothAddress(byteArrayListOfInts(1, 2, 3, 4, 5, 6))
val testComboIO = TestComboIO()
val tpLayerIO = TransportLayer.IO(testPumpStateStore, testBluetoothAddress, testComboIO) {}
val testDecryptedCPKey =
byteArrayListOfInts(0x5a, 0x25, 0x0b, 0x75, 0xa9, 0x02, 0x21, 0xfa, 0xab, 0xbd, 0x36, 0x4d, 0x5c, 0xb8, 0x37, 0xd7)
val testDecryptedPCKey =
byteArrayListOfInts(0x2a, 0xb0, 0xf2, 0x67, 0xc2, 0x7d, 0xcf, 0xaa, 0x32, 0xb2, 0x48, 0x94, 0xe1, 0x6d, 0xe9, 0x5c)
val testAddress = 0x10.toByte()
testPumpStateStore.createPumpState(
testBluetoothAddress,
InvariantPumpData(
clientPumpCipher = Cipher(testDecryptedCPKey.toByteArray()),
pumpClientCipher = Cipher(testDecryptedPCKey.toByteArray()),
keyResponseAddress = testAddress,
pumpID = "testPump"
),
UtcOffset.ZERO, CurrentTbrState.NoTbrOngoing
)
// The packets that our simulated Combo transmits to our client.
val customDataPackets = listOf(
TransportLayer.Packet(
command = TransportLayer.Command.REGULAR_CONNECTION_REQUEST_ACCEPTED,
version = 0x10.toByte(),
sequenceBit = false,
reliabilityBit = false,
address = 0x01.toByte(),
nonce = Nonce(byteArrayListOfInts(0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(0, 1, 2, 3)
),
TransportLayer.Packet(
command = TransportLayer.Command.DATA,
version = 0x10.toByte(),
sequenceBit = false,
reliabilityBit = false,
address = 0x01.toByte(),
nonce = Nonce(byteArrayListOfInts(0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(1, 2, 3)
),
TransportLayer.Packet(
command = TransportLayer.Command.REGULAR_CONNECTION_REQUEST_ACCEPTED,
version = 0x10.toByte(),
sequenceBit = false,
reliabilityBit = false,
address = 0x01.toByte(),
nonce = Nonce(byteArrayListOfInts(0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
payload = byteArrayListOfInts(0, 1, 2, 3)
)
)
customDataPackets.forEach {
it.authenticate(Cipher(testDecryptedPCKey.toByteArray()))
}
var numReceivedDataPackets = 0
tpLayerIO.start(packetReceiverScope = this) { tpLayerPacket ->
if (tpLayerPacket.command == TransportLayer.Command.DATA) {
numReceivedDataPackets++
TransportLayer.IO.ReceiverBehavior.DROP_PACKET
} else
TransportLayer.IO.ReceiverBehavior.FORWARD_PACKET
}
customDataPackets.forEach {
testComboIO.feedIncomingData(it.toByteList())
}
// Check that we received 2 non-DATA packets.
for (i in 0 until 2) {
val tpLayerPacket = tpLayerIO.receive()
assertNotEquals(TransportLayer.Command.DATA, tpLayerPacket.command)
}
// An attempt at receiving another packet should never
// finish, since any packet other than the 2 non-DATA
// ones must have been filtered out.
assertFailsWith<WatchdogTimeoutException> {
coroutineScopeWithWatchdog(1000) {
tpLayerIO.receive()
}
}
tpLayerIO.stop()
assertEquals(1, numReceivedDataPackets)
}
}
}

View file

@ -0,0 +1,333 @@
package info.nightscout.comboctl.base
import kotlin.test.Test
import kotlin.test.assertEquals
class TwofishTest {
// The test datasets in the unit tests below originate from the
// Two-Fish known answers test dataset (the twofish-kat.zip
// archive). It can be downloaded from:
// https://www.schneier.com/academic/twofish/
@Test
fun checkProcessedSubkeys() {
// From the ecb_ival.txt test file in the twofish-kat.zip
// Two-Fish known answers test dataset. 128-bit keysize.
val key = byteArrayOfInts(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
val expectedSubKeys = intArrayOf(
0x52C54DDE.toInt(), 0x11F0626D.toInt(), // Input whiten
0x7CAC9D4A.toInt(), 0x4D1B4AAA.toInt(),
0xB7B83A10.toInt(), 0x1E7D0BEB.toInt(), // Output whiten
0xEE9C341F.toInt(), 0xCFE14BE4.toInt(),
0xF98FFEF9.toInt(), 0x9C5B3C17.toInt(), // Round subkeys
0x15A48310.toInt(), 0x342A4D81.toInt(),
0x424D89FE.toInt(), 0xC14724A7.toInt(),
0x311B834C.toInt(), 0xFDE87320.toInt(),
0x3302778F.toInt(), 0x26CD67B4.toInt(),
0x7A6C6362.toInt(), 0xC2BAF60E.toInt(),
0x3411B994.toInt(), 0xD972C87F.toInt(),
0x84ADB1EA.toInt(), 0xA7DEE434.toInt(),
0x54D2960F.toInt(), 0xA2F7CAA8.toInt(),
0xA6B8FF8C.toInt(), 0x8014C425.toInt(),
0x6A748D1C.toInt(), 0xEDBAF720.toInt(),
0x928EF78C.toInt(), 0x0338EE13.toInt(),
0x9949D6BE.toInt(), 0xC8314176.toInt(),
0x07C07D68.toInt(), 0xECAE7EA7.toInt(),
0x1FE71844.toInt(), 0x85C05C89.toInt(),
0xF298311E.toInt(), 0x696EA672.toInt()
)
val keyObject = Twofish.processKey(key)
assertEquals(expectedSubKeys.toList(), keyObject.subKeys.toList())
}
@Test
fun checkPermutationTablesAndMDSMatrixMultiplyTables() {
// From the ecb_tbl.txt test file in the twofish-kat.zip
// Two-Fish known answers test dataset. 128-bit keysize.
class TestVector(key: String, plaintext: String, ciphertext: String) {
val keyArray: ByteArray
val plaintextArray: ByteArray
val ciphertextArray: ByteArray
init {
keyArray = hexstringToByteArray(key)
plaintextArray = hexstringToByteArray(plaintext)
ciphertextArray = hexstringToByteArray(ciphertext)
}
private fun hexstringToByteArray(hexstring: String): ByteArray {
val array = ByteArray(hexstring.length / 2)
for (i in array.indices) {
val hexcharStr = hexstring.substring(IntRange(i * 2, i * 2 + 1))
array[i] = Integer.parseInt(hexcharStr, 16).toByte()
}
return array
}
}
val testVectors = arrayOf(
TestVector(
key = "00000000000000000000000000000000",
plaintext = "00000000000000000000000000000000",
ciphertext = "9F589F5CF6122C32B6BFEC2F2AE8C35A"
),
TestVector(
key = "00000000000000000000000000000000",
plaintext = "9F589F5CF6122C32B6BFEC2F2AE8C35A",
ciphertext = "D491DB16E7B1C39E86CB086B789F5419"
),
TestVector(
key = "9F589F5CF6122C32B6BFEC2F2AE8C35A",
plaintext = "D491DB16E7B1C39E86CB086B789F5419",
ciphertext = "019F9809DE1711858FAAC3A3BA20FBC3"
),
TestVector(
key = "D491DB16E7B1C39E86CB086B789F5419",
plaintext = "019F9809DE1711858FAAC3A3BA20FBC3",
ciphertext = "6363977DE839486297E661C6C9D668EB"
),
TestVector(
key = "019F9809DE1711858FAAC3A3BA20FBC3",
plaintext = "6363977DE839486297E661C6C9D668EB",
ciphertext = "816D5BD0FAE35342BF2A7412C246F752"
),
TestVector(
key = "6363977DE839486297E661C6C9D668EB",
plaintext = "816D5BD0FAE35342BF2A7412C246F752",
ciphertext = "5449ECA008FF5921155F598AF4CED4D0"
),
TestVector(
key = "816D5BD0FAE35342BF2A7412C246F752",
plaintext = "5449ECA008FF5921155F598AF4CED4D0",
ciphertext = "6600522E97AEB3094ED5F92AFCBCDD10"
),
TestVector(
key = "5449ECA008FF5921155F598AF4CED4D0",
plaintext = "6600522E97AEB3094ED5F92AFCBCDD10",
ciphertext = "34C8A5FB2D3D08A170D120AC6D26DBFA"
),
TestVector(
key = "6600522E97AEB3094ED5F92AFCBCDD10",
plaintext = "34C8A5FB2D3D08A170D120AC6D26DBFA",
ciphertext = "28530B358C1B42EF277DE6D4407FC591"
),
TestVector(
key = "34C8A5FB2D3D08A170D120AC6D26DBFA",
plaintext = "28530B358C1B42EF277DE6D4407FC591",
ciphertext = "8A8AB983310ED78C8C0ECDE030B8DCA4"
),
TestVector(
key = "28530B358C1B42EF277DE6D4407FC591",
plaintext = "8A8AB983310ED78C8C0ECDE030B8DCA4",
ciphertext = "48C758A6DFC1DD8B259FA165E1CE2B3C"
),
TestVector(
key = "8A8AB983310ED78C8C0ECDE030B8DCA4",
plaintext = "48C758A6DFC1DD8B259FA165E1CE2B3C",
ciphertext = "CE73C65C101680BBC251C5C16ABCF214"
),
TestVector(
key = "48C758A6DFC1DD8B259FA165E1CE2B3C",
plaintext = "CE73C65C101680BBC251C5C16ABCF214",
ciphertext = "C7ABD74AA060F78B244E24C71342BA89"
),
TestVector(
key = "CE73C65C101680BBC251C5C16ABCF214",
plaintext = "C7ABD74AA060F78B244E24C71342BA89",
ciphertext = "D0F8B3B6409EBCB666D29C916565ABFC"
),
TestVector(
key = "C7ABD74AA060F78B244E24C71342BA89",
plaintext = "D0F8B3B6409EBCB666D29C916565ABFC",
ciphertext = "DD42662908070054544FE09DA4263130"
),
TestVector(
key = "D0F8B3B6409EBCB666D29C916565ABFC",
plaintext = "DD42662908070054544FE09DA4263130",
ciphertext = "7007BACB42F7BF989CF30F78BC50EDCA"
),
TestVector(
key = "DD42662908070054544FE09DA4263130",
plaintext = "7007BACB42F7BF989CF30F78BC50EDCA",
ciphertext = "57B9A18EE97D90F435A16F69F0AC6F16"
),
TestVector(
key = "7007BACB42F7BF989CF30F78BC50EDCA",
plaintext = "57B9A18EE97D90F435A16F69F0AC6F16",
ciphertext = "06181F0D53267ABD8F3BB28455B198AD"
),
TestVector(
key = "57B9A18EE97D90F435A16F69F0AC6F16",
plaintext = "06181F0D53267ABD8F3BB28455B198AD",
ciphertext = "81A12D8449E9040BAAE7196338D8C8F2"
),
TestVector(
key = "06181F0D53267ABD8F3BB28455B198AD",
plaintext = "81A12D8449E9040BAAE7196338D8C8F2",
ciphertext = "BE422651C56F2622DA0201815A95A820"
),
TestVector(
key = "81A12D8449E9040BAAE7196338D8C8F2",
plaintext = "BE422651C56F2622DA0201815A95A820",
ciphertext = "113B19F2D778473990480CEE4DA238D1"
),
TestVector(
key = "BE422651C56F2622DA0201815A95A820",
plaintext = "113B19F2D778473990480CEE4DA238D1",
ciphertext = "E6942E9A86E544CF3E3364F20BE011DF"
),
TestVector(
key = "113B19F2D778473990480CEE4DA238D1",
plaintext = "E6942E9A86E544CF3E3364F20BE011DF",
ciphertext = "87CDC6AA487BFD0EA70188257D9B3859"
),
TestVector(
key = "E6942E9A86E544CF3E3364F20BE011DF",
plaintext = "87CDC6AA487BFD0EA70188257D9B3859",
ciphertext = "D5E2701253DD75A11A4CFB243714BD14"
),
TestVector(
key = "87CDC6AA487BFD0EA70188257D9B3859",
plaintext = "D5E2701253DD75A11A4CFB243714BD14",
ciphertext = "FD24812EEA107A9E6FAB8EABE0F0F48C"
),
TestVector(
key = "D5E2701253DD75A11A4CFB243714BD14",
plaintext = "FD24812EEA107A9E6FAB8EABE0F0F48C",
ciphertext = "DAFA84E31A297F372C3A807100CD783D"
),
TestVector(
key = "FD24812EEA107A9E6FAB8EABE0F0F48C",
plaintext = "DAFA84E31A297F372C3A807100CD783D",
ciphertext = "A55ED2D955EC8950FC0CC93B76ACBF91"
),
TestVector(
key = "DAFA84E31A297F372C3A807100CD783D",
plaintext = "A55ED2D955EC8950FC0CC93B76ACBF91",
ciphertext = "2ABEA2A4BF27ABDC6B6F278993264744"
),
TestVector(
key = "A55ED2D955EC8950FC0CC93B76ACBF91",
plaintext = "2ABEA2A4BF27ABDC6B6F278993264744",
ciphertext = "045383E219321D5A4435C0E491E7DE10"
),
TestVector(
key = "2ABEA2A4BF27ABDC6B6F278993264744",
plaintext = "045383E219321D5A4435C0E491E7DE10",
ciphertext = "7460A4CD4F312F32B1C7A94FA004E934"
),
TestVector(
key = "045383E219321D5A4435C0E491E7DE10",
plaintext = "7460A4CD4F312F32B1C7A94FA004E934",
ciphertext = "6BBF9186D32C2C5895649D746566050A"
),
TestVector(
key = "7460A4CD4F312F32B1C7A94FA004E934",
plaintext = "6BBF9186D32C2C5895649D746566050A",
ciphertext = "CDBDD19ACF40B8AC0328C80054266068"
),
TestVector(
key = "6BBF9186D32C2C5895649D746566050A",
plaintext = "CDBDD19ACF40B8AC0328C80054266068",
ciphertext = "1D2836CAE4223EAB5066867A71B1A1C3"
),
TestVector(
key = "CDBDD19ACF40B8AC0328C80054266068",
plaintext = "1D2836CAE4223EAB5066867A71B1A1C3",
ciphertext = "2D7F37121D0D2416D5E2767FF202061B"
),
TestVector(
key = "1D2836CAE4223EAB5066867A71B1A1C3",
plaintext = "2D7F37121D0D2416D5E2767FF202061B",
ciphertext = "D70736D1ABC7427A121CC816CD66D7FF"
),
TestVector(
key = "2D7F37121D0D2416D5E2767FF202061B",
plaintext = "D70736D1ABC7427A121CC816CD66D7FF",
ciphertext = "AC6CA71CBCBEDCC0EA849FB2E9377865"
),
TestVector(
key = "D70736D1ABC7427A121CC816CD66D7FF",
plaintext = "AC6CA71CBCBEDCC0EA849FB2E9377865",
ciphertext = "307265FF145CBBC7104B3E51C6C1D6B4"
),
TestVector(
key = "AC6CA71CBCBEDCC0EA849FB2E9377865",
plaintext = "307265FF145CBBC7104B3E51C6C1D6B4",
ciphertext = "934B7DB4B3544854DBCA81C4C5DE4EB1"
),
TestVector(
key = "307265FF145CBBC7104B3E51C6C1D6B4",
plaintext = "934B7DB4B3544854DBCA81C4C5DE4EB1",
ciphertext = "18759824AD9823D5961F84377D7EAEBF"
),
TestVector(
key = "934B7DB4B3544854DBCA81C4C5DE4EB1",
plaintext = "18759824AD9823D5961F84377D7EAEBF",
ciphertext = "DEDDAC6029B01574D9BABB099DC6CA6C"
),
TestVector(
key = "18759824AD9823D5961F84377D7EAEBF",
plaintext = "DEDDAC6029B01574D9BABB099DC6CA6C",
ciphertext = "5EA82EEA2244DED42CCA2F835D5615DF"
),
TestVector(
key = "DEDDAC6029B01574D9BABB099DC6CA6C",
plaintext = "5EA82EEA2244DED42CCA2F835D5615DF",
ciphertext = "1E3853F7FFA57091771DD8CDEE9414DE"
),
TestVector(
key = "5EA82EEA2244DED42CCA2F835D5615DF",
plaintext = "1E3853F7FFA57091771DD8CDEE9414DE",
ciphertext = "5C2EBBF75D31F30B5EA26EAC8782D8D1"
),
TestVector(
key = "1E3853F7FFA57091771DD8CDEE9414DE",
plaintext = "5C2EBBF75D31F30B5EA26EAC8782D8D1",
ciphertext = "3A3CFA1F13A136C94D76E5FA4A1109FF"
),
TestVector(
key = "5C2EBBF75D31F30B5EA26EAC8782D8D1",
plaintext = "3A3CFA1F13A136C94D76E5FA4A1109FF",
ciphertext = "91630CF96003B8032E695797E313A553"
),
TestVector(
key = "3A3CFA1F13A136C94D76E5FA4A1109FF",
plaintext = "91630CF96003B8032E695797E313A553",
ciphertext = "137A24CA47CD12BE818DF4D2F4355960"
),
TestVector(
key = "91630CF96003B8032E695797E313A553",
plaintext = "137A24CA47CD12BE818DF4D2F4355960",
ciphertext = "BCA724A54533C6987E14AA827952F921"
),
TestVector(
key = "137A24CA47CD12BE818DF4D2F4355960",
plaintext = "BCA724A54533C6987E14AA827952F921",
ciphertext = "6B459286F3FFD28D49F15B1581B08E42"
),
TestVector(
key = "BCA724A54533C6987E14AA827952F921",
plaintext = "6B459286F3FFD28D49F15B1581B08E42",
ciphertext = "5D9D4EEFFA9151575524F115815A12E0"
)
)
for (testVector in testVectors) {
val keyObject = Twofish.processKey(testVector.keyArray)
val computedCiphertext = Twofish.blockEncrypt(testVector.plaintextArray, 0, keyObject)
assertEquals(testVector.ciphertextArray.toList(), computedCiphertext.toList())
val computedPlaintext = Twofish.blockDecrypt(testVector.ciphertextArray, 0, keyObject)
assertEquals(testVector.plaintextArray.toList(), computedPlaintext.toList())
}
}
}

View file

@ -0,0 +1,58 @@
package info.nightscout.comboctl.base.testUtils
import info.nightscout.comboctl.base.BluetoothAddress
import info.nightscout.comboctl.base.BluetoothDevice
import info.nightscout.comboctl.base.ComboFrameParser
import info.nightscout.comboctl.base.ComboIO
import info.nightscout.comboctl.base.ProgressReporter
import info.nightscout.comboctl.base.byteArrayListOfInts
import info.nightscout.comboctl.base.toComboFrame
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.runBlocking
class TestBluetoothDevice(private val testComboIO: ComboIO) : BluetoothDevice(Dispatchers.IO) {
private val frameParser = ComboFrameParser()
private var innerJob = SupervisorJob()
private var innerScope = CoroutineScope(innerJob)
override val address: BluetoothAddress = BluetoothAddress(byteArrayListOfInts(1, 2, 3, 4, 5, 6))
override fun connect() {
}
override fun disconnect() {
frameParser.reset()
runBlocking {
innerJob.cancelAndJoin()
}
// Reinitialize these, since once a Job is cancelled, it cannot be reused again.
innerJob = SupervisorJob()
innerScope = CoroutineScope(innerJob)
}
override fun unpair() {
}
override fun blockingSend(dataToSend: List<Byte>) {
frameParser.pushData(dataToSend)
frameParser.parseFrame()?.let {
runBlocking {
innerScope.async {
testComboIO.send(it)
}.await()
}
}
}
override fun blockingReceive(): List<Byte> = runBlocking {
innerScope.async {
val retval = testComboIO.receive().toComboFrame()
retval
}.await()
}
}

View file

@ -0,0 +1,60 @@
package info.nightscout.comboctl.base.testUtils
import info.nightscout.comboctl.base.ApplicationLayer
import info.nightscout.comboctl.base.Cipher
import info.nightscout.comboctl.base.ComboIO
import info.nightscout.comboctl.base.TransportLayer
import info.nightscout.comboctl.base.byteArrayListOfInts
import info.nightscout.comboctl.base.toTransportLayerPacket
import kotlin.test.assertNotNull
import kotlinx.coroutines.channels.Channel
class TestComboIO : ComboIO {
val sentPacketData = newTestPacketSequence()
var incomingPacketDataChannel = Channel<TestPacketData>(Channel.UNLIMITED)
var respondToRTKeypressWithConfirmation = false
var pumpClientCipher: Cipher? = null
override suspend fun send(dataToSend: TestPacketData) {
sentPacketData.add(dataToSend)
if (respondToRTKeypressWithConfirmation) {
assertNotNull(pumpClientCipher)
val tpLayerPacket = dataToSend.toTransportLayerPacket()
if (tpLayerPacket.command == TransportLayer.Command.DATA) {
try {
// Not using toAppLayerPacket() here, since that one
// performs error checks, which are only useful for
// application layer packets that we _received_.
val appLayerPacket = ApplicationLayer.Packet(tpLayerPacket)
if (appLayerPacket.command == ApplicationLayer.Command.RT_BUTTON_STATUS) {
feedIncomingData(
produceTpLayerPacket(
ApplicationLayer.Packet(
command = ApplicationLayer.Command.RT_BUTTON_CONFIRMATION,
payload = byteArrayListOfInts(0, 0)
).toTransportLayerPacketInfo(),
pumpClientCipher!!
).toByteList()
)
}
} catch (ignored: ApplicationLayer.ErrorCodeException) {
}
}
}
}
override suspend fun receive(): TestPacketData =
incomingPacketDataChannel.receive()
suspend fun feedIncomingData(dataToFeed: TestPacketData) =
incomingPacketDataChannel.send(dataToFeed)
fun resetSentPacketData() = sentPacketData.clear()
fun resetIncomingPacketDataChannel() {
incomingPacketDataChannel.close()
incomingPacketDataChannel = Channel(Channel.UNLIMITED)
}
}

View file

@ -0,0 +1,38 @@
package info.nightscout.comboctl.base.testUtils
import info.nightscout.comboctl.base.ApplicationLayer
import info.nightscout.comboctl.base.TransportLayer
import info.nightscout.comboctl.base.toTransportLayerPacket
import kotlin.test.assertEquals
import kotlin.test.assertTrue
typealias TestPacketData = List<Byte>
fun newTestPacketSequence() = mutableListOf<TestPacketData>()
sealed class TestRefPacketItem {
data class TransportLayerPacketItem(val packetInfo: TransportLayer.OutgoingPacketInfo) : TestRefPacketItem()
data class ApplicationLayerPacketItem(val packet: ApplicationLayer.Packet) : TestRefPacketItem()
}
fun checkTestPacketSequence(referenceSequence: List<TestRefPacketItem>, testPacketSequence: List<TestPacketData>) {
assertTrue(testPacketSequence.size >= referenceSequence.size)
referenceSequence.zip(testPacketSequence) { referenceItem, tpLayerPacketData ->
val testTpLayerPacket = tpLayerPacketData.toTransportLayerPacket()
when (referenceItem) {
is TestRefPacketItem.TransportLayerPacketItem -> {
val refPacketInfo = referenceItem.packetInfo
assertEquals(refPacketInfo.command, testTpLayerPacket.command, "Transport layer packet command mismatch")
assertEquals(refPacketInfo.payload, testTpLayerPacket.payload, "Transport layer packet payload mismatch")
assertEquals(refPacketInfo.reliable, testTpLayerPacket.reliabilityBit, "Transport layer packet reliability bit mismatch")
}
is TestRefPacketItem.ApplicationLayerPacketItem -> {
val refAppLayerPacket = referenceItem.packet
val testAppLayerPacket = ApplicationLayer.Packet(testTpLayerPacket)
assertEquals(refAppLayerPacket, testAppLayerPacket)
}
}
}
}

View file

@ -0,0 +1,90 @@
package info.nightscout.comboctl.base.testUtils
import info.nightscout.comboctl.base.BluetoothAddress
import info.nightscout.comboctl.base.CurrentTbrState
import info.nightscout.comboctl.base.InvariantPumpData
import info.nightscout.comboctl.base.NUM_NONCE_BYTES
import info.nightscout.comboctl.base.Nonce
import info.nightscout.comboctl.base.PumpStateAlreadyExistsException
import info.nightscout.comboctl.base.PumpStateDoesNotExistException
import info.nightscout.comboctl.base.PumpStateStore
import kotlinx.datetime.UtcOffset
class TestPumpStateStore : PumpStateStore {
data class Entry(
val invariantPumpData: InvariantPumpData,
var currentTxNonce: Nonce,
var currentUtcOffset: UtcOffset,
var currentTbrState: CurrentTbrState
)
var states = mutableMapOf<BluetoothAddress, Entry>()
private set
override fun createPumpState(
pumpAddress: BluetoothAddress,
invariantPumpData: InvariantPumpData,
utcOffset: UtcOffset,
tbrState: CurrentTbrState
) {
if (states.contains(pumpAddress))
throw PumpStateAlreadyExistsException(pumpAddress)
states[pumpAddress] = Entry(invariantPumpData, Nonce(List(NUM_NONCE_BYTES) { 0x00 }), utcOffset, tbrState)
}
override fun deletePumpState(pumpAddress: BluetoothAddress) =
if (states.contains(pumpAddress)) {
states.remove(pumpAddress)
true
} else {
false
}
override fun hasPumpState(pumpAddress: BluetoothAddress): Boolean =
states.contains(pumpAddress)
override fun getAvailablePumpStateAddresses(): Set<BluetoothAddress> = states.keys
override fun getInvariantPumpData(pumpAddress: BluetoothAddress): InvariantPumpData {
if (!states.contains(pumpAddress))
throw PumpStateDoesNotExistException(pumpAddress)
return states[pumpAddress]!!.invariantPumpData
}
override fun getCurrentTxNonce(pumpAddress: BluetoothAddress): Nonce {
if (!states.contains(pumpAddress))
throw PumpStateDoesNotExistException(pumpAddress)
return states[pumpAddress]!!.currentTxNonce
}
override fun setCurrentTxNonce(pumpAddress: BluetoothAddress, currentTxNonce: Nonce) {
if (!states.contains(pumpAddress))
throw PumpStateDoesNotExistException(pumpAddress)
states[pumpAddress]!!.currentTxNonce = currentTxNonce
}
override fun getCurrentUtcOffset(pumpAddress: BluetoothAddress): UtcOffset {
if (!states.contains(pumpAddress))
throw PumpStateDoesNotExistException(pumpAddress)
return states[pumpAddress]!!.currentUtcOffset
}
override fun setCurrentUtcOffset(pumpAddress: BluetoothAddress, utcOffset: UtcOffset) {
if (!states.contains(pumpAddress))
throw PumpStateDoesNotExistException(pumpAddress)
states[pumpAddress]!!.currentUtcOffset = utcOffset
}
override fun getCurrentTbrState(pumpAddress: BluetoothAddress): CurrentTbrState {
if (!states.contains(pumpAddress))
throw PumpStateDoesNotExistException(pumpAddress)
return states[pumpAddress]!!.currentTbrState
}
override fun setCurrentTbrState(pumpAddress: BluetoothAddress, currentTbrState: CurrentTbrState) {
if (!states.contains(pumpAddress))
throw PumpStateDoesNotExistException(pumpAddress)
states[pumpAddress]!!.currentTbrState = currentTbrState
}
}

View file

@ -0,0 +1,98 @@
package info.nightscout.comboctl.base.testUtils
import info.nightscout.comboctl.base.Cipher
import info.nightscout.comboctl.base.ComboException
import info.nightscout.comboctl.base.Nonce
import info.nightscout.comboctl.base.TransportLayer
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.test.fail
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
// Utility function to combine runBlocking() with a watchdog.
// A coroutine is started with runBlocking(), and inside that
// coroutine, sub-coroutines are spawned. One of them runs
// the supplied block, the other implements a watchdog by
// waiting with delay(). If delay() runs out, the watchdog
// is considered to have timed out, and failure is reported.
// The watchdog is disabled after the supplied block finished
// running. That way, if something in that block suspends
// coroutines indefinitely, the watchdog will make sure that
// the test does not hang permanently.
fun runBlockingWithWatchdog(
timeout: Long,
context: CoroutineContext = EmptyCoroutineContext,
block: suspend CoroutineScope.() -> Unit
) {
runBlocking(context) {
val watchdogJob = launch {
delay(timeout)
fail("Test run timeout reached")
}
launch {
try {
// Call the block with the current CoroutineScope
// as the receiver to allow code inside that block
// to access the CoroutineScope via the "this" value.
// This is important, otherwise test code cannot
// launch coroutines easily.
this.block()
} finally {
// Disabling the watchdog here makes sure
// that it is disabled no matter if the block
// finishes regularly or due to an exception.
watchdogJob.cancel()
}
}
}
}
class WatchdogTimeoutException(message: String) : ComboException(message)
suspend fun coroutineScopeWithWatchdog(
timeout: Long,
block: suspend CoroutineScope.() -> Unit
) {
coroutineScope {
val watchdogJob = launch {
delay(timeout)
throw WatchdogTimeoutException("Test run timeout reached")
}
launch {
try {
// Call the block with the current CoroutineScope
// as the receiver to allow code inside that block
// to access the CoroutineScope via the "this" value.
// This is important, otherwise test code cannot
// launch coroutines easily.
this.block()
} finally {
// Disabling the watchdog here makes sure
// that it is disabled no matter if the block
// finishes regularly or due to an exception.
watchdogJob.cancel()
}
}
}
}
fun produceTpLayerPacket(outgoingPacketInfo: TransportLayer.OutgoingPacketInfo, cipher: Cipher): TransportLayer.Packet {
val packet = TransportLayer.Packet(
command = outgoingPacketInfo.command,
sequenceBit = false,
reliabilityBit = false,
address = 0x01,
nonce = Nonce.nullNonce(),
payload = outgoingPacketInfo.payload
)
packet.authenticate(cipher)
return packet
}

View file

@ -0,0 +1,369 @@
package info.nightscout.comboctl.main
import info.nightscout.comboctl.base.DisplayFrame
import info.nightscout.comboctl.base.LogLevel
import info.nightscout.comboctl.base.Logger
import info.nightscout.comboctl.base.NUM_DISPLAY_FRAME_PIXELS
import info.nightscout.comboctl.base.timeWithoutDate
import info.nightscout.comboctl.parser.AlertScreenContent
import info.nightscout.comboctl.parser.AlertScreenException
import info.nightscout.comboctl.parser.BatteryState
import info.nightscout.comboctl.parser.MainScreenContent
import info.nightscout.comboctl.parser.ParsedScreen
import info.nightscout.comboctl.parser.testFrameMainScreenWithTimeSeparator
import info.nightscout.comboctl.parser.testFrameMainScreenWithoutTimeSeparator
import info.nightscout.comboctl.parser.testFrameStandardBolusMenuScreen
import info.nightscout.comboctl.parser.testFrameTbrDurationEnglishScreen
import info.nightscout.comboctl.parser.testFrameTemporaryBasalRateNoPercentageScreen
import info.nightscout.comboctl.parser.testFrameTemporaryBasalRatePercentage110Screen
import info.nightscout.comboctl.parser.testFrameW6CancelTbrWarningScreen
import info.nightscout.comboctl.parser.testTimeAndDateSettingsHourPolishScreen
import info.nightscout.comboctl.parser.testTimeAndDateSettingsHourRussianScreen
import info.nightscout.comboctl.parser.testTimeAndDateSettingsHourTurkishScreen
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.BeforeAll
class ParsedDisplayFrameStreamTest {
companion object {
@BeforeAll
@JvmStatic
fun commonInit() {
Logger.threshold = LogLevel.VERBOSE
}
}
@Test
fun checkSingleDisplayFrame() = runBlocking {
/* Check if a frame is correctly recognized. */
val stream = ParsedDisplayFrameStream()
stream.feedDisplayFrame(testFrameStandardBolusMenuScreen)
val parsedFrame = stream.getParsedDisplayFrame()
assertNotNull(parsedFrame)
assertEquals(ParsedScreen.StandardBolusMenuScreen, parsedFrame.parsedScreen)
}
@Test
fun checkNullDisplayFrame() = runBlocking {
/* Check if a null frame is handled correctly. */
val stream = ParsedDisplayFrameStream()
stream.feedDisplayFrame(null)
val parsedFrame = stream.getParsedDisplayFrame()
assertNull(parsedFrame)
}
@Test
fun checkDuplicateDisplayFrameFiltering() = runBlocking {
// Test the duplicate detection by feeding the stream test frames
// along with unrecognizable ones. We feed duplicates, both recognizable
// and unrecognizable ones, to check that the stream filters these out.
val unrecognizableDisplayFrame1A = DisplayFrame(BooleanArray(NUM_DISPLAY_FRAME_PIXELS) { false })
val unrecognizableDisplayFrame1B = DisplayFrame(BooleanArray(NUM_DISPLAY_FRAME_PIXELS) { false })
val unrecognizableDisplayFrame2 = DisplayFrame(BooleanArray(NUM_DISPLAY_FRAME_PIXELS) { true })
val displayFrameList = listOf(
// We use these two frames to test out the filtering
// of duplicate frames. These two frame _are_ equal.
// The frames just differ in the time separator, but
// both result in ParsedScreen.NormalMainScreen instances
// with the same semantics (same time etc). We expect the
// stream to recognize and filter out the duplicate.
testFrameMainScreenWithTimeSeparator,
testFrameMainScreenWithoutTimeSeparator,
// 1A and 1B are two different unrecognizable DisplayFrame
// instances with equal pixel content to test the filtering
// of duplicate frames when said frames are _not_ recognizable
// by the parser. The stream should then compare the frames
// pixel by pixel.
unrecognizableDisplayFrame1A,
unrecognizableDisplayFrame1B,
// Frame 2 is an unrecognizable DisplayFrame whose pixels
// are different than the ones in frames 1A and 1B. We
// expect the stream to do a pixel-by-pixel comparison between
// the unrecognizable frames and detect that frame 2 is
// really different (= not a duplicate).
unrecognizableDisplayFrame2,
// A recognizable frame to test the case that a recognizable
// frame follows an unrecognizable one.
testFrameStandardBolusMenuScreen
)
val parsedFrameList = mutableListOf<ParsedDisplayFrame>()
val stream = ParsedDisplayFrameStream()
coroutineScope {
val producerJob = launch {
for (displayFrame in displayFrameList) {
// Wait here until the frame has been retrieved, since otherwise,
// the feedDisplayFrame() call below would overwrite the already
// stored frame.
while (stream.hasStoredDisplayFrame())
delay(100)
stream.feedDisplayFrame(displayFrame)
}
}
launch {
while (true) {
val parsedFrame = stream.getParsedDisplayFrame(filterDuplicates = true)
assertNotNull(parsedFrame)
parsedFrameList.add(parsedFrame)
if (parsedFrameList.size >= 4)
break
}
producerJob.cancel()
}
}
val parsedFrameIter = parsedFrameList.listIterator()
// We expect _one_ ParsedScreen.NormalMainScreen
// (the other frame with the equal content must be filtered out).
assertEquals(
ParsedScreen.MainScreen(
MainScreenContent.Normal(
currentTime = timeWithoutDate(hour = 10, minute = 20),
activeBasalProfileNumber = 1,
currentBasalRateFactor = 200,
batteryState = BatteryState.FULL_BATTERY
)
),
parsedFrameIter.next().parsedScreen
)
// Next we expect an UnrecognizedScreen result after the change from NormalMainScreen
// to a frame (unrecognizableDisplayFrame1A) that could not be recognized.
assertEquals(
ParsedScreen.UnrecognizedScreen,
parsedFrameIter.next().parsedScreen
)
// We expect an UnrecognizedScreen result after switching to unrecognizableDisplayFrame2.
// This is an unrecognizable frame that differs in its pixels from
// unrecognizableDisplayFrame1A and 1B. Importantly, 1B must have been
// filtered out, since both 1A and 1B could not be recognized _and_ have
// equal pixel content.
assertEquals(
ParsedScreen.UnrecognizedScreen,
parsedFrameIter.next().parsedScreen
)
// Since unrecognizableDisplayFrame1B must have been filtered out,
// the next result we expect is the StandardBolusMenuScreen.
assertEquals(
ParsedScreen.StandardBolusMenuScreen,
parsedFrameIter.next().parsedScreen
)
}
@Test
fun checkDuplicateParsedScreenFiltering() = runBlocking {
// Test the duplicate parsed screen detection with 3 time and date hour settings screens.
// All three are parsed to ParsedScreen.TimeAndDateSettingsHourScreen instances.
// All three contain different pixels. (This is the crucial difference to the
// checkDuplicateDisplayFrameFiltering above.) However, the first 2 have their "hour"
// properties set to 13, while the third has "hour" set to 14. The stream is
// expected to filter the duplicate TimeAndDateSettingsHourScreen with the "13" hour.
val displayFrameList = listOf(
testTimeAndDateSettingsHourRussianScreen, // This screen frame has "1 PM" (= 13 in 24h format) as hour
testTimeAndDateSettingsHourTurkishScreen, // This screen frame has "1 PM" (= 13 in 24h format) as hour
testTimeAndDateSettingsHourPolishScreen // This screen frame has "2 PM" (= 13 in 24h format) as hour
)
val parsedFrameList = mutableListOf<ParsedDisplayFrame>()
val stream = ParsedDisplayFrameStream()
coroutineScope {
val producerJob = launch {
for (displayFrame in displayFrameList) {
// Wait here until the frame has been retrieved, since otherwise,
// the feedDisplayFrame() call below would overwrite the already
// stored frame.
while (stream.hasStoredDisplayFrame())
delay(100)
stream.feedDisplayFrame(displayFrame)
}
}
launch {
while (true) {
val parsedFrame = stream.getParsedDisplayFrame(filterDuplicates = true)
assertNotNull(parsedFrame)
parsedFrameList.add(parsedFrame)
if (parsedFrameList.size >= 2)
break
}
producerJob.cancel()
}
}
val parsedFrameIter = parsedFrameList.listIterator()
assertEquals(2, parsedFrameList.size)
assertEquals(ParsedScreen.TimeAndDateSettingsHourScreen(hour = 13), parsedFrameIter.next().parsedScreen)
assertEquals(ParsedScreen.TimeAndDateSettingsHourScreen(hour = 14), parsedFrameIter.next().parsedScreen)
}
@Test
fun checkDuplicateDetectionReset() = runBlocking {
// Test that resetting the duplicate detection works correctly.
// Two screens with equal content (both are a TimeAndDateSettingsHourScreen
// with the hour set to 13, or 1 PM). Duplicate detection would normally
// filter out the second one. The resetDuplicate() call should prevent this.
val displayFrameList = listOf(
testTimeAndDateSettingsHourRussianScreen,
testTimeAndDateSettingsHourTurkishScreen
)
val stream = ParsedDisplayFrameStream()
val parsedFrameList = mutableListOf<ParsedDisplayFrame>()
for (displayFrame in displayFrameList) {
stream.resetDuplicate()
stream.feedDisplayFrame(displayFrame)
val parsedFrame = stream.getParsedDisplayFrame()
assertNotNull(parsedFrame)
parsedFrameList.add(parsedFrame)
}
val parsedFrameIter = parsedFrameList.listIterator()
assertEquals(ParsedScreen.TimeAndDateSettingsHourScreen(hour = 13), parsedFrameIter.next().parsedScreen)
assertEquals(ParsedScreen.TimeAndDateSettingsHourScreen(hour = 13), parsedFrameIter.next().parsedScreen)
}
@Test
fun checkDisabledDuplicateDetection() = runBlocking {
// Test that getting frames with disabled duplicate detection works correctly.
// Two screens with equal content (both are a TimeAndDateSettingsHourScreen
// with the hour set to 13, or 1 PM). Duplicate detection would normally
// filter out the second one.
val displayFrameList = listOf(
testTimeAndDateSettingsHourRussianScreen,
testTimeAndDateSettingsHourTurkishScreen
)
val parsedFrameList = mutableListOf<ParsedDisplayFrame>()
val stream = ParsedDisplayFrameStream()
coroutineScope {
val producerJob = launch {
for (displayFrame in displayFrameList) {
// Wait here until the frame has been retrieved, since otherwise,
// the feedDisplayFrame() call below would overwrite the already
// stored frame.
while (stream.hasStoredDisplayFrame())
delay(100)
stream.feedDisplayFrame(displayFrame)
}
}
launch {
while (true) {
val parsedFrame = stream.getParsedDisplayFrame(filterDuplicates = false)
assertNotNull(parsedFrame)
parsedFrameList.add(parsedFrame)
if (parsedFrameList.size >= 2)
break
}
producerJob.cancel()
}
}
val parsedFrameIter = parsedFrameList.listIterator()
assertEquals(ParsedScreen.TimeAndDateSettingsHourScreen(hour = 13), parsedFrameIter.next().parsedScreen)
assertEquals(ParsedScreen.TimeAndDateSettingsHourScreen(hour = 13), parsedFrameIter.next().parsedScreen)
}
@Test
fun checkAlertScreenDetection() = runBlocking {
// Test that alert screens are detected and handled correctly.
val stream = ParsedDisplayFrameStream()
// Feed some dummy non-alert screen first to see that such a
// screen does not mess up the alert screen detection logic.
// We expect normal parsing behavior.
stream.feedDisplayFrame(testTimeAndDateSettingsHourRussianScreen)
val parsedFirstFrame = stream.getParsedDisplayFrame()
assertEquals(ParsedScreen.TimeAndDateSettingsHourScreen(hour = 13), parsedFirstFrame!!.parsedScreen)
// Feed a W6 screen, but with alert screen detection disabled.
// We expect normal parsing behavior.
stream.feedDisplayFrame(testFrameW6CancelTbrWarningScreen)
val parsedWarningFrame = stream.getParsedDisplayFrame(processAlertScreens = false)
assertEquals(ParsedScreen.AlertScreen(AlertScreenContent.Warning(6)), parsedWarningFrame!!.parsedScreen)
// Feed a W6 screen, but with alert screen detection enabled.
// We expect the alert screen to be detected and an exception
// to be thrown as a result.
val alertScreenException = assertFailsWith<AlertScreenException> {
stream.feedDisplayFrame(testFrameW6CancelTbrWarningScreen)
stream.getParsedDisplayFrame(processAlertScreens = true)
}
assertIs<AlertScreenContent.Warning>(alertScreenException.alertScreenContent)
assertEquals(6, (alertScreenException.alertScreenContent as AlertScreenContent.Warning).code)
// Feed another dummy non-alert screen to see that the stream
// parses correctly even after an AlertScreenException.
stream.feedDisplayFrame(testTimeAndDateSettingsHourTurkishScreen)
val parsedLastFrame = stream.getParsedDisplayFrame()
assertEquals(ParsedScreen.TimeAndDateSettingsHourScreen(hour = 13), parsedLastFrame!!.parsedScreen)
}
@Test
fun checkSkippingBlinkedOutScreens() = runBlocking {
// Test that the stream correctly skips blinked-out screens.
// Produce a test feed of 3 frames, with the second frame being blinked out.
// We expect the stream to filter that blinked-out frame.
val displayFrameList = listOf(
testFrameTemporaryBasalRatePercentage110Screen,
testFrameTemporaryBasalRateNoPercentageScreen,
testFrameTbrDurationEnglishScreen
)
val parsedFrameList = mutableListOf<ParsedDisplayFrame>()
val stream = ParsedDisplayFrameStream()
coroutineScope {
val producerJob = launch {
for (displayFrame in displayFrameList) {
// Wait here until the frame has been retrieved, since otherwise,
// the feedDisplayFrame() call below would overwrite the already
// stored frame.
while (stream.hasStoredDisplayFrame())
delay(100)
stream.feedDisplayFrame(displayFrame)
}
}
launch {
while (true) {
val parsedFrame = stream.getParsedDisplayFrame(filterDuplicates = false)
assertNotNull(parsedFrame)
parsedFrameList.add(parsedFrame)
if (parsedFrameList.size >= 2)
break
}
producerJob.cancel()
}
}
val parsedFrameIter = parsedFrameList.listIterator()
assertEquals(2, parsedFrameList.size)
assertEquals(
ParsedScreen.TemporaryBasalRatePercentageScreen(percentage = 110, remainingDurationInMinutes = 30),
parsedFrameIter.next().parsedScreen
)
assertEquals(ParsedScreen.TemporaryBasalRateDurationScreen(durationInMinutes = 30), parsedFrameIter.next().parsedScreen)
}
}

View file

@ -0,0 +1,929 @@
package info.nightscout.comboctl.main
import info.nightscout.comboctl.base.LogLevel
import info.nightscout.comboctl.base.Logger
import info.nightscout.comboctl.base.NullDisplayFrame
import info.nightscout.comboctl.base.PathSegment
import info.nightscout.comboctl.base.findShortestPath
import info.nightscout.comboctl.base.testUtils.runBlockingWithWatchdog
import info.nightscout.comboctl.parser.AlertScreenContent
import info.nightscout.comboctl.parser.AlertScreenException
import info.nightscout.comboctl.parser.BatteryState
import info.nightscout.comboctl.parser.MainScreenContent
import info.nightscout.comboctl.parser.ParsedScreen
import info.nightscout.comboctl.parser.Quickinfo
import info.nightscout.comboctl.parser.ReservoirState
import kotlin.reflect.KClassifier
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDateTime
import org.junit.jupiter.api.BeforeAll
import kotlin.test.assertFailsWith
class RTNavigationTest {
/* RTNavigationContext implementation for testing out RTNavigation functionality.
* This simulates the activity of a Combo in RT mode by using a defined list
* of ParsedScreen instances. This context runs a coroutine that keeps emitting
* screens from the list. Initially, it regularly emits the first ParsedScreen
* from the list. If automaticallyAdvanceScreens is true, it will move on to
* the next screen after a short while, otherwise it will stay at the same screen
* in the testParsedScreenList until a button is pressed. The actual button type
* is not evaluated; any button press advances the list iterator and causes the
* next screen to be emitted through the SharedFlow. The flow is set up such
* that it suspends when there are subscribers to make sure they don't miss
* any screens. When there are no subscribers, it just keeps repeating the
* same screen, so no screens are missed then either.
*
* This simulates the Combo's RT mode in the following ways: Often, the screen
* doesn't actually change in a meaningful manner (for example when it blinks).
* And, in most cases, a real change in the RT screen contents only happens
* after user interaction (= a button press). automaticallyAdvanceScreens is
* in fact false by default because its behavior is not commonly encountered.
* That property is only used when testing waitUntilScreenAppears().
*/
class TestRTNavigationContext(
testParsedScreenList: List<ParsedScreen>,
private val automaticallyAdvanceScreens: Boolean = false
) : RTNavigationContext {
private val mainJob = SupervisorJob()
private val mainScope = CoroutineScope(mainJob)
private val testParsedScreenListIter = testParsedScreenList.listIterator()
private var currentParsedScreen = testParsedScreenListIter.next()
private val parsedScreenChannel = Channel<ParsedScreen?>(capacity = Channel.RENDEZVOUS)
private var longButtonJob: Job? = null
private var lastParsedScreen: ParsedScreen? = null
val shortPressedRTButtons = mutableListOf<RTNavigationButton>()
init {
mainScope.launch {
while (true) {
System.err.println("Emitting test screen $currentParsedScreen")
parsedScreenChannel.send(currentParsedScreen)
delay(100)
if (automaticallyAdvanceScreens) {
if (testParsedScreenListIter.hasNext())
currentParsedScreen = testParsedScreenListIter.next()
else
break
}
}
}
}
override val maxNumCycleAttempts: Int = 20
override fun resetDuplicate() {
lastParsedScreen = null
}
override suspend fun getParsedDisplayFrame(filterDuplicates: Boolean, processAlertScreens: Boolean): ParsedDisplayFrame? {
while (true) {
val thisParsedScreen = parsedScreenChannel.receive()
if (filterDuplicates && (lastParsedScreen != null) && (thisParsedScreen != null)) {
if (lastParsedScreen == thisParsedScreen) {
currentParsedScreen = testParsedScreenListIter.next()
continue
}
}
lastParsedScreen = thisParsedScreen
if ((thisParsedScreen != null) && thisParsedScreen.isBlinkedOut)
continue
if (processAlertScreens && (thisParsedScreen != null)) {
if (thisParsedScreen is ParsedScreen.AlertScreen)
throw AlertScreenException(thisParsedScreen.content)
}
return thisParsedScreen?.let { ParsedDisplayFrame(NullDisplayFrame, it) }
}
}
override suspend fun startLongButtonPress(button: RTNavigationButton, keepGoing: (suspend () -> Boolean)?) {
longButtonJob = mainScope.launch {
while (true) {
// The keepGoing() predicate can suspend this coroutine for a while.
// This is OK and expected. By definition, every time this predicate
// returns true, the long RT button press "keeps going". At each
// iteration with a predicate return value being true, the long RT
// button press is signaled to the Combo, which induces a change
// in the Combo, for example a quantity increment. We simulate this
// here by moving to the next screen if keepGoing() returns true.
// If keepGoing is not set, this behaves as if keepGoing() always
// returned true.
if (keepGoing?.let { !it() } ?: false)
break
currentParsedScreen = testParsedScreenListIter.next()
}
}
}
override suspend fun stopLongButtonPress() {
longButtonJob?.cancelAndJoin()
longButtonJob = null
}
override suspend fun waitForLongButtonPressToFinish() {
longButtonJob?.join()
longButtonJob = null
}
override suspend fun shortPressButton(button: RTNavigationButton) {
// Simulate the consequences of user interaction by moving to the next screen.
currentParsedScreen = testParsedScreenListIter.next()
System.err.println("Moved to next screen $currentParsedScreen after short button press")
shortPressedRTButtons.add(button)
}
}
companion object {
@BeforeAll
@JvmStatic
fun commonInit() {
Logger.threshold = LogLevel.VERBOSE
}
}
@Test
fun checkRTNavigationGraphConnectivity() {
// Check the rtNavigationGraph's connectivity. All nodes are
// supposed to be connected and reachable from other nodes.
val screenNodes = rtNavigationGraph.nodes
for (nodeA in screenNodes.values) {
for (nodeB in screenNodes.values) {
// Skip this case, since the nodes in this
// graph have no self-edges.
if (nodeA === nodeB)
continue
val path = rtNavigationGraph.findShortestPath(nodeA, nodeB)
assertTrue(path!!.isNotEmpty())
}
}
}
@Test
fun checkRTNavigationGraphPathFromMainScreenToBasalRateFactorSettingScreen() {
val path = findShortestRtPath(
ParsedScreen.MainScreen::class,
ParsedScreen.BasalRateFactorSettingScreen::class,
isComboStopped = false
)
assertNotNull(path)
assertEquals(5, path.size)
assertEquals(PathSegment<KClassifier, RTEdgeValue>(
ParsedScreen.TemporaryBasalRateMenuScreen::class, RTEdgeValue(RTNavigationButton.MENU)), path[0])
assertEquals(PathSegment<KClassifier, RTEdgeValue>(
ParsedScreen.MyDataMenuScreen::class, RTEdgeValue(RTNavigationButton.MENU)), path[1])
assertEquals(PathSegment<KClassifier, RTEdgeValue>(
ParsedScreen.BasalRate1ProgrammingMenuScreen::class, RTEdgeValue(RTNavigationButton.MENU)), path[2])
assertEquals(PathSegment<KClassifier, RTEdgeValue>(
ParsedScreen.BasalRateTotalScreen::class, RTEdgeValue(RTNavigationButton.CHECK)), path[3])
assertEquals(PathSegment<KClassifier, RTEdgeValue>(
ParsedScreen.BasalRateFactorSettingScreen::class, RTEdgeValue(RTNavigationButton.MENU)), path[4])
}
@Test
fun checkRTNavigationGraphPathFromMainScreenToBasalRateFactorSettingScreenWhenStopped() {
// The TBR menu is disabled when the Combo is stopped. We expect the
// RT navigation to take that into account and find a shortest path
// that does not include the TBR menu screen.
val path = findShortestRtPath(
ParsedScreen.MainScreen::class,
ParsedScreen.BasalRateFactorSettingScreen::class,
isComboStopped = true
)
assertNotNull(path)
assertEquals(4, path.size)
assertEquals(PathSegment<KClassifier, RTEdgeValue>(
ParsedScreen.MyDataMenuScreen::class, RTEdgeValue(RTNavigationButton.MENU)), path[0])
assertEquals(PathSegment<KClassifier, RTEdgeValue>(
ParsedScreen.BasalRate1ProgrammingMenuScreen::class, RTEdgeValue(RTNavigationButton.MENU)), path[1])
assertEquals(PathSegment<KClassifier, RTEdgeValue>(
ParsedScreen.BasalRateTotalScreen::class, RTEdgeValue(RTNavigationButton.CHECK)), path[2])
assertEquals(PathSegment<KClassifier, RTEdgeValue>(
ParsedScreen.BasalRateFactorSettingScreen::class, RTEdgeValue(RTNavigationButton.MENU)), path[3])
}
@Test
fun checkComputeShortRTButtonPressWithOneStepSize() {
// Test that computeShortRTButtonPress() correctly computes
// the number of necessary short RT button presses and figures
// out the correct button to press. These are the tests for
// increment step arrays with one item.
var result: Pair<Int, RTNavigationButton>
result = computeShortRTButtonPress(
currentQuantity = 100,
targetQuantity = 130,
cyclicQuantityRange = null,
incrementSteps = arrayOf(Pair(0, 3)),
incrementButton = RTNavigationButton.UP,
decrementButton = RTNavigationButton.DOWN
)
assertEquals(Pair((130 - 100) / 3, RTNavigationButton.UP), result)
result = computeShortRTButtonPress(
currentQuantity = 4000,
targetQuantity = 60,
cyclicQuantityRange = null,
incrementSteps = arrayOf(Pair(0, 20)),
incrementButton = RTNavigationButton.UP,
decrementButton = RTNavigationButton.DOWN
)
assertEquals(Pair((4000 - 60) / 20, RTNavigationButton.DOWN), result)
result = computeShortRTButtonPress(
currentQuantity = 10,
targetQuantity = 20,
cyclicQuantityRange = 60,
incrementSteps = arrayOf(Pair(0, 1)),
incrementButton = RTNavigationButton.UP,
decrementButton = RTNavigationButton.DOWN
)
assertEquals(Pair((20 - 10) / 1, RTNavigationButton.UP), result)
// Tests that the cyclic quantity range is respected.
// In this case, a wrap-around is expected to be preferred
// by computeShortRTButtonPress().
result = computeShortRTButtonPress(
currentQuantity = 10,
targetQuantity = 50,
cyclicQuantityRange = 60,
incrementSteps = arrayOf(Pair(0, 1)),
incrementButton = RTNavigationButton.UP,
decrementButton = RTNavigationButton.DOWN
)
assertEquals(Pair(((60 - 50) + (10 - 0)) / 1, RTNavigationButton.DOWN), result)
// Another cyclic quantity range test with a wrap around,
// this time from the other direction (wrapping around
// from quantity 58 to target quantity 1).
result = computeShortRTButtonPress(
currentQuantity = 58,
targetQuantity = 1,
cyclicQuantityRange = 60,
incrementSteps = arrayOf(Pair(0, 1)),
incrementButton = RTNavigationButton.UP,
decrementButton = RTNavigationButton.DOWN
)
assertEquals(Pair(3, RTNavigationButton.UP), result)
// Additional test to check that cyclic ranges are handled correctly
// even if currentQuantity is slightly higher than targetQuantity. This
// verifies that the computation does not produce negative distances.
result = computeShortRTButtonPress(
currentQuantity = 11,
targetQuantity = 10,
cyclicQuantityRange = 24,
incrementSteps = arrayOf(Pair(0, 1)),
incrementButton = RTNavigationButton.UP,
decrementButton = RTNavigationButton.DOWN
)
assertEquals(Pair(1, RTNavigationButton.DOWN), result)
// Test that computeShortRTButtonPress() can correctly handle start
// quantities that aren't an integer multiple of the step size. The
// "half-step" should be counted as one full step.
result = computeShortRTButtonPress(
currentQuantity = 25,
targetQuantity = 40,
cyclicQuantityRange = null,
incrementSteps = arrayOf(Pair(0, 20)),
incrementButton = RTNavigationButton.UP,
decrementButton = RTNavigationButton.DOWN
)
assertEquals(Pair(1, RTNavigationButton.UP), result)
result = computeShortRTButtonPress(
currentQuantity = 25,
targetQuantity = 60,
cyclicQuantityRange = null,
incrementSteps = arrayOf(Pair(0, 20)),
incrementButton = RTNavigationButton.UP,
decrementButton = RTNavigationButton.DOWN
)
assertEquals(Pair(2, RTNavigationButton.UP), result)
result = computeShortRTButtonPress(
currentQuantity = 35,
targetQuantity = 20,
cyclicQuantityRange = null,
incrementSteps = arrayOf(Pair(0, 20)),
incrementButton = RTNavigationButton.UP,
decrementButton = RTNavigationButton.DOWN
)
assertEquals(Pair(1, RTNavigationButton.DOWN), result)
result = computeShortRTButtonPress(
currentQuantity = 55,
targetQuantity = 20,
cyclicQuantityRange = null,
incrementSteps = arrayOf(Pair(0, 20)),
incrementButton = RTNavigationButton.UP,
decrementButton = RTNavigationButton.DOWN
)
assertEquals(Pair(2, RTNavigationButton.DOWN), result)
// Corner case: current and target quantity are the same. In this case,
// no RT button would actually be pressed, but the button value in the
// result can't be left unset, so it is just set to CHECK. (Any value
// would be okay; CHECK was chosen because it seems closest to something
// like a "neutral" value.)
result = computeShortRTButtonPress(
currentQuantity = 60,
targetQuantity = 60,
cyclicQuantityRange = null,
incrementSteps = arrayOf(Pair(0, 20)),
incrementButton = RTNavigationButton.UP,
decrementButton = RTNavigationButton.DOWN
)
assertEquals(Pair(0, RTNavigationButton.CHECK), result)
}
@Test
fun checkComputeShortRTButtonPressWithTwoStepSizes() {
// Test that computeShortRTButtonPress() correctly computes
// the number of necessary short RT button presses and figures
// out the correct button to press. These are the tests for
// increment step arrays with two items.
var result: Pair<Int, RTNavigationButton>
result = computeShortRTButtonPress(
currentQuantity = 100,
targetQuantity = 150,
cyclicQuantityRange = null,
incrementSteps = arrayOf(Pair(0, 10), Pair(1000, 50)),
incrementButton = RTNavigationButton.UP,
decrementButton = RTNavigationButton.DOWN
)
assertEquals(Pair((150 - 100) / 10, RTNavigationButton.UP), result)
result = computeShortRTButtonPress(
currentQuantity = 1000,
targetQuantity = 1100,
cyclicQuantityRange = null,
incrementSteps = arrayOf(Pair(0, 10), Pair(1000, 50)),
incrementButton = RTNavigationButton.UP,
decrementButton = RTNavigationButton.DOWN
)
assertEquals(Pair((1100 - 1000) / 50, RTNavigationButton.UP), result)
result = computeShortRTButtonPress(
currentQuantity = 900,
targetQuantity = 1050,
cyclicQuantityRange = null,
incrementSteps = arrayOf(Pair(0, 10), Pair(1000, 50)),
incrementButton = RTNavigationButton.UP,
decrementButton = RTNavigationButton.DOWN
)
assertEquals(Pair((1000 - 900) / 10 + (1050 - 1000) / 50, RTNavigationButton.UP), result)
result = computeShortRTButtonPress(
currentQuantity = 300,
targetQuantity = 230,
cyclicQuantityRange = null,
incrementSteps = arrayOf(Pair(0, 10), Pair(1000, 50)),
incrementButton = RTNavigationButton.UP,
decrementButton = RTNavigationButton.DOWN
)
assertEquals(Pair((300 - 230) / 10, RTNavigationButton.DOWN), result)
result = computeShortRTButtonPress(
currentQuantity = 1200,
targetQuantity = 1000,
cyclicQuantityRange = null,
incrementSteps = arrayOf(Pair(0, 10), Pair(1000, 50)),
incrementButton = RTNavigationButton.UP,
decrementButton = RTNavigationButton.DOWN
)
assertEquals(Pair((1200 - 1000) / 50, RTNavigationButton.DOWN), result)
result = computeShortRTButtonPress(
currentQuantity = 1100,
targetQuantity = 970,
cyclicQuantityRange = null,
incrementSteps = arrayOf(Pair(0, 10), Pair(1000, 50)),
incrementButton = RTNavigationButton.UP,
decrementButton = RTNavigationButton.DOWN
)
assertEquals(Pair((1000 - 970) / 10 + (1100 - 1000) / 50, RTNavigationButton.DOWN), result)
}
@Test
fun checkComputeShortRTButtonPressWithThreeStepSizes() {
// Test that computeShortRTButtonPress() correctly computes
// the number of necessary short RT button presses and figures
// out the correct button to press. These are the tests for
// increment step arrays with three items.
var result: Pair<Int, RTNavigationButton>
result = computeShortRTButtonPress(
currentQuantity = 7900,
targetQuantity = 710,
cyclicQuantityRange = null,
incrementSteps = arrayOf(Pair(0, 50), Pair(50, 10), Pair(1000, 50)),
incrementButton = RTNavigationButton.UP,
decrementButton = RTNavigationButton.DOWN
)
assertEquals(Pair((1000 - 710) / 10 + (7900 - 1000) / 50, RTNavigationButton.DOWN), result)
result = computeShortRTButtonPress(
currentQuantity = 0,
targetQuantity = 1100,
cyclicQuantityRange = null,
incrementSteps = arrayOf(Pair(0, 50), Pair(50, 10), Pair(1000, 50)),
incrementButton = RTNavigationButton.UP,
decrementButton = RTNavigationButton.DOWN
)
assertEquals(Pair((50 - 0) / 50 + (1000 - 50) / 10 + (1100 - 1000) / 50, RTNavigationButton.UP), result)
}
@Test
fun checkRTNavigationFromMainToQuickinfo() {
// Check RT screen navigation by navigating from the main screen
// to the quickinfo screen. If this does not work properly, the
// navigateToRTScreen() throws an exception or never ends. In
// the latter case, the watchdog will eventually cancel the
// coroutine and report the test as failed.
val rtNavigationContext = TestRTNavigationContext(listOf(
ParsedScreen.MainScreen(MainScreenContent.Normal(
currentTime = LocalDateTime(year = 2020, monthNumber = 10, dayOfMonth = 4, hour = 0, minute = 0),
activeBasalProfileNumber = 1,
currentBasalRateFactor = 300,
batteryState = BatteryState.FULL_BATTERY
)),
ParsedScreen.QuickinfoMainScreen(Quickinfo(availableUnits = 105, reservoirState = ReservoirState.FULL))
))
runBlockingWithWatchdog(6000) {
navigateToRTScreen(rtNavigationContext, ParsedScreen.QuickinfoMainScreen::class, isComboStopped = false)
}
}
@Test
fun checkRTNavigationWithBlinkedOutScreens() {
// During navigation, the stream must skip screens that are blinked out,
// otherwise the navigation may incorrectly press RT buttons more often
// than necessary.
val rtNavigationContext = TestRTNavigationContext(listOf(
ParsedScreen.MainScreen(MainScreenContent.Normal(
currentTime = LocalDateTime(year = 2020, monthNumber = 10, dayOfMonth = 4, hour = 0, minute = 0),
activeBasalProfileNumber = 1,
currentBasalRateFactor = 300,
batteryState = BatteryState.FULL_BATTERY
)),
ParsedScreen.TemporaryBasalRateMenuScreen,
ParsedScreen.TemporaryBasalRatePercentageScreen(percentage = 110, remainingDurationInMinutes = 30),
ParsedScreen.TemporaryBasalRatePercentageScreen(percentage = null, remainingDurationInMinutes = null),
ParsedScreen.TemporaryBasalRateDurationScreen(durationInMinutes = 45)
))
runBlockingWithWatchdog(6000) {
navigateToRTScreen(rtNavigationContext, ParsedScreen.TemporaryBasalRateDurationScreen::class, isComboStopped = false)
}
assertContentEquals(
listOf(
RTNavigationButton.MENU,
RTNavigationButton.CHECK,
RTNavigationButton.MENU
),
rtNavigationContext.shortPressedRTButtons
)
}
@Test
fun checkRTNavigationWhenAlreadyAtTarget() {
// Check edge case handling when we want to navigate to
// a target screen type, but we are in fact already there.
val rtNavigationContext = TestRTNavigationContext(listOf(
ParsedScreen.MainScreen(MainScreenContent.Normal(
currentTime = LocalDateTime(year = 2020, monthNumber = 10, dayOfMonth = 4, hour = 0, minute = 0),
activeBasalProfileNumber = 1,
currentBasalRateFactor = 300,
batteryState = BatteryState.FULL_BATTERY
))
))
runBlockingWithWatchdog(6000) {
navigateToRTScreen(rtNavigationContext, ParsedScreen.MainScreen::class, isComboStopped = false)
}
}
@Test
fun checkRTNavigationFromMainScreenToBasalRateFactorSettingScreen() {
// Check the result of a more complex navigation.
val rtNavigationContext = TestRTNavigationContext(listOf(
ParsedScreen.MainScreen(MainScreenContent.Normal(
currentTime = LocalDateTime(year = 0, monthNumber = 1, dayOfMonth = 1, hour = 23, minute = 11),
activeBasalProfileNumber = 1,
currentBasalRateFactor = 800,
batteryState = BatteryState.FULL_BATTERY
)),
ParsedScreen.StopPumpMenuScreen,
ParsedScreen.StandardBolusMenuScreen,
ParsedScreen.ExtendedBolusMenuScreen,
ParsedScreen.MultiwaveBolusMenuScreen,
ParsedScreen.TemporaryBasalRateMenuScreen,
ParsedScreen.MyDataMenuScreen,
ParsedScreen.BasalRateProfileSelectionMenuScreen,
ParsedScreen.BasalRate1ProgrammingMenuScreen,
ParsedScreen.BasalRateTotalScreen(1840, 1),
ParsedScreen.BasalRateFactorSettingScreen(
LocalDateTime(year = 0, monthNumber = 1, dayOfMonth = 1, hour = 0, minute = 0),
LocalDateTime(year = 0, monthNumber = 1, dayOfMonth = 1, hour = 1, minute = 0),
1000,
1
)
))
runBlockingWithWatchdog(6000) {
val targetScreen = navigateToRTScreen(
rtNavigationContext,
ParsedScreen.BasalRateFactorSettingScreen::class,
isComboStopped = false
)
assertIs<ParsedScreen.BasalRateFactorSettingScreen>(targetScreen)
}
// Navigation is done by pressing MENU 9 times until the basal rate
// 1 programming menu is reached. The programming menu is entered
// by pressing CHECK, after which the basal rate totals screen
// shows up. Pressing MENU again enters further and shows the
// first basal profile factor, which is the target the navigation
// is trying to reach.
val expectedShortRTButtonPressSequence = listOf(
RTNavigationButton.MENU,
RTNavigationButton.MENU,
RTNavigationButton.MENU,
RTNavigationButton.MENU,
RTNavigationButton.MENU,
RTNavigationButton.MENU,
RTNavigationButton.MENU,
RTNavigationButton.MENU,
RTNavigationButton.CHECK,
RTNavigationButton.MENU
)
assertContentEquals(expectedShortRTButtonPressSequence, rtNavigationContext.shortPressedRTButtons)
}
@Test
fun checkRTNavigationFromMainToQuickinfoWithChangingRemainingTbrDuration() {
// Check that RT screen navigation skips a newly received screen
// if it is of the same type as the previously observed screen.
// This typically happens because a quantity like the remaining
// TBR duration changes on screen. The navigation code has to
// check that the _type_ of the screen changed, and if not, it
// must skip the screen. Here, we simulate a main TBR screen
// whose remaining TBR duration changes. We expect the navigation
// code to detect this and press CHECK just _once_ (because the
// second TBR main screen is skipped by the detection). Without
// the screen type check, it would press CHECK _twice_.
val rtNavigationContext = TestRTNavigationContext(listOf(
ParsedScreen.MainScreen(MainScreenContent.Tbr(
currentTime = LocalDateTime(year = 2020, monthNumber = 10, dayOfMonth = 4, hour = 0, minute = 0),
remainingTbrDurationInMinutes = 28,
tbrPercentage = 110,
activeBasalProfileNumber = 1,
currentBasalRateFactor = 300,
batteryState = BatteryState.FULL_BATTERY
)),
ParsedScreen.MainScreen(MainScreenContent.Tbr(
currentTime = LocalDateTime(year = 2020, monthNumber = 10, dayOfMonth = 4, hour = 0, minute = 0),
remainingTbrDurationInMinutes = 27,
tbrPercentage = 110,
activeBasalProfileNumber = 1,
currentBasalRateFactor = 300,
batteryState = BatteryState.FULL_BATTERY
)),
ParsedScreen.QuickinfoMainScreen(Quickinfo(availableUnits = 105, reservoirState = ReservoirState.FULL))
))
runBlockingWithWatchdog(6000) {
navigateToRTScreen(rtNavigationContext, ParsedScreen.QuickinfoMainScreen::class, isComboStopped = false)
}
val expectedShortRTButtonPressSequence = listOf(
RTNavigationButton.CHECK
)
assertContentEquals(expectedShortRTButtonPressSequence, rtNavigationContext.shortPressedRTButtons)
}
@Test
fun checkLongPressRTButtonUntil() {
// Test long RT button presses by simulating transitions
// between screens that happen due to the long button
// press. The transition goes from the main screen
// over basal rate 1-3 programming screens up to the
// 4th one, which is the target. To test for "overshoots",
// there's a 5th one after that.
val rtNavigationContext = TestRTNavigationContext(listOf(
ParsedScreen.MainScreen(MainScreenContent.Normal(
currentTime = LocalDateTime(year = 2020, monthNumber = 10, dayOfMonth = 4, hour = 0, minute = 0),
activeBasalProfileNumber = 1,
currentBasalRateFactor = 300,
batteryState = BatteryState.FULL_BATTERY
)),
ParsedScreen.BasalRate1ProgrammingMenuScreen,
ParsedScreen.BasalRate2ProgrammingMenuScreen,
ParsedScreen.BasalRate3ProgrammingMenuScreen,
ParsedScreen.BasalRate4ProgrammingMenuScreen,
ParsedScreen.BasalRate5ProgrammingMenuScreen
))
runBlockingWithWatchdog(6000) {
val finalScreen = longPressRTButtonUntil(rtNavigationContext, RTNavigationButton.MENU) { parsedScreen ->
if (parsedScreen is ParsedScreen.BasalRate4ProgrammingMenuScreen)
LongPressRTButtonsCommand.ReleaseButton
else
LongPressRTButtonsCommand.ContinuePressingButton
}
assertIs<ParsedScreen.BasalRate4ProgrammingMenuScreen>(finalScreen)
}
}
@Test
fun checkShortPressRTButtonUntil() {
// Test short RT button presses by simulating transitions
// between screens that happen due to repeated short
// button presses. The transition goes from the main screen
// over basal rate 1-3 programming screens up to the
// 4th one, which is the target. To test for "overshoots",
// there's a 5th one after that.
val rtNavigationContext = TestRTNavigationContext(listOf(
ParsedScreen.MainScreen(MainScreenContent.Normal(
currentTime = LocalDateTime(year = 2020, monthNumber = 10, dayOfMonth = 4, hour = 0, minute = 0),
activeBasalProfileNumber = 1,
currentBasalRateFactor = 300,
batteryState = BatteryState.FULL_BATTERY
)),
ParsedScreen.BasalRate1ProgrammingMenuScreen,
ParsedScreen.BasalRate2ProgrammingMenuScreen,
ParsedScreen.BasalRate3ProgrammingMenuScreen,
ParsedScreen.BasalRate4ProgrammingMenuScreen,
ParsedScreen.BasalRate5ProgrammingMenuScreen
))
runBlockingWithWatchdog(6000) {
val finalScreen = shortPressRTButtonsUntil(rtNavigationContext) { parsedScreen ->
if (parsedScreen is ParsedScreen.BasalRate4ProgrammingMenuScreen)
ShortPressRTButtonsCommand.Stop
else
ShortPressRTButtonsCommand.PressButton(RTNavigationButton.MENU)
}
assertIs<ParsedScreen.BasalRate4ProgrammingMenuScreen>(finalScreen)
}
}
@Test
fun checkLongPressRTButtonInnerAbort() {
// Modified checkLongPressRTButtonUntil() test with a W6 warning
// screen in between. We except the long button press to be aborted
// and an AlertScreenException to be thrown.
val rtNavigationContext = TestRTNavigationContext(listOf(
ParsedScreen.MainScreen(MainScreenContent.Normal(
currentTime = LocalDateTime(year = 2020, monthNumber = 10, dayOfMonth = 4, hour = 0, minute = 0),
activeBasalProfileNumber = 1,
currentBasalRateFactor = 300,
batteryState = BatteryState.FULL_BATTERY
)),
ParsedScreen.BasalRate1ProgrammingMenuScreen,
ParsedScreen.BasalRate2ProgrammingMenuScreen,
ParsedScreen.AlertScreen(AlertScreenContent.Warning(code = 6)),
ParsedScreen.BasalRate3ProgrammingMenuScreen,
ParsedScreen.BasalRate4ProgrammingMenuScreen,
ParsedScreen.BasalRate5ProgrammingMenuScreen
))
runBlockingWithWatchdog(6000) {
val e = assertFailsWith<AlertScreenException> {
// Keep long-pressing the button. Eventually, the W6 screen is received.
longPressRTButtonUntil(rtNavigationContext, RTNavigationButton.MENU) { parsedScreen ->
if (parsedScreen is ParsedScreen.BasalRate4ProgrammingMenuScreen)
LongPressRTButtonsCommand.ReleaseButton
else
LongPressRTButtonsCommand.ContinuePressingButton
}
}
assertIs<AlertScreenContent.Warning>(e.alertScreenContent)
assertEquals(6, (e.alertScreenContent as AlertScreenContent.Warning).code)
// Simulate a short RT button press that would be used after the exception
// was thrown to dismiss the W6 warning. This also checks that the long
// button press has been finished correctly; if not, this call may fail
// because the code incorrectly thinks that a long press button job
// is still ongoing.
rtNavigationContext.shortPressButton(RTNavigationButton.CHECK)
}
}
@Test
fun checkCycleToRTScreen() {
// Test the cycleToRTScreen() by letting it repeatedly
// press MENU until it reaches basal rate programming screen 4
// in our simulated sequence of screens.
val rtNavigationContext = TestRTNavigationContext(listOf(
ParsedScreen.MainScreen(MainScreenContent.Normal(
currentTime = LocalDateTime(year = 2020, monthNumber = 10, dayOfMonth = 4, hour = 0, minute = 0),
activeBasalProfileNumber = 1,
currentBasalRateFactor = 300,
batteryState = BatteryState.FULL_BATTERY
)),
ParsedScreen.BasalRate1ProgrammingMenuScreen,
ParsedScreen.BasalRate2ProgrammingMenuScreen,
ParsedScreen.BasalRate3ProgrammingMenuScreen,
ParsedScreen.BasalRate4ProgrammingMenuScreen,
ParsedScreen.BasalRate5ProgrammingMenuScreen
))
runBlockingWithWatchdog(6000) {
val finalScreen = cycleToRTScreen(
rtNavigationContext,
RTNavigationButton.MENU,
ParsedScreen.BasalRate4ProgrammingMenuScreen::class
)
assertIs<ParsedScreen.BasalRate4ProgrammingMenuScreen>(finalScreen)
}
}
@Test
fun checkWaitUntilScreenAppears() {
// Test waitUntilScreenAppears() by letting the context itself
// advance the screens until the screen that the function is
// waiting for is reached.
val rtNavigationContext = TestRTNavigationContext(
listOf(
ParsedScreen.BasalRate1ProgrammingMenuScreen,
ParsedScreen.BasalRate2ProgrammingMenuScreen,
ParsedScreen.BasalRate3ProgrammingMenuScreen,
ParsedScreen.BasalRate4ProgrammingMenuScreen
),
automaticallyAdvanceScreens = true
)
runBlockingWithWatchdog(6000) {
val finalScreen = waitUntilScreenAppears(
rtNavigationContext,
ParsedScreen.BasalRate3ProgrammingMenuScreen::class
)
assertIs<ParsedScreen.BasalRate3ProgrammingMenuScreen>(finalScreen)
}
}
@Test
fun checkAdjustQuantityOnScreen() {
// Test adjustQuantityOnScreen() by simulating a sequence of screens
// with a changing percentage quantity. This also simulates an overshoot
// by jumping from 150 straight to 170, past the target of 160. We
// expect adjustQuantityOnScreen() to catch this and correct it
// using short RT button presses until the target quantity is observed.
val rtNavigationContext = TestRTNavigationContext(listOf(
ParsedScreen.TemporaryBasalRatePercentageScreen(100, remainingDurationInMinutes = 30),
ParsedScreen.TemporaryBasalRatePercentageScreen(110, remainingDurationInMinutes = 30),
ParsedScreen.TemporaryBasalRatePercentageScreen(120, remainingDurationInMinutes = 30),
ParsedScreen.TemporaryBasalRatePercentageScreen(130, remainingDurationInMinutes = 30),
ParsedScreen.TemporaryBasalRatePercentageScreen(140, remainingDurationInMinutes = 30),
ParsedScreen.TemporaryBasalRatePercentageScreen(150, remainingDurationInMinutes = 30),
// No 160 quantity here, on purpose, to test overshoot handling
ParsedScreen.TemporaryBasalRatePercentageScreen(170, remainingDurationInMinutes = 30),
ParsedScreen.TemporaryBasalRatePercentageScreen(170, remainingDurationInMinutes = 30),
ParsedScreen.TemporaryBasalRatePercentageScreen(170, remainingDurationInMinutes = 30),
ParsedScreen.TemporaryBasalRatePercentageScreen(160, remainingDurationInMinutes = 30),
ParsedScreen.TemporaryBasalRatePercentageScreen(160, remainingDurationInMinutes = 30)
))
runBlockingWithWatchdog(6000) {
adjustQuantityOnScreen(
rtNavigationContext,
targetQuantity = 160,
cyclicQuantityRange = null,
incrementSteps = arrayOf(Pair(0, 10))
) { parsedScreen ->
parsedScreen as ParsedScreen.TemporaryBasalRatePercentageScreen
parsedScreen.percentage
}
}
}
@Test
fun checkAdjustQuantityOnScreenWithW6WarningDuringShortButtonPresses() {
// Modified checkAdjustQuantityOnScreen() variant with a W6 warning screen in between
// the screens that are received by the code when it corrects a long button press
// overshoot with a number of short button presses. We expect an AlertScreenException
// to be thrown. Such a warning screen interrupts and aborts whatever operation we
// were doing and returns the Combo back to the main screen.
val rtNavigationContext = TestRTNavigationContext(listOf(
ParsedScreen.TemporaryBasalRatePercentageScreen(100, remainingDurationInMinutes = 30),
ParsedScreen.TemporaryBasalRatePercentageScreen(110, remainingDurationInMinutes = 30),
ParsedScreen.TemporaryBasalRatePercentageScreen(120, remainingDurationInMinutes = 30),
ParsedScreen.TemporaryBasalRatePercentageScreen(130, remainingDurationInMinutes = 30),
ParsedScreen.TemporaryBasalRatePercentageScreen(140, remainingDurationInMinutes = 30),
ParsedScreen.TemporaryBasalRatePercentageScreen(150, remainingDurationInMinutes = 30),
// No 160 quantity here, on purpose, to test overshoot handling.
// During the screens below, short button presses will be used
// to fix the overshoot, so we place the W6 in between these
// to test that the AlertScreenException is correctly thrown
// while short-pressing the button.
ParsedScreen.TemporaryBasalRatePercentageScreen(170, remainingDurationInMinutes = 30),
ParsedScreen.TemporaryBasalRatePercentageScreen(170, remainingDurationInMinutes = 30),
ParsedScreen.TemporaryBasalRatePercentageScreen(170, remainingDurationInMinutes = 30),
ParsedScreen.AlertScreen(AlertScreenContent.Warning(code = 6)),
ParsedScreen.TemporaryBasalRatePercentageScreen(160, remainingDurationInMinutes = 30),
ParsedScreen.TemporaryBasalRatePercentageScreen(160, remainingDurationInMinutes = 30)
))
val e = assertFailsWith<AlertScreenException> {
runBlockingWithWatchdog(6000) {
adjustQuantityOnScreen(
rtNavigationContext,
targetQuantity = 160,
cyclicQuantityRange = null,
incrementSteps = arrayOf(Pair(0, 10))
) { parsedScreen ->
parsedScreen as ParsedScreen.TemporaryBasalRatePercentageScreen
parsedScreen.percentage
}
}
}
assertIs<AlertScreenContent.Warning>(e.alertScreenContent)
assertEquals(6, (e.alertScreenContent as AlertScreenContent.Warning).code)
}
@Test
fun checkCyclicAdjustQuantityOnScreen() {
// Similar to checkAdjustQuantityOnScreen(), except that we
// test a "cyclic" quantity here, meaning that there is a maximum
// quantity and the current quantity can wrap around it back to 0.
// In here, we simulate an adjustment with starting quantity 58
// and target quantity 2, and a range of 0-60. Expected behavior
// is that adjustQuantityOnScreen() increments and correctly
// handles the wraparound from 59 to 0, since thanks to the
// wraparound, incrementing the quantity is actually faster
// than decrementing it. (With wrapround it goes 58 -> 0 -> 2
// by incrementing, which is a total distance of 5 steps, while
// without wraparound, it goes 58 -> 2 by decrementing, which
// is a total distance of 55 steps.)
val rtNavigationContext = TestRTNavigationContext(listOf(
ParsedScreen.TimeAndDateSettingsMinuteScreen(58),
ParsedScreen.TimeAndDateSettingsMinuteScreen(59),
ParsedScreen.TimeAndDateSettingsMinuteScreen(0),
ParsedScreen.TimeAndDateSettingsMinuteScreen(1),
// No 2 quantity here, on purpose, to test overshoot handling
ParsedScreen.TimeAndDateSettingsMinuteScreen(3),
ParsedScreen.TimeAndDateSettingsMinuteScreen(2),
// This is a dummy screen to avoid an exception due to the next() call in TestRTNavigationContext.shortButtonPress()
ParsedScreen.TimeAndDateSettingsMinuteScreen(0)
))
runBlockingWithWatchdog(6000) {
adjustQuantityOnScreen(
rtNavigationContext,
targetQuantity = 2,
cyclicQuantityRange = 60,
incrementSteps = arrayOf(Pair(0, 1))
) { parsedScreen ->
parsedScreen as ParsedScreen.TimeAndDateSettingsMinuteScreen
parsedScreen.minute
}
}
}
}

View file

@ -0,0 +1,104 @@
package info.nightscout.comboctl.parser
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class TokenizationTest {
@Test
fun checkBasicPatternMatch() {
// Try to match the LARGE_BASAL symbol pattern in the testFrameMainScreenWithTimeSeparator.
// That symbol is present at position (0,9).
// Trying to match it at those coordinates is expected to succeed,
// while trying to match it slightly to the right should fail.
val largeBasalGlyphPattern = glyphPatterns[Glyph.LargeSymbol(LargeSymbol.BASAL)]!!
val result1 = checkIfPatternMatchesAt(
testFrameMainScreenWithTimeSeparator,
largeBasalGlyphPattern,
0, 8
)
assertTrue(result1)
val result2 = checkIfPatternMatchesAt(
testFrameMainScreenWithTimeSeparator,
largeBasalGlyphPattern,
1, 8
)
assertFalse(result2)
}
@Test
fun checkMainScreenTokenization() {
// Look for tokens in the main menu display frame.
// The pattern matching algorithm scans the frame
// left to right, top to bottom, and tries the
// large patterns first.
// The main screen contains symbols that yield
// ambiguities due to overlapping tokens. For
// example, the basal icon contains sub-patterns
// that match the cyrillic letter "п". These must
// be filtered out by findTokens().
val tokens = findTokens(testFrameMainScreenWithTimeSeparator)
assertEquals(13, tokens.size)
val iterator = tokens.iterator()
assertEquals(Glyph.SmallSymbol(SmallSymbol.CLOCK), iterator.next().glyph)
assertEquals(Glyph.SmallDigit(1), iterator.next().glyph)
assertEquals(Glyph.SmallDigit(0), iterator.next().glyph)
assertEquals(Glyph.SmallSymbol(SmallSymbol.SEPARATOR), iterator.next().glyph)
assertEquals(Glyph.SmallDigit(2), iterator.next().glyph)
assertEquals(Glyph.SmallDigit(0), iterator.next().glyph)
assertEquals(Glyph.LargeSymbol(LargeSymbol.BASAL), iterator.next().glyph)
assertEquals(Glyph.LargeDigit(0), iterator.next().glyph)
assertEquals(Glyph.LargeSymbol(LargeSymbol.DOT), iterator.next().glyph)
assertEquals(Glyph.LargeDigit(2), iterator.next().glyph)
assertEquals(Glyph.LargeDigit(0), iterator.next().glyph)
assertEquals(Glyph.LargeSymbol(LargeSymbol.UNITS_PER_HOUR), iterator.next().glyph)
assertEquals(Glyph.SmallDigit(1), iterator.next().glyph)
}
@Test
fun checkStandardBolusTokenization() {
// Look for tokens in the standard bolus display frame.
// The pattern matching algorithm scans the frame
// left to right, top to bottom, and tries the
// large patterns first.
// The standard bolus screen contains mostly letters,
// but also a LARGE_BOLUS symbol at the very bottom of
// the screen, thus testing that patterns are also properly
// matched if they are at a border.
val tokens = findTokens(testFrameStandardBolusMenuScreen)
assertEquals(14, tokens.size)
val iterator = tokens.iterator()
assertEquals(Glyph.SmallCharacter('S'), iterator.next().glyph)
assertEquals(Glyph.SmallCharacter('T'), iterator.next().glyph)
assertEquals(Glyph.SmallCharacter('A'), iterator.next().glyph)
assertEquals(Glyph.SmallCharacter('N'), iterator.next().glyph)
assertEquals(Glyph.SmallCharacter('D'), iterator.next().glyph)
assertEquals(Glyph.SmallCharacter('A'), iterator.next().glyph)
assertEquals(Glyph.SmallCharacter('R'), iterator.next().glyph)
assertEquals(Glyph.SmallCharacter('D'), iterator.next().glyph)
assertEquals(Glyph.SmallCharacter('B'), iterator.next().glyph)
assertEquals(Glyph.SmallCharacter('O'), iterator.next().glyph)
assertEquals(Glyph.SmallCharacter('L'), iterator.next().glyph)
assertEquals(Glyph.SmallCharacter('U'), iterator.next().glyph)
assertEquals(Glyph.SmallCharacter('S'), iterator.next().glyph)
assertEquals(Glyph.LargeSymbol(LargeSymbol.BOLUS), iterator.next().glyph)
}
}

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="info.nightscout.androidaps.plugins.pump.combov2">
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<application>
<activity
android:name="info.nightscout.androidaps.plugins.pump.combov2.activities.ComboV2PairingActivity"
android:label="@string/combov2_pair_with_pump_title"
android:theme="@style/AppTheme" />
</application>
</manifest>

View file

@ -0,0 +1,14 @@
package info.nightscout.androidaps.combov2.di
import dagger.Module
import dagger.android.ContributesAndroidInjector
import info.nightscout.androidaps.plugins.pump.combov2.ComboV2Fragment
import info.nightscout.androidaps.plugins.pump.combov2.activities.ComboV2PairingActivity
@Module
@Suppress("unused")
abstract class ComboV2ActivitiesModule {
@ContributesAndroidInjector abstract fun contributesComboV2PairingActivity(): ComboV2PairingActivity
@ContributesAndroidInjector abstract fun contributesComboV2Fragment(): ComboV2Fragment
}

View file

@ -0,0 +1,8 @@
package info.nightscout.androidaps.combov2.di
import dagger.Module
@Module(includes = [
ComboV2ActivitiesModule::class
])
open class ComboV2Module

View file

@ -0,0 +1,40 @@
package info.nightscout.androidaps.plugins.pump.combov2
import android.util.Log
import info.nightscout.comboctl.base.LogLevel
import info.nightscout.comboctl.base.LoggerBackend as ComboCtlLoggerBackend
import info.nightscout.rx.logging.AAPSLogger
import info.nightscout.rx.logging.LTag
internal class AAPSComboCtlLogger(private val aapsLogger: AAPSLogger) : ComboCtlLoggerBackend {
override fun log(tag: String, level: LogLevel, throwable: Throwable?, message: String?) {
val ltag = with (tag) {
when {
startsWith("Bluetooth") || startsWith("AndroidBluetooth") -> LTag.PUMPBTCOMM
endsWith("IO") -> LTag.PUMPCOMM
else -> LTag.PUMP
}
}
val fullMessage = "[$tag]" +
(if (throwable != null) " (${throwable::class.qualifiedName}: \"${throwable.message}\")" else "") +
(if (message != null) " $message" else "")
val stackInfo = Throwable().stackTrace[1]
val className = stackInfo.className.substringAfterLast(".")
val methodName = stackInfo.methodName
val lineNumber = stackInfo.lineNumber
when (level) {
// Log verbose content directly with Android's logger to not let this
// end up in AndroidAPS log files, which otherwise would quickly become
// very big, since verbose logging produces a lot of material.
LogLevel.VERBOSE -> Log.v(tag, message, throwable)
LogLevel.DEBUG -> aapsLogger.debug(className, methodName, lineNumber, ltag, fullMessage)
LogLevel.INFO -> aapsLogger.info(className, methodName, lineNumber, ltag, fullMessage)
LogLevel.WARN -> aapsLogger.warn(className, methodName, lineNumber, ltag, fullMessage)
LogLevel.ERROR -> aapsLogger.error(className, methodName, lineNumber, ltag, fullMessage)
}
}
}

View file

@ -0,0 +1,303 @@
package info.nightscout.androidaps.plugins.pump.combov2
import android.graphics.Color
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import dagger.android.support.DaggerFragment
import info.nightscout.androidaps.combov2.R
import info.nightscout.androidaps.combov2.databinding.Combov2FragmentBinding
import info.nightscout.comboctl.base.NullDisplayFrame
import info.nightscout.comboctl.main.Pump as ComboCtlPump
import info.nightscout.comboctl.base.Tbr as ComboCtlTbr
import info.nightscout.comboctl.parser.BatteryState
import info.nightscout.comboctl.parser.ReservoirState
import info.nightscout.interfaces.queue.CommandQueue
import info.nightscout.shared.interfaces.ResourceHelper
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlin.math.max
import java.util.Locale
import javax.inject.Inject
class ComboV2Fragment : DaggerFragment() {
@Inject lateinit var combov2Plugin: ComboV2Plugin
@Inject lateinit var rh: ResourceHelper
@Inject lateinit var commandQueue: CommandQueue
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding: Combov2FragmentBinding = DataBindingUtil.inflate(
inflater, R.layout.combov2_fragment, container, false)
val view = binding.root
binding.combov2RefreshButton.setOnClickListener {
binding.combov2RefreshButton.isEnabled = false
commandQueue.readStatus(rh.gs(R.string.user_request), null)
}
viewLifecycleOwner.lifecycleScope.launch {
// Start all of these flows with repeatOnLifecycle()
// which will automatically cancel the flows when
// the lifecycle reaches the STOPPED stage and
// re-runs the lambda (as a suspended function)
// when the lifecycle reaches the STARTED stage.
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
combov2Plugin.pairedStateUIFlow
.onEach { isPaired ->
binding.combov2FragmentUnpairedUi.visibility = if (isPaired) View.GONE else View.VISIBLE
binding.combov2FragmentMainUi.visibility = if (isPaired) View.VISIBLE else View.GONE
}
.launchIn(this)
combov2Plugin.driverStateUIFlow
.onEach { connectionState ->
val text = when (connectionState) {
ComboV2Plugin.DriverState.NotInitialized -> rh.gs(R.string.combov2_not_initialized)
ComboV2Plugin.DriverState.Disconnected -> rh.gs(R.string.disconnected)
ComboV2Plugin.DriverState.Connecting -> rh.gs(R.string.connecting)
ComboV2Plugin.DriverState.CheckingPump -> rh.gs(R.string.combov2_checking_pump)
ComboV2Plugin.DriverState.Ready -> rh.gs(R.string.combov2_ready)
ComboV2Plugin.DriverState.Suspended -> rh.gs(R.string.combov2_suspended)
ComboV2Plugin.DriverState.Error -> rh.gs(R.string.error)
is ComboV2Plugin.DriverState.ExecutingCommand ->
when (val desc = connectionState.description) {
is ComboCtlPump.GettingBasalProfileCommandDesc ->
rh.gs(R.string.combov2_getting_basal_profile_cmddesc)
is ComboCtlPump.SettingBasalProfileCommandDesc ->
rh.gs(R.string.combov2_setting_basal_profile_cmddesc)
is ComboCtlPump.SettingTbrCommandDesc ->
if (desc.percentage != 100)
rh.gs(R.string.combov2_setting_tbr_cmddesc, desc.percentage, desc.durationInMinutes)
else
rh.gs(R.string.combov2_cancelling_tbr)
is ComboCtlPump.DeliveringBolusCommandDesc ->
rh.gs(R.string.combov2_delivering_bolus_cmddesc, desc.bolusAmount.cctlBolusToIU())
is ComboCtlPump.FetchingTDDHistoryCommandDesc ->
rh.gs(R.string.combov2_fetching_tdd_history_cmddesc)
is ComboCtlPump.UpdatingPumpDateTimeCommandDesc ->
rh.gs(R.string.combov2_updating_pump_datetime_cmddesc)
is ComboCtlPump.UpdatingPumpStatusCommandDesc ->
rh.gs(R.string.combov2_updating_pump_status_cmddesc)
else -> rh.gs(R.string.combov2_executing_command)
}
}
binding.combov2DriverState.text = text
binding.combov2RefreshButton.isEnabled = when (connectionState) {
ComboV2Plugin.DriverState.Disconnected,
ComboV2Plugin.DriverState.Suspended -> true
else -> false
}
binding.combov2DriverState.setTextColor(
when (connectionState) {
ComboV2Plugin.DriverState.Error -> Color.RED
ComboV2Plugin.DriverState.Suspended -> Color.YELLOW
else -> Color.WHITE
}
)
}
.launchIn(this)
combov2Plugin.lastConnectionTimestampUIFlow
.onEach { lastConnectionTimestamp ->
updateLastConnectionField(lastConnectionTimestamp, binding)
}
.launchIn(this)
// This "Activity" is not to be confused with Android's "Activity" class.
combov2Plugin.currentActivityUIFlow
.onEach { currentActivity ->
if (currentActivity.description.isEmpty()) {
binding.combov2CurrentActivityDesc.text = rh.gs(R.string.combov2_no_activity)
binding.combov2CurrentActivityProgress.progress = 0
} else {
binding.combov2CurrentActivityDesc.text = currentActivity.description
binding.combov2CurrentActivityProgress.progress = (currentActivity.overallProgress * 100.0).toInt()
}
}
.launchIn(this)
combov2Plugin.batteryStateUIFlow
.onEach { batteryState ->
when (batteryState) {
null -> binding.combov2Battery.text = ""
BatteryState.NO_BATTERY -> {
binding.combov2Battery.text = rh.gs(R.string.combov2_battery_empty_indicator)
binding.combov2Battery.setTextColor(Color.RED)
}
BatteryState.LOW_BATTERY -> {
binding.combov2Battery.text = rh.gs(R.string.combov2_battery_low_indicator)
binding.combov2Battery.setTextColor(Color.YELLOW)
}
BatteryState.FULL_BATTERY -> {
binding.combov2Battery.text = rh.gs(R.string.combov2_battery_full_indicator)
binding.combov2Battery.setTextColor(Color.WHITE)
}
}
}
.launchIn(this)
combov2Plugin.reservoirLevelUIFlow
.onEach { reservoirLevel ->
binding.combov2Reservoir.text = if (reservoirLevel != null)
"${reservoirLevel.availableUnits} ${rh.gs(R.string.insulin_unit_shortname)}"
else
""
binding.combov2Reservoir.setTextColor(
when (reservoirLevel?.state) {
null -> Color.WHITE
ReservoirState.EMPTY -> Color.RED
ReservoirState.LOW -> Color.YELLOW
ReservoirState.FULL -> Color.WHITE
}
)
}
.launchIn(this)
combov2Plugin.lastBolusUIFlow
.onEach { lastBolus ->
updateLastBolusField(lastBolus, binding)
}
.launchIn(this)
combov2Plugin.currentTbrUIFlow
.onEach { tbr ->
updateCurrentTbrField(tbr, binding)
}
.launchIn(this)
combov2Plugin.baseBasalRateUIFlow
.onEach { baseBasalRate ->
binding.combov2BaseBasalRate.text = if (baseBasalRate != null)
rh.gs(R.string.pump_basebasalrate, baseBasalRate)
else
""
}
.launchIn(this)
combov2Plugin.serialNumberUIFlow
.onEach { serialNumber ->
binding.combov2PumpId.text = serialNumber
}
.launchIn(this)
combov2Plugin.bluetoothAddressUIFlow
.onEach { bluetoothAddress ->
binding.combov2BluetoothAddress.text = bluetoothAddress.uppercase(Locale.ROOT)
}
.launchIn(this)
combov2Plugin.displayFrameUIFlow
.onEach { displayFrame ->
binding.combov2RtDisplayFrame.displayFrame = displayFrame ?: NullDisplayFrame
}
.launchIn(this)
launch {
while (true) {
delay(30 * 1000L) // Wait for 30 seconds
updateLastConnectionField(combov2Plugin.lastConnectionTimestampUIFlow.value, binding)
updateLastBolusField(combov2Plugin.lastBolusUIFlow.value, binding)
updateCurrentTbrField(combov2Plugin.currentTbrUIFlow.value, binding)
}
}
}
}
return view
}
private fun updateLastConnectionField(lastConnectionTimestamp: Long?, binding: Combov2FragmentBinding) {
val currentTimestamp = System.currentTimeMillis()
// If the last connection is >= 30 minutes ago,
// we display a different message, one that
// warns the user that a long time passed
when (val secondsPassed = lastConnectionTimestamp?.let { (currentTimestamp - it) / 1000 }) {
null ->
binding.combov2LastConnection.text = ""
in 0..60 -> {
binding.combov2LastConnection.text = rh.gs(R.string.combov2_less_than_one_minute_ago)
binding.combov2LastConnection.setTextColor(Color.WHITE)
}
in 60..(30 * 60) -> {
binding.combov2LastConnection.text = rh.gs(R.string.minago, secondsPassed / 60)
binding.combov2LastConnection.setTextColor(Color.WHITE)
}
else -> {
binding.combov2LastConnection.text = rh.gs(R.string.combov2_no_connection_for_n_mins, secondsPassed / 60)
binding.combov2LastConnection.setTextColor(Color.RED)
}
}
}
private fun updateLastBolusField(lastBolus: ComboCtlPump.LastBolus?, binding: Combov2FragmentBinding) {
val currentTimestamp = System.currentTimeMillis()
if (lastBolus == null) {
binding.combov2LastBolus.text = ""
return
}
// If the last bolus is >= 30 minutes ago,
// we display a different message, one that
// warns the user that a long time passed
val bolusAgoText = when (val secondsPassed = (currentTimestamp - lastBolus.timestamp.toEpochMilliseconds()) / 1000) {
in 0..59 ->
rh.gs(R.string.combov2_less_than_one_minute_ago)
else ->
rh.gs(R.string.minago, secondsPassed / 60)
}
binding.combov2LastBolus.text =
rh.gs(
R.string.combov2_last_bolus,
lastBolus.bolusAmount.cctlBolusToIU(),
rh.gs(R.string.insulin_unit_shortname),
bolusAgoText
)
}
private fun updateCurrentTbrField(currentTbr: ComboCtlTbr?, binding: Combov2FragmentBinding) {
val currentTimestamp = System.currentTimeMillis()
if (currentTbr == null) {
binding.combov2CurrentTbr.text = ""
return
}
val remainingSeconds = max(
currentTbr.durationInMinutes * 60 - (currentTimestamp - currentTbr.timestamp.toEpochMilliseconds()) / 1000,
0
)
binding.combov2CurrentTbr.text =
if (remainingSeconds >= 60)
rh.gs(
R.string.combov2_current_tbr,
currentTbr.percentage,
remainingSeconds / 60
)
else
rh.gs(
R.string.combov2_current_tbr_less_than_1min,
currentTbr.percentage
)
}
}

View file

@ -0,0 +1,84 @@
package info.nightscout.androidaps.plugins.pump.combov2
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.util.AttributeSet
import android.view.View
import info.nightscout.comboctl.base.DISPLAY_FRAME_HEIGHT
import info.nightscout.comboctl.base.DISPLAY_FRAME_WIDTH
import info.nightscout.comboctl.base.DisplayFrame
import info.nightscout.comboctl.base.NUM_DISPLAY_FRAME_PIXELS
import info.nightscout.comboctl.base.NullDisplayFrame
/**
* Custom [View] to show a Combo remote terminal display frame on the UI.
*
* The [DisplayFrame] is shown on the UI via [Canvas]. To do that, the frame
* is converted to a [Bitmap], and then that bitmap is rendered on the UI.
*
* Callers pass new frames to the view by setting the [displayFrame] property.
* The frame -> bitmap conversion happens on-demand, when [onDraw] is called.
* That way, if this view is not shown on the UI (because for example the
* associated fragment is not visible at the moment), no unnecessary conversions
* are performed, saving computational effort.
*
* The frame is drawn unsmoothed to better mimic the Combo's LCD.
*/
internal class ComboV2RTDisplayFrameView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private companion object {
const val BACKGROUND_SHADE = 0xB5
const val FOREGROUND_SHADE = 0x20
}
private val bitmap = Bitmap.createBitmap(DISPLAY_FRAME_WIDTH, DISPLAY_FRAME_HEIGHT, Bitmap.Config.ARGB_8888, false)
private val bitmapPixels = IntArray(NUM_DISPLAY_FRAME_PIXELS) { BACKGROUND_SHADE }
private val bitmapPaint = Paint().apply {
style = Paint.Style.FILL
// These are necessary to ensure nearest neighbor scaling.
isAntiAlias = false
isFilterBitmap = false
}
private val bitmapRect = Rect()
private var isNewDisplayFrame = true
var displayFrame = NullDisplayFrame
set(value) {
field = value
isNewDisplayFrame = true
// Necessary to inform Android that during
// the next UI update it should call onDraw().
invalidate()
}
override fun onDraw(canvas: Canvas) {
updateBitmap()
canvas.drawBitmap(bitmap, null, bitmapRect, bitmapPaint)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
bitmapRect.set(0, 0, w, h)
}
private fun updateBitmap() {
if (!isNewDisplayFrame)
return
for (pixelIdx in 0 until NUM_DISPLAY_FRAME_PIXELS) {
val srcPixel = if (displayFrame[pixelIdx]) FOREGROUND_SHADE else BACKGROUND_SHADE
bitmapPixels[pixelIdx] = Color.argb(0xFF, srcPixel, srcPixel, srcPixel)
}
bitmap.setPixels(bitmapPixels, 0, DISPLAY_FRAME_WIDTH, 0, 0, DISPLAY_FRAME_WIDTH, DISPLAY_FRAME_HEIGHT)
isNewDisplayFrame = false
}
}

View file

@ -0,0 +1,165 @@
package info.nightscout.androidaps.plugins.pump.combov2
import info.nightscout.comboctl.base.BluetoothAddress
import info.nightscout.comboctl.base.CurrentTbrState
import info.nightscout.comboctl.base.InvariantPumpData
import info.nightscout.comboctl.base.Nonce
import info.nightscout.comboctl.base.PumpStateStore
import info.nightscout.comboctl.base.Tbr
import info.nightscout.comboctl.base.toBluetoothAddress
import info.nightscout.comboctl.base.toCipher
import info.nightscout.comboctl.base.toNonce
import info.nightscout.shared.sharedPreferences.SP
import info.nightscout.shared.sharedPreferences.SPDelegateInt
import info.nightscout.shared.sharedPreferences.SPDelegateLong
import info.nightscout.shared.sharedPreferences.SPDelegateString
import kotlinx.datetime.Instant
import kotlinx.datetime.UtcOffset
/**
* AndroidAPS [SP] based pump state store.
*
* This store is set up to contain a single paired pump. AndroidAPS is not
* designed to handle multiple pumps, so this simplification makes sense.
* This affects all accessors, which
*/
class SPPumpStateStore(private val sp: SP) : PumpStateStore {
private var btAddress: String
by SPDelegateString(sp, BT_ADDRESS_KEY, "")
// The nonce is updated with commit instead of apply to make sure
// is atomically written to storage synchronously, minimizing
// the likelihood that it could be lost due to app crashes etc.
// It is very important to not lose the nonce, hence that choice.
private var nonceString: String
by SPDelegateString(sp, NONCE_KEY, Nonce.nullNonce().toString(), commit = true)
private var cpCipherString: String
by SPDelegateString(sp, CP_CIPHER_KEY, "")
private var pcCipherString: String
by SPDelegateString(sp, PC_CIPHER_KEY, "")
private var keyResponseAddressInt: Int
by SPDelegateInt(sp, KEY_RESPONSE_ADDRESS_KEY, 0)
private var pumpID: String
by SPDelegateString(sp, PUMP_ID_KEY, "")
private var tbrTimestamp: Long
by SPDelegateLong(sp, TBR_TIMESTAMP_KEY, 0)
private var tbrPercentage: Int
by SPDelegateInt(sp, TBR_PERCENTAGE_KEY, 0)
private var tbrDuration: Int
by SPDelegateInt(sp, TBR_DURATION_KEY, 0)
private var tbrType: String
by SPDelegateString(sp, TBR_TYPE_KEY, "")
private var utcOffsetSeconds: Int
by SPDelegateInt(sp, UTC_OFFSET_KEY, 0)
override fun createPumpState(
pumpAddress: BluetoothAddress,
invariantPumpData: InvariantPumpData,
utcOffset: UtcOffset,
tbrState: CurrentTbrState
) {
// Write these values via edit() instead of using the delegates
// above to be able to write all of them with a single commit.
sp.edit(commit = true) {
putString(BT_ADDRESS_KEY, pumpAddress.toString().uppercase())
putString(CP_CIPHER_KEY, invariantPumpData.clientPumpCipher.toString())
putString(PC_CIPHER_KEY, invariantPumpData.pumpClientCipher.toString())
putInt(KEY_RESPONSE_ADDRESS_KEY, invariantPumpData.keyResponseAddress.toInt() and 0xFF)
putString(PUMP_ID_KEY, invariantPumpData.pumpID)
putLong(TBR_TIMESTAMP_KEY, if (tbrState is CurrentTbrState.TbrStarted) tbrState.tbr.timestamp.epochSeconds else -1)
putInt(TBR_PERCENTAGE_KEY, if (tbrState is CurrentTbrState.TbrStarted) tbrState.tbr.percentage else -1)
putInt(TBR_DURATION_KEY, if (tbrState is CurrentTbrState.TbrStarted) tbrState.tbr.durationInMinutes else -1)
putString(TBR_TYPE_KEY, if (tbrState is CurrentTbrState.TbrStarted) tbrState.tbr.type.stringId else "")
putInt(UTC_OFFSET_KEY, utcOffset.totalSeconds)
}
}
override fun deletePumpState(pumpAddress: BluetoothAddress): Boolean {
val hasState = sp.contains(NONCE_KEY)
sp.edit(commit = true) {
remove(BT_ADDRESS_KEY)
remove(NONCE_KEY)
remove(CP_CIPHER_KEY)
remove(PC_CIPHER_KEY)
remove(KEY_RESPONSE_ADDRESS_KEY)
remove(TBR_TIMESTAMP_KEY)
remove(TBR_PERCENTAGE_KEY)
remove(TBR_DURATION_KEY)
remove(TBR_TYPE_KEY)
remove(UTC_OFFSET_KEY)
}
return hasState
}
override fun hasPumpState(pumpAddress: BluetoothAddress) =
sp.contains(NONCE_KEY)
override fun getAvailablePumpStateAddresses() =
if (btAddress.isBlank()) setOf() else setOf(btAddress.toBluetoothAddress())
override fun getInvariantPumpData(pumpAddress: BluetoothAddress) = InvariantPumpData(
clientPumpCipher = cpCipherString.toCipher(),
pumpClientCipher = pcCipherString.toCipher(),
keyResponseAddress = keyResponseAddressInt.toByte(),
pumpID = pumpID
)
override fun getCurrentTxNonce(pumpAddress: BluetoothAddress) = nonceString.toNonce()
override fun setCurrentTxNonce(pumpAddress: BluetoothAddress, currentTxNonce: Nonce) {
nonceString = currentTxNonce.toString()
}
override fun getCurrentUtcOffset(pumpAddress: BluetoothAddress) =
UtcOffset(seconds = utcOffsetSeconds)
override fun setCurrentUtcOffset(pumpAddress: BluetoothAddress, utcOffset: UtcOffset) {
utcOffsetSeconds = utcOffset.totalSeconds
}
override fun getCurrentTbrState(pumpAddress: BluetoothAddress) =
if (tbrTimestamp >= 0)
CurrentTbrState.TbrStarted(Tbr(
timestamp = Instant.fromEpochSeconds(tbrTimestamp),
percentage = tbrPercentage,
durationInMinutes = tbrDuration,
type = Tbr.Type.fromStringId(tbrType)!!
))
else
CurrentTbrState.NoTbrOngoing
override fun setCurrentTbrState(pumpAddress: BluetoothAddress, currentTbrState: CurrentTbrState) {
when (currentTbrState) {
is CurrentTbrState.TbrStarted -> {
tbrTimestamp = currentTbrState.tbr.timestamp.epochSeconds
tbrPercentage = currentTbrState.tbr.percentage
tbrDuration = currentTbrState.tbr.durationInMinutes
tbrType = currentTbrState.tbr.type.stringId
}
else -> {
tbrTimestamp = -1
tbrPercentage = -1
tbrDuration = -1
tbrType = ""
}
}
}
companion object {
const val BT_ADDRESS_KEY = "combov2-bt-address-key"
const val NONCE_KEY = "combov2-nonce-key"
const val CP_CIPHER_KEY = "combov2-cp-cipher-key"
const val PC_CIPHER_KEY = "combov2-pc-cipher-key"
const val KEY_RESPONSE_ADDRESS_KEY = "combov2-key-response-address-key"
const val PUMP_ID_KEY = "combov2-pump-id-key"
const val TBR_TIMESTAMP_KEY = "combov2-tbr-timestamp"
const val TBR_PERCENTAGE_KEY = "combov2-tbr-percentage"
const val TBR_DURATION_KEY = "combov2-tbr-duration"
const val TBR_TYPE_KEY = "combov2-tbr-type"
const val UTC_OFFSET_KEY = "combov2-utc-offset"
}
}

View file

@ -0,0 +1,25 @@
package info.nightscout.androidaps.plugins.pump.combov2
import info.nightscout.comboctl.main.BasalProfile
import info.nightscout.comboctl.main.NUM_COMBO_BASAL_PROFILE_FACTORS
import info.nightscout.interfaces.profile.Profile as AAPSProfile
// Utility extension functions for clearer conversion between
// ComboCtl units and AAPS units. ComboCtl uses integer-encoded
// decimals. For basal values, the last 3 digits of an integer
// make up the decimal, so for example, 1568 actually means
// 1.568 IU, and 419 means 0.419 IU etc. Similarly, for bolus
// values, the last digit makes up the decimal. 57 means 5.7 IU,
// 4 means 0.4 IU etc.
// To emphasize better that such a conversion is taking place,
// these extension functions are put in place.
internal fun Int.cctlBasalToIU() = this.toDouble() / 1000.0
internal fun Int.cctlBolusToIU() = this.toDouble() / 10.0
internal fun Double.iuToCctlBolus() = (this * 10.0).toInt()
fun AAPSProfile.toComboCtlBasalProfile(): BasalProfile {
val factors = IntRange(0, NUM_COMBO_BASAL_PROFILE_FACTORS - 1).map { hour ->
(this.getBasalTimeFromMidnight(hour * 60 * 60) * 1000.0).toInt()
}
return BasalProfile(factors)
}

View file

@ -0,0 +1,242 @@
package info.nightscout.androidaps.plugins.pump.combov2.activities
import android.app.Activity
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import info.nightscout.androidaps.activities.NoSplashAppCompatActivity
import info.nightscout.androidaps.combov2.R
import info.nightscout.androidaps.combov2.databinding.Combov2PairingActivityBinding
import info.nightscout.androidaps.plugins.pump.combov2.ComboV2Plugin
import info.nightscout.comboctl.base.BasicProgressStage
import info.nightscout.comboctl.base.PairingPIN
import info.nightscout.core.ui.dialogs.OKDialog
import info.nightscout.rx.logging.LTag
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
class ComboV2PairingActivity : NoSplashAppCompatActivity() {
@Inject lateinit var combov2Plugin: ComboV2Plugin
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Install an activity result caller for when the user presses
// "deny" or "reject" in the dialog that pops up when Android
// asks for permission to enable device discovery. In such a
// case, without this caller, the logic would continue to look
// for devices even though discovery isn't actually happening.
// With this caller, we can cancel pairing in this case instead.
val startPairingActivityLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
when (result.resultCode) {
Activity.RESULT_CANCELED -> {
aapsLogger.info(LTag.PUMP, "User rejected discovery request; cancelling pairing")
combov2Plugin.cancelPairing()
}
else -> Unit
}
}
combov2Plugin.customDiscoveryActivityStartCallback = { intent ->
startPairingActivityLauncher.launch(intent)
}
val binding: Combov2PairingActivityBinding = DataBindingUtil.setContentView(
this, R.layout.combov2_pairing_activity)
binding.combov2PairingFinishedOk.setOnClickListener {
finish()
}
binding.combov2PairingAborted.setOnClickListener {
finish()
}
val pinFormatRegex = "(\\d{1,3})(\\d{1,3})?(\\d{1,4})?".toRegex()
val nonDigitsRemovalRegex = "\\D".toRegex()
val whitespaceRemovalRegex = "\\s".toRegex()
// Add a custom TextWatcher to format the PIN in the
// same format it is shown on the Combo LCD, which is:
//
// xxx xxx xxxx
binding.combov2PinEntryEdit.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
// Nothing needs to be done here; overridden method only exists
// to properly and fully implement the TextWatcher interface.
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
// Nothing needs to be done here; overridden method only exists
// to properly and fully implement the TextWatcher interface.
}
override fun afterTextChanged(s: Editable?) {
if (s == null)
return
val originalText = s.toString()
val trimmedText = originalText.trim().replace(nonDigitsRemovalRegex, "")
val digitGroupValues = pinFormatRegex.matchEntire(trimmedText)?.let { matchResult ->
// Get the digit groups. Skip the first group, which contains the entire original string.
if (matchResult.groupValues.isEmpty())
listOf()
else
matchResult.groupValues.subList(1, matchResult.groupValues.size)
} ?: listOf()
// Join the groups to a string with a whitespace in between to construct
// a correct PIN string (see the format above). Skip empty groups to
// not have a trailing whitespace.
val processedText = digitGroupValues.filter { it.isNotEmpty() }.joinToString(" ")
if (originalText != processedText) {
// Remove and add this listener to modify the text
// without causing an infinite loop (text is changed,
// listener is called, listener changes text).
binding.combov2PinEntryEdit.removeTextChangedListener(this)
// Shift the cursor position to skip the whitespaces.
val cursorPosition = when (val it = binding.combov2PinEntryEdit.selectionStart) {
4 -> 5
8 -> 9
else -> it
}
binding.combov2PinEntryEdit.setText(processedText)
binding.combov2PinEntryEdit.setSelection(cursorPosition)
binding.combov2PinEntryEdit.addTextChangedListener(this)
}
}
})
binding.combov2EnterPin.setOnClickListener {
// We need to skip whitespaces since the
// TextWatcher above inserts some.
val pinString = binding.combov2PinEntryEdit.text.replace(whitespaceRemovalRegex, "")
runBlocking {
val PIN = PairingPIN(pinString.map { it - '0' }.toIntArray())
combov2Plugin.providePairingPIN(PIN)
}
}
binding.combov2StartPairing.setOnClickListener {
combov2Plugin.startPairing()
}
binding.combov2CancelPairing.setOnClickListener {
OKDialog.showConfirmation(this, "Confirm pairing cancellation", "Do you really want to cancel pairing?", ok = Runnable {
combov2Plugin.cancelPairing()
})
}
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
combov2Plugin.getPairingProgressFlow()
.onEach { progressReport ->
val stage = progressReport.stage
binding.combov2PairingSectionInitial.visibility =
if (stage == BasicProgressStage.Idle) View.VISIBLE else View.GONE
binding.combov2PairingSectionFinished.visibility =
if (stage == BasicProgressStage.Finished) View.VISIBLE else View.GONE
binding.combov2PairingSectionAborted.visibility =
if (stage is BasicProgressStage.Aborted) View.VISIBLE else View.GONE
binding.combov2PairingSectionMain.visibility = when (stage) {
BasicProgressStage.Idle,
BasicProgressStage.Finished,
is BasicProgressStage.Aborted -> View.GONE
else -> View.VISIBLE
}
if (stage is BasicProgressStage.Aborted) {
binding.combov2PairingAbortedReasonText.text = when (stage) {
is BasicProgressStage.Cancelled -> rh.gs(R.string.combov2_pairing_cancelled)
is BasicProgressStage.Timeout -> rh.gs(R.string.combov2_pairing_combo_scan_timeout_reached)
is BasicProgressStage.Error -> rh.gs(R.string.combov2_pairing_failed_due_to_error, stage.cause.toString())
else -> rh.gs(R.string.combov2_pairing_aborted_unknown_reasons)
}
}
binding.combov2CurrentPairingStepDesc.text = when (val progStage = stage) {
BasicProgressStage.ScanningForPumpStage ->
rh.gs(R.string.combov2_scanning_for_pump)
is BasicProgressStage.EstablishingBtConnection -> {
rh.gs(
R.string.combov2_establishing_bt_connection,
progStage.currentAttemptNr
)
}
BasicProgressStage.PerformingConnectionHandshake ->
rh.gs(R.string.combov2_pairing_performing_handshake)
BasicProgressStage.ComboPairingKeyAndPinRequested ->
rh.gs(R.string.combov2_pairing_pump_requests_pin)
BasicProgressStage.ComboPairingFinishing ->
rh.gs(R.string.combov2_pairing_finishing)
else -> ""
}
if (stage == BasicProgressStage.ComboPairingKeyAndPinRequested) {
binding.combov2PinEntryUi.visibility = View.VISIBLE
} else
binding.combov2PinEntryUi.visibility = View.INVISIBLE
// Scanning for the pump can take a long time and happens at the
// beginning, so set the progress bar to indeterminate during that
// time to show _something_ to the user.
binding.combov2PairingProgressBar.isIndeterminate =
(stage == BasicProgressStage.ScanningForPumpStage)
binding.combov2PairingProgressBar.progress = (progressReport.overallProgress * 100).toInt()
}
.launchIn(this)
combov2Plugin.previousPairingAttemptFailedFlow
.onEach { previousAttemptFailed ->
binding.combov2PinFailureIndicator.visibility =
if (previousAttemptFailed) View.VISIBLE else View.INVISIBLE
}
.launchIn(this)
}
}
}
override fun onBackPressed() {
aapsLogger.info(LTag.PUMP, "User pressed the back button; cancelling any ongoing pairing")
combov2Plugin.cancelPairing()
super.onBackPressed()
}
override fun onDestroy() {
// Reset the pairing progress reported to allow for future pairing attempts.
// Do this only after pairing was finished or aborted. onDestroy() can be
// called in the middle of a pairing process, and we do not want to reset
// the progress reporter mid-pairing.
when (combov2Plugin.getPairingProgressFlow().value.stage) {
BasicProgressStage.Finished,
is BasicProgressStage.Aborted -> {
aapsLogger.debug(
LTag.PUMP,
"Resetting pairing progress reporter after pairing was finished/aborted"
)
combov2Plugin.resetPairingProgress()
}
else -> Unit
}
super.onDestroy()
}
}

View file

@ -0,0 +1,107 @@
<vector android:height="100dp" android:viewportHeight="300"
android:viewportWidth="600" android:width="200dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#464646" android:pathData="M488.195,157.927C458.285,58.052 466.785,-0.052 335.939,2.361L88.761,2.361C85.492,2.361 82.84,4.991 82.84,8.23L82.84,290.011C82.84,293.25 85.492,295.881 88.761,295.881C224.79,295.794 375.022,292.871 492.871,293.984C491.295,246.05 499.821,196.746 488.195,157.927Z"/>
<path android:fillAlpha="0.53" android:fillColor="#FF000000" android:pathData="M490.739,286.213l4.139,0l0,7.322l-4.139,0z"/>
<path android:fillColor="#464646" android:pathData="M322.836,2.36l108.727,0l0,13.274l-108.727,0z"/>
<path android:fillColor="#464646" android:pathData="M443.395,10.164C441.656,4.554 435.686,1.415 430.077,3.153L409.749,9.456C404.141,11.195 401,17.16 402.739,22.77L442.928,152.381C444.667,157.991 450.632,161.132 456.24,159.393L476.568,153.09C482.176,151.351 485.323,145.385 483.583,139.775L443.395,10.164Z"/>
<path android:fillColor="#464646" android:pathData="M483.813,149.316L477.072,122.488L484.009,125.023L488.728,143.803L483.813,149.316Z"/>
<path android:fillColor="#464646" android:pathData="M462.843,73.912L454.367,47.445L462.119,49.339L468.052,67.866L462.843,73.912Z"/>
<path android:fillColor="#FF000000" android:pathData="M447.001,23.469l-1.412,0.425l42.517,141.237l1.412,-0.425z"/>
<path android:fillColor="#FF000000" android:pathData="M82.838,19.618l309.745,0l0,1.229l-309.745,0z"/>
<path android:fillColor="#FF000000" android:pathData="M389.994,19.301L393.142,19.846C432.527,43.973 466.678,79.349 473.136,127.873C464.91,78.173 429.623,43.999 389.994,19.301Z"/>
<path android:fillColor="#464646" android:pathData="M82.628,3.577L74.877,3.577C67.434,3.577 61.392,9.62 61.392,17.063L61.392,285.37C61.392,291.171 66.101,295.881 71.902,295.881L82.628,295.881L82.628,3.577Z"/>
<path android:fillColor="#FF000000" android:pathData="M82.628,4.888l1.475,0l0,289.243l-1.475,0z"/>
<path android:fillAlpha="0.46" android:fillColor="#6B6B6B" android:pathData="M83.149,21.136l3.686,0l0,229.606l-3.686,0z"/>
<path android:fillColor="#464646" android:pathData="M538.606,209.214C538.606,202.737 533.2,197.48 526.544,197.48L490.353,197.48L491.251,294.196L526.544,291.577C533.2,291.577 538.606,286.318 538.606,279.841L538.606,209.214Z"/>
<path android:fillColor="#FF000000" android:pathData="M495.834,198.897C495.834,198.24 495.301,197.705 494.642,197.705L492.254,197.705C491.594,197.705 491.059,198.24 491.059,198.897L491.059,292.711C491.059,293.369 491.594,293.906 492.254,293.906L494.642,293.906C495.301,293.906 495.834,293.369 495.834,292.711L495.834,198.897Z"/>
<path android:fillColor="#545050" android:pathData="M536.077,203.484C536.077,202.147 534.991,201.061 533.655,201.061L504.182,201.061C502.845,201.061 501.759,202.147 501.759,203.484L501.759,208.331C501.759,209.668 502.845,210.754 504.182,210.754L533.655,210.754C534.991,210.754 536.077,209.668 536.077,208.331L536.077,203.484Z"/>
<path android:fillColor="#545050" android:pathData="M538.608,231.773C538.608,229.941 537.122,228.454 535.29,228.454L504.21,228.454C502.378,228.454 500.892,229.941 500.892,231.773L500.892,238.41C500.892,240.241 502.378,241.729 504.21,241.729L535.29,241.729C537.122,241.729 538.608,240.241 538.608,238.41L538.608,231.773Z"/>
<path android:fillColor="#545050" android:pathData="M537.764,264.485C537.764,262.857 536.441,261.535 534.814,261.535L502.784,261.535C501.158,261.535 499.835,262.857 499.835,264.485L499.835,270.385C499.835,272.013 501.158,273.335 502.784,273.335L534.814,273.335C536.441,273.335 537.764,272.013 537.764,270.385L537.764,264.485Z"/>
<path android:fillColor="#FF000000" android:pathData="M459.763,145.568C437.91,77.95 426.306,39.41 352.331,37.453C279.374,35.524 205.483,35.819 131.222,36.585C110.191,36.585 93.118,52.819 93.118,72.814C91.55,117.388 91.429,161.962 93.333,206.535C93.989,239.028 111.184,261.849 151.431,262.099C237.913,262.636 332.049,261.573 422.107,262.262C462.95,262.574 481.669,247.844 473.212,208.058L459.763,145.568Z"/>
<path android:fillColor="#CACACA" android:pathData="M454.941,151.462C434.56,88.634 430.075,44.303 350.464,42.249C280.002,40.643 207.689,40.589 136.393,41.307C116.203,41.307 99.811,56.533 99.811,75.286C98.021,117.701 97.352,159.481 99.811,201.896C101.829,236.749 116.977,255.499 158.302,255.333L406.507,254.706C455.731,256.278 475.142,251.85 466.576,202.533L454.941,151.462Z"/>
<path android:fillColor="#3C3C3C" android:pathData="M442.779,148.348C423.733,93.076 419.542,54.077 345.145,52.27C279.298,50.857 211.722,50.809 145.095,51.441C126.227,51.441 110.909,64.836 110.909,81.334L110.263,192.954C109.879,222.358 124.59,241.815 164.918,240.814L397.517,239.176C443.518,240.559 461.657,236.663 453.652,193.277L442.779,148.348Z"/>
<path android:fillColor="#ffffff" android:fillType="nonZero" android:pathData="M205.273,82.727L205.38,82.21C205.703,82.189 205.961,82.103 206.133,81.931C206.305,81.78 206.434,81.565 206.499,81.307C206.585,81.027 206.628,80.662 206.628,80.21C206.628,79.952 206.606,79.651 206.585,79.328L206.52,77.973L201.895,77.973L201.315,79.005C201.035,79.479 200.841,79.887 200.712,80.253C200.583,80.597 200.519,80.898 200.519,81.156C200.519,81.759 200.884,82.103 201.594,82.21L201.465,82.727L197.185,82.727L197.292,82.21C197.507,82.189 197.722,82.103 197.916,81.952C198.088,81.802 198.303,81.587 198.518,81.307C198.733,81.006 199.035,80.533 199.443,79.866L206.176,68.229L207.746,68.229L208.499,79.93C208.542,80.49 208.607,80.92 208.693,81.221C208.779,81.544 208.908,81.78 209.08,81.931C209.23,82.081 209.467,82.167 209.768,82.21L209.639,82.727L205.273,82.727ZM202.369,77.048L206.477,77.048L206.283,73.671C206.262,73.219 206.262,72.638 206.24,71.95C206.219,71.24 206.219,70.746 206.219,70.444L206.111,70.444L202.369,77.048Z"/>
<path android:fillColor="#ffffff" android:fillType="nonZero" android:pathData="M222.455,68.767L221.831,71.606L220.82,71.606C220.755,70.746 220.583,70.122 220.261,69.735C219.938,69.347 219.422,69.175 218.712,69.175C218.131,69.175 217.572,69.347 217.056,69.692C216.539,70.036 216.045,70.53 215.593,71.197C215.12,71.843 214.733,72.617 214.388,73.499C214.044,74.359 213.765,75.263 213.593,76.188C213.42,77.091 213.313,77.908 213.313,78.661C213.313,79.801 213.506,80.64 213.872,81.156C214.259,81.694 214.84,81.974 215.636,81.974C216.174,81.974 216.625,81.888 217.013,81.737C217.4,81.587 217.787,81.307 218.11,80.941C218.454,80.554 218.798,80.038 219.142,79.371L220.153,79.371L219.508,82.339C218.798,82.555 218.11,82.684 217.443,82.77C216.776,82.856 216.088,82.899 215.399,82.899C214.517,82.899 213.786,82.705 213.162,82.361C212.56,81.995 212.108,81.479 211.807,80.791C211.485,80.102 211.334,79.285 211.334,78.317C211.334,77.822 211.377,77.328 211.442,76.854C211.528,76.274 211.657,75.628 211.829,74.94C212.065,74.08 212.366,73.284 212.711,72.574C213.098,71.821 213.528,71.176 214.001,70.595C214.453,70.057 214.948,69.627 215.485,69.261C215.98,68.939 216.496,68.681 217.077,68.487C217.636,68.315 218.239,68.229 218.884,68.229C219.508,68.229 220.11,68.25 220.648,68.336C221.186,68.422 221.788,68.552 222.455,68.767Z"/>
<path android:fillColor="#ffffff" android:fillType="nonZero" android:pathData="M233.746,68.767L233.122,71.606L232.111,71.606C232.046,70.746 231.874,70.122 231.552,69.735C231.229,69.347 230.713,69.175 230.003,69.175C229.422,69.175 228.863,69.347 228.347,69.692C227.83,70.036 227.336,70.53 226.884,71.197C226.411,71.843 226.024,72.617 225.679,73.499C225.335,74.359 225.056,75.263 224.883,76.188C224.711,77.091 224.604,77.908 224.604,78.661C224.604,79.801 224.797,80.64 225.163,81.156C225.55,81.694 226.131,81.974 226.927,81.974C227.465,81.974 227.916,81.888 228.304,81.737C228.691,81.587 229.078,81.307 229.401,80.941C229.745,80.554 230.089,80.038 230.433,79.371L231.444,79.371L230.799,82.339C230.089,82.555 229.401,82.684 228.734,82.77C228.067,82.856 227.379,82.899 226.69,82.899C225.808,82.899 225.077,82.705 224.453,82.361C223.851,81.995 223.399,81.479 223.098,80.791C222.775,80.102 222.625,79.285 222.625,78.317C222.625,77.822 222.668,77.328 222.732,76.854C222.819,76.274 222.948,75.628 223.12,74.94C223.356,74.08 223.657,73.284 224.002,72.574C224.389,71.821 224.819,71.176 225.292,70.595C225.744,70.057 226.239,69.627 226.776,69.261C227.271,68.939 227.787,68.681 228.368,68.487C228.927,68.315 229.53,68.229 230.175,68.229C230.799,68.229 231.401,68.25 231.939,68.336C232.477,68.422 233.079,68.552 233.746,68.767Z"/>
<path android:fillColor="#ffffff" android:fillType="nonZero" android:pathData="M235.65,68.896L235.757,68.379L239.995,68.379L239.866,68.896C239.608,68.96 239.414,69.025 239.306,69.089C239.177,69.154 239.07,69.261 238.984,69.412C238.898,69.541 238.79,69.778 238.704,70.057C238.597,70.358 238.489,70.853 238.339,71.52L237.026,77.392C236.94,77.801 236.854,78.188 236.811,78.532C236.747,78.898 236.725,79.285 236.725,79.651C236.725,80.425 236.919,81.006 237.285,81.393C237.672,81.78 238.231,81.974 238.984,81.974C239.694,81.974 240.296,81.823 240.769,81.544C241.242,81.242 241.63,80.812 241.909,80.275C242.21,79.715 242.447,79.005 242.641,78.124L244.103,71.52C244.168,71.197 244.232,70.896 244.275,70.595C244.318,70.294 244.34,70.036 244.34,69.821C244.34,69.498 244.254,69.261 244.082,69.132C243.91,69.003 243.652,68.917 243.307,68.896L243.415,68.379L247.416,68.379L247.287,68.896C247.029,68.96 246.857,69.025 246.727,69.089C246.598,69.175 246.491,69.283 246.405,69.412C246.297,69.563 246.211,69.778 246.125,70.079C246.039,70.38 245.91,70.853 245.759,71.52L244.404,77.801C244.125,79.07 243.738,80.059 243.243,80.812C242.748,81.544 242.124,82.081 241.371,82.404C240.64,82.748 239.715,82.899 238.64,82.899C237.435,82.899 236.51,82.619 235.865,82.038C235.22,81.436 234.897,80.597 234.897,79.479C234.897,79.07 234.94,78.618 235.004,78.124C235.09,77.607 235.198,77.048 235.349,76.403L236.446,71.52C236.532,71.133 236.596,70.81 236.639,70.509C236.661,70.229 236.682,69.993 236.682,69.821C236.682,69.498 236.618,69.283 236.446,69.132C236.295,69.003 236.015,68.917 235.65,68.896Z"/>
<path android:fillColor="#ffffff" android:fillType="nonZero" android:pathData="M252.177,78.016L247.596,78.016L247.94,76.381L252.522,76.381L252.177,78.016Z"/>
<path android:fillColor="#ffffff" android:fillType="nonZero" android:pathData="M265.81,68.767L265.186,71.606L264.175,71.606C264.111,70.746 263.939,70.122 263.616,69.735C263.294,69.347 262.777,69.175 262.067,69.175C261.487,69.175 260.927,69.347 260.411,69.692C259.895,70.036 259.4,70.53 258.948,71.197C258.475,71.843 258.088,72.617 257.744,73.499C257.4,74.359 257.12,75.263 256.948,76.188C256.776,77.091 256.668,77.908 256.668,78.661C256.668,79.801 256.862,80.64 257.228,81.156C257.615,81.694 258.196,81.974 258.992,81.974C259.529,81.974 259.981,81.888 260.368,81.737C260.755,81.587 261.143,81.307 261.465,80.941C261.809,80.554 262.154,80.038 262.498,79.371L263.509,79.371L262.863,82.339C262.154,82.555 261.465,82.684 260.798,82.77C260.132,82.856 259.443,82.899 258.755,82.899C257.873,82.899 257.142,82.705 256.518,82.361C255.916,81.995 255.464,81.479 255.163,80.791C254.84,80.102 254.689,79.285 254.689,78.317C254.689,77.822 254.733,77.328 254.797,76.854C254.883,76.274 255.012,75.628 255.184,74.94C255.421,74.08 255.722,73.284 256.066,72.574C256.453,71.821 256.884,71.176 257.357,70.595C257.808,70.057 258.303,69.627 258.841,69.261C259.336,68.939 259.852,68.681 260.433,68.487C260.992,68.315 261.594,68.229 262.24,68.229C262.863,68.229 263.466,68.25 264.003,68.336C264.541,68.422 265.143,68.552 265.81,68.767Z"/>
<path android:fillColor="#ffffff" android:fillType="nonZero" android:pathData="M276.449,79.672C276.341,80.081 276.298,80.404 276.255,80.662C276.234,80.898 276.212,81.113 276.212,81.307C276.212,81.63 276.298,81.845 276.449,81.974C276.621,82.103 276.879,82.189 277.245,82.21L277.137,82.727L272.943,82.727L273.05,82.21C273.308,82.167 273.524,82.103 273.653,82.017C273.782,81.909 273.889,81.78 273.975,81.608C274.083,81.436 274.169,81.242 274.233,81.006C274.319,80.748 274.427,80.296 274.578,79.586L275.438,75.693L269.802,75.693L268.942,79.672C268.877,79.93 268.834,80.145 268.791,80.296C268.77,80.468 268.748,80.64 268.727,80.791C268.727,80.941 268.705,81.113 268.705,81.307C268.705,81.608 268.791,81.823 268.92,81.952C269.071,82.103 269.329,82.167 269.738,82.21L269.63,82.727L265.436,82.727L265.543,82.21C265.801,82.167 266.017,82.103 266.146,82.017C266.275,81.909 266.382,81.78 266.468,81.608C266.576,81.436 266.64,81.242 266.726,81.006C266.812,80.748 266.92,80.296 267.07,79.586L268.856,71.52C268.92,71.219 268.985,70.918 269.028,70.595C269.071,70.272 269.092,70.014 269.092,69.821C269.092,69.498 269.006,69.261 268.834,69.132C268.662,69.003 268.404,68.917 268.06,68.896L268.168,68.379L272.383,68.379L272.254,68.896C271.996,68.96 271.803,69.025 271.695,69.089C271.566,69.154 271.48,69.261 271.373,69.412C271.286,69.541 271.179,69.778 271.093,70.057C271.007,70.358 270.878,70.853 270.727,71.52L270.017,74.725L275.653,74.725L276.363,71.52C276.427,71.219 276.492,70.918 276.535,70.595C276.578,70.272 276.599,70.014 276.599,69.821C276.599,69.498 276.513,69.261 276.341,69.132C276.191,69.003 275.911,68.917 275.567,68.896L275.696,68.379L279.891,68.379L279.783,68.896C279.525,68.96 279.374,69.003 279.267,69.068C279.181,69.111 279.073,69.175 279.009,69.261C278.923,69.347 278.837,69.476 278.772,69.627C278.686,69.778 278.621,70.014 278.535,70.315C278.449,70.616 278.342,71.025 278.234,71.52L276.449,79.672Z"/>
<path android:fillColor="#ffffff" android:fillType="nonZero" android:pathData="M292.236,68.379L291.504,71.606L290.493,71.606C290.472,71.111 290.429,70.724 290.386,70.423C290.321,70.143 290.257,69.95 290.192,69.799C290.128,69.67 290.041,69.563 289.955,69.498C289.848,69.433 289.74,69.39 289.611,69.347C289.461,69.326 289.267,69.304 289.03,69.304L285.61,69.304L284.406,74.811L286.406,74.811C286.707,74.811 286.966,74.768 287.181,74.682C287.396,74.617 287.589,74.467 287.761,74.252C287.933,74.058 288.127,73.735 288.342,73.305L289.289,73.305L288.342,77.349L287.46,77.349C287.46,76.962 287.439,76.661 287.417,76.489C287.396,76.295 287.353,76.166 287.31,76.08C287.267,75.994 287.202,75.929 287.138,75.886C287.073,75.822 286.966,75.8 286.858,75.779C286.75,75.757 286.557,75.736 286.299,75.736L284.212,75.736L282.857,81.802L285.976,81.802C286.277,81.802 286.535,81.78 286.75,81.737C286.966,81.694 287.159,81.608 287.331,81.501C287.503,81.393 287.675,81.242 287.847,81.07C287.998,80.877 288.17,80.662 288.342,80.382C288.514,80.102 288.751,79.672 289.03,79.091L290.063,79.091L289.138,82.727L279.824,82.727L279.932,82.21C280.19,82.167 280.405,82.103 280.534,82.017C280.663,81.909 280.771,81.78 280.857,81.608C280.964,81.436 281.029,81.242 281.115,81.006C281.201,80.748 281.308,80.296 281.459,79.586L283.244,71.52C283.395,70.81 283.481,70.251 283.481,69.821C283.481,69.498 283.395,69.261 283.223,69.132C283.051,69.003 282.793,68.917 282.448,68.896L282.556,68.379L292.236,68.379Z"/>
<path android:fillColor="#ffffff" android:fillType="nonZero" android:pathData="M296.589,74.875L296.955,74.875C297.234,74.875 297.514,74.811 297.75,74.725C297.987,74.617 298.267,74.445 298.589,74.187C298.912,73.929 299.428,73.456 300.138,72.768C300.826,72.058 301.343,71.541 301.644,71.176C301.923,70.81 302.139,70.509 302.289,70.251C302.418,69.971 302.483,69.735 302.483,69.519C302.483,69.347 302.418,69.218 302.289,69.089C302.139,68.982 301.945,68.917 301.687,68.896L301.794,68.379L306.311,68.379L306.182,68.896C305.903,68.939 305.58,69.089 305.236,69.347C304.892,69.584 304.333,70.079 303.623,70.789L299.536,74.768L301.773,80.145C301.966,80.597 302.117,80.941 302.225,81.156C302.354,81.35 302.483,81.544 302.612,81.673C302.741,81.823 302.891,81.931 303.063,82.038C303.236,82.124 303.429,82.167 303.666,82.21L303.558,82.727L299.493,82.727L299.622,82.21C299.988,82.167 300.181,82.017 300.181,81.716C300.181,81.608 300.181,81.522 300.16,81.415C300.138,81.329 300.095,81.178 300.031,80.984C299.988,80.812 299.901,80.576 299.794,80.318L298.417,76.962C298.267,76.575 298.116,76.295 298.009,76.123C297.88,75.973 297.75,75.865 297.621,75.8C297.492,75.736 297.277,75.693 296.998,75.693L296.417,75.693L295.556,79.672C295.449,80.081 295.406,80.425 295.363,80.662C295.341,80.898 295.32,81.113 295.32,81.307C295.32,81.63 295.406,81.845 295.556,81.974C295.729,82.103 295.987,82.189 296.352,82.21L296.245,82.727L292.05,82.727L292.158,82.21C292.416,82.167 292.631,82.103 292.76,82.017C292.889,81.909 292.997,81.78 293.083,81.608C293.19,81.436 293.255,81.242 293.341,81.006C293.427,80.748 293.534,80.296 293.685,79.586L295.47,71.52C295.535,71.197 295.599,70.875 295.642,70.573C295.685,70.272 295.707,70.036 295.707,69.821C295.707,69.498 295.621,69.261 295.449,69.132C295.277,69.003 295.019,68.917 294.675,68.896L294.782,68.379L298.998,68.379L298.869,68.896C298.611,68.96 298.417,69.025 298.31,69.089C298.181,69.154 298.095,69.261 297.987,69.412C297.901,69.541 297.793,69.778 297.707,70.057C297.621,70.358 297.492,70.853 297.342,71.52L296.589,74.875Z"/>
<path android:fillColor="#ffffff" android:fillType="nonZero" android:pathData="M312.382,78.786C312.382,78.872 312.36,78.958 312.36,79.044C312.36,79.108 312.36,79.194 312.36,79.259C312.36,79.667 312.403,80.012 312.511,80.291C312.618,80.571 312.769,80.786 312.984,80.958C313.177,81.13 313.414,81.259 313.694,81.345C313.973,81.431 314.274,81.453 314.619,81.453C314.963,81.453 315.307,81.41 315.608,81.324C315.931,81.216 316.189,81.087 316.425,80.894C316.662,80.7 316.856,80.463 317.006,80.162C317.135,79.883 317.2,79.56 317.2,79.173C317.2,78.936 317.178,78.721 317.114,78.527C317.049,78.334 316.963,78.14 316.834,77.968C316.727,77.818 316.597,77.667 316.447,77.516C316.275,77.387 316.124,77.237 315.931,77.129C315.737,77 315.543,76.871 315.35,76.764C315.156,76.656 314.941,76.527 314.726,76.419C314.511,76.312 314.296,76.183 314.081,76.054C313.844,75.925 313.651,75.796 313.435,75.645C313.242,75.516 313.048,75.344 312.876,75.193C312.726,75.021 312.575,74.828 312.446,74.634C312.317,74.419 312.231,74.204 312.145,73.967C312.08,73.709 312.037,73.451 312.037,73.15C312.037,72.763 312.08,72.419 312.188,72.117C312.274,71.795 312.403,71.494 312.575,71.235C312.747,70.956 312.962,70.719 313.199,70.504C313.435,70.289 313.694,70.117 313.973,69.966C314.253,69.837 314.554,69.73 314.877,69.644C315.199,69.579 315.522,69.536 315.845,69.536C316.017,69.536 316.167,69.536 316.339,69.558C316.533,69.579 316.705,69.601 316.877,69.644C317.049,69.687 317.221,69.73 317.372,69.794C317.544,69.837 317.673,69.902 317.802,69.966C317.91,70.052 318.017,70.117 318.082,70.203C318.168,70.289 318.211,70.375 318.211,70.461C318.211,70.569 318.189,70.698 318.189,70.848C318.189,70.999 318.189,71.171 318.189,71.365C318.168,71.558 318.168,71.773 318.168,72.01C318.146,72.246 318.146,72.483 318.125,72.741L317.587,72.698C317.608,72.591 317.608,72.462 317.608,72.354C317.608,72.01 317.565,71.687 317.458,71.429C317.35,71.171 317.2,70.934 317.028,70.762C316.834,70.59 316.597,70.461 316.339,70.375C316.081,70.289 315.78,70.225 315.457,70.225C315.135,70.225 314.834,70.289 314.554,70.397C314.274,70.504 314.038,70.655 313.844,70.848C313.629,71.042 313.479,71.257 313.349,71.515C313.242,71.795 313.177,72.074 313.177,72.397C313.177,72.634 313.22,72.87 313.285,73.085C313.349,73.3 313.435,73.494 313.565,73.666C313.672,73.838 313.823,73.989 313.973,74.139C314.145,74.29 314.317,74.419 314.511,74.548C314.683,74.677 314.898,74.785 315.113,74.914C315.307,75.021 315.522,75.15 315.737,75.258C315.952,75.365 316.189,75.494 316.404,75.602C316.619,75.731 316.834,75.86 317.028,76.011C317.221,76.14 317.415,76.29 317.587,76.462C317.759,76.613 317.888,76.807 318.017,77C318.146,77.194 318.232,77.409 318.318,77.645C318.383,77.882 318.426,78.162 318.426,78.441C318.426,78.915 318.361,79.345 318.232,79.71C318.103,80.098 317.91,80.42 317.694,80.678C317.479,80.958 317.221,81.195 316.942,81.388C316.662,81.582 316.361,81.732 316.038,81.84C315.737,81.948 315.436,82.034 315.113,82.098C314.812,82.141 314.533,82.163 314.274,82.163C313.93,82.163 313.586,82.141 313.285,82.098C312.984,82.055 312.726,81.991 312.489,81.904C312.252,81.84 312.08,81.754 311.951,81.646C311.822,81.539 311.758,81.431 311.758,81.345C311.758,81.109 311.758,80.764 311.779,80.334C311.801,79.883 311.822,79.366 311.844,78.764L312.382,78.786Z"/>
<path android:fillColor="#ffffff" android:fillType="nonZero" android:pathData="M318.675,74.871C319.062,74.763 319.406,74.656 319.686,74.591C319.965,74.505 320.18,74.462 320.374,74.419C320.567,74.376 320.718,74.354 320.826,74.333C320.933,74.311 320.998,74.311 321.062,74.311C321.148,74.311 321.234,74.311 321.299,74.354C321.385,74.376 321.449,74.419 321.514,74.484C321.557,74.548 321.6,74.634 321.643,74.742C321.664,74.849 321.686,74.978 321.686,75.129C321.686,75.172 321.686,75.279 321.686,75.43C321.664,75.602 321.664,75.796 321.643,76.075C321.643,76.355 321.621,76.678 321.6,77.065C321.578,77.473 321.535,77.925 321.514,78.463L321.578,78.463C321.837,77.796 322.138,77.194 322.46,76.656C322.783,76.14 323.106,75.71 323.428,75.344C323.751,74.978 324.074,74.72 324.396,74.527C324.719,74.354 325.042,74.268 325.343,74.268C325.579,74.268 325.773,74.311 325.967,74.397C326.139,74.484 326.311,74.591 326.44,74.742C326.569,74.892 326.676,75.064 326.784,75.258C326.87,75.451 326.934,75.667 326.999,75.882C327.064,76.075 327.085,76.29 327.128,76.505C327.15,76.742 327.15,76.936 327.15,77.129C327.15,77.645 327.107,78.119 326.999,78.57C326.891,79.022 326.741,79.431 326.569,79.797C326.375,80.184 326.16,80.506 325.923,80.807C325.665,81.087 325.407,81.345 325.106,81.539C324.826,81.754 324.525,81.904 324.203,82.012C323.88,82.12 323.579,82.163 323.256,82.163C322.912,82.163 322.589,82.098 322.267,81.991C321.944,81.861 321.643,81.668 321.342,81.367C321.32,82.249 321.277,83.152 321.234,84.099C321.191,85.023 321.17,86.013 321.17,87.024L320.073,87.153L319.965,87.153C320.008,86.486 320.073,85.798 320.116,85.088C320.159,84.378 320.202,83.647 320.223,82.915C320.266,82.206 320.309,81.474 320.331,80.786C320.374,80.076 320.395,79.431 320.417,78.807C320.438,78.183 320.46,77.624 320.46,77.129C320.481,76.635 320.481,76.204 320.481,75.882C320.481,75.753 320.481,75.624 320.46,75.538C320.46,75.43 320.417,75.344 320.374,75.279C320.352,75.215 320.288,75.172 320.202,75.129C320.137,75.107 320.03,75.086 319.901,75.086C319.772,75.086 319.621,75.086 319.427,75.129C319.234,75.15 319.019,75.193 318.761,75.258L318.675,74.871ZM321.406,80.807C321.708,81.044 321.987,81.238 322.288,81.367C322.589,81.474 322.891,81.539 323.17,81.539C323.579,81.539 323.923,81.453 324.267,81.281C324.59,81.13 324.891,80.872 325.128,80.549C325.364,80.205 325.558,79.818 325.687,79.323C325.816,78.829 325.88,78.269 325.88,77.624C325.88,77.387 325.859,77.151 325.837,76.893C325.794,76.656 325.73,76.419 325.665,76.204C325.579,76.011 325.472,75.839 325.343,75.71C325.214,75.581 325.063,75.516 324.891,75.516C324.676,75.516 324.461,75.559 324.246,75.688C324.031,75.796 323.837,75.946 323.643,76.118C323.428,76.312 323.256,76.527 323.063,76.785C322.891,77.043 322.719,77.301 322.546,77.581C322.396,77.861 322.245,78.162 322.116,78.463C321.966,78.764 321.858,79.044 321.751,79.323C321.664,79.624 321.578,79.883 321.514,80.141C321.449,80.377 321.406,80.614 321.406,80.807Z"/>
<path android:fillColor="#ffffff" android:fillType="nonZero" android:pathData="M330.016,72.117C329.887,72.117 329.78,72.096 329.694,72.053C329.586,71.988 329.522,71.924 329.457,71.838C329.393,71.773 329.328,71.666 329.307,71.558C329.264,71.451 329.242,71.343 329.242,71.235C329.242,71.085 329.264,70.934 329.328,70.805C329.371,70.698 329.436,70.59 329.522,70.504C329.608,70.418 329.694,70.375 329.801,70.332C329.909,70.289 330.016,70.268 330.103,70.268C330.232,70.268 330.339,70.289 330.425,70.332C330.511,70.375 330.597,70.44 330.662,70.504C330.748,70.59 330.791,70.676 330.834,70.784C330.855,70.87 330.877,70.977 330.877,71.106C330.877,71.214 330.855,71.343 330.812,71.472C330.769,71.58 330.726,71.687 330.64,71.795C330.576,71.902 330.468,71.967 330.361,72.031C330.253,72.096 330.146,72.117 330.016,72.117ZM330.468,74.484C330.404,74.892 330.361,75.301 330.318,75.688C330.275,76.097 330.253,76.484 330.21,76.85C330.189,77.237 330.167,77.581 330.167,77.925C330.146,78.248 330.146,78.549 330.146,78.829C330.146,79.194 330.146,79.495 330.167,79.753C330.167,80.033 330.189,80.248 330.21,80.42C330.232,80.592 330.275,80.743 330.296,80.85C330.339,80.958 330.382,81.044 330.447,81.109C330.49,81.152 330.554,81.195 330.619,81.216C330.705,81.238 330.791,81.238 330.877,81.238C331.049,81.238 331.221,81.216 331.415,81.152C331.608,81.109 331.802,81.044 332.017,80.958L332.103,81.216C331.974,81.324 331.823,81.431 331.63,81.539C331.436,81.646 331.243,81.732 331.027,81.818C330.812,81.904 330.619,81.969 330.404,82.034C330.21,82.077 330.038,82.12 329.887,82.12C329.801,82.12 329.694,82.098 329.629,82.098C329.543,82.077 329.479,82.034 329.414,81.969C329.35,81.904 329.285,81.818 329.221,81.689C329.178,81.582 329.135,81.41 329.113,81.195C329.07,80.98 329.049,80.721 329.027,80.399C329.005,80.098 329.005,79.71 329.005,79.259C329.005,79.001 329.005,78.699 329.005,78.377C329.005,78.033 329.027,77.689 329.027,77.301C329.049,76.936 329.049,76.527 329.07,76.097C329.092,75.688 329.113,75.236 329.135,74.806C329.156,74.699 329.178,74.613 329.221,74.57C329.264,74.505 329.328,74.462 329.393,74.419C329.457,74.376 329.522,74.354 329.608,74.354C329.672,74.333 329.758,74.333 329.801,74.333C330.038,74.333 330.275,74.376 330.468,74.484Z"/>
<path android:fillColor="#ffffff" android:fillType="nonZero" android:pathData="M333.332,82.012L333.246,82.012C333.267,81.689 333.31,81.324 333.332,80.937C333.353,80.528 333.375,80.141 333.397,79.732C333.418,79.323 333.44,78.936 333.44,78.527C333.461,78.14 333.483,77.753 333.483,77.409C333.504,77.065 333.504,76.764 333.526,76.484C333.526,76.226 333.526,76.011 333.526,75.839C333.526,75.71 333.526,75.602 333.504,75.516C333.504,75.408 333.483,75.344 333.44,75.279C333.397,75.215 333.353,75.172 333.267,75.129C333.203,75.107 333.117,75.086 332.988,75.086C332.945,75.086 332.88,75.086 332.816,75.107C332.751,75.107 332.687,75.107 332.601,75.129C332.515,75.15 332.429,75.172 332.342,75.193C332.235,75.215 332.127,75.236 331.998,75.258L331.934,74.892C332.515,74.677 332.988,74.527 333.332,74.44C333.698,74.354 333.934,74.311 334.063,74.311C334.3,74.311 334.472,74.376 334.558,74.527C334.666,74.656 334.709,74.849 334.709,75.129C334.709,75.15 334.709,75.215 334.709,75.344C334.709,75.451 334.687,75.602 334.687,75.796C334.666,75.989 334.644,76.204 334.623,76.462C334.623,76.721 334.601,76.979 334.58,77.28C334.558,77.559 334.515,77.861 334.494,78.183C334.472,78.484 334.429,78.807 334.407,79.108L334.451,79.13C334.623,78.441 334.816,77.796 335.031,77.194C335.246,76.613 335.483,76.097 335.741,75.645C335.999,75.215 336.3,74.871 336.623,74.613C336.924,74.376 337.268,74.247 337.655,74.247C337.785,74.247 337.914,74.268 338.021,74.311C338.15,74.354 338.236,74.419 338.322,74.484C338.408,74.57 338.473,74.656 338.537,74.763C338.58,74.849 338.602,74.978 338.602,75.086C338.602,75.258 338.559,75.408 338.494,75.538C338.43,75.688 338.322,75.817 338.172,75.925L338.129,75.925C337.978,75.817 337.849,75.731 337.72,75.667C337.591,75.624 337.44,75.581 337.29,75.581C337.139,75.581 336.989,75.624 336.838,75.667C336.688,75.731 336.559,75.817 336.429,75.925C336.236,76.118 336.064,76.355 335.892,76.656C335.72,76.957 335.548,77.28 335.418,77.645C335.268,78.011 335.139,78.398 335.031,78.807C334.924,79.194 334.816,79.581 334.73,79.969C334.644,80.356 334.58,80.7 334.537,81.044C334.494,81.367 334.451,81.646 334.429,81.883L333.332,82.012Z"/>
<path android:fillColor="#ffffff" android:fillType="nonZero" android:pathData="M340.493,72.117C340.364,72.117 340.257,72.096 340.171,72.053C340.063,71.988 339.999,71.924 339.934,71.838C339.869,71.773 339.805,71.666 339.783,71.558C339.74,71.451 339.719,71.343 339.719,71.235C339.719,71.085 339.74,70.934 339.805,70.805C339.848,70.698 339.912,70.59 339.999,70.504C340.085,70.418 340.171,70.375 340.278,70.332C340.386,70.289 340.493,70.268 340.579,70.268C340.708,70.268 340.816,70.289 340.902,70.332C340.988,70.375 341.074,70.44 341.139,70.504C341.225,70.59 341.268,70.676 341.311,70.784C341.332,70.87 341.354,70.977 341.354,71.106C341.354,71.214 341.332,71.343 341.289,71.472C341.246,71.58 341.203,71.687 341.117,71.795C341.052,71.902 340.945,71.967 340.837,72.031C340.73,72.096 340.622,72.117 340.493,72.117ZM340.945,74.484C340.88,74.892 340.837,75.301 340.794,75.688C340.751,76.097 340.73,76.484 340.687,76.85C340.665,77.237 340.644,77.581 340.644,77.925C340.622,78.248 340.622,78.549 340.622,78.829C340.622,79.194 340.622,79.495 340.644,79.753C340.644,80.033 340.665,80.248 340.687,80.42C340.708,80.592 340.751,80.743 340.773,80.85C340.816,80.958 340.859,81.044 340.923,81.109C340.966,81.152 341.031,81.195 341.096,81.216C341.182,81.238 341.268,81.238 341.354,81.238C341.526,81.238 341.698,81.216 341.891,81.152C342.085,81.109 342.279,81.044 342.494,80.958L342.58,81.216C342.451,81.324 342.3,81.431 342.107,81.539C341.913,81.646 341.719,81.732 341.504,81.818C341.289,81.904 341.096,81.969 340.88,82.034C340.687,82.077 340.515,82.12 340.364,82.12C340.278,82.12 340.171,82.098 340.106,82.098C340.02,82.077 339.955,82.034 339.891,81.969C339.826,81.904 339.762,81.818 339.697,81.689C339.654,81.582 339.611,81.41 339.59,81.195C339.547,80.98 339.525,80.721 339.504,80.399C339.482,80.098 339.482,79.71 339.482,79.259C339.482,79.001 339.482,78.699 339.482,78.377C339.482,78.033 339.504,77.689 339.504,77.301C339.525,76.936 339.525,76.527 339.547,76.097C339.568,75.688 339.59,75.236 339.611,74.806C339.633,74.699 339.654,74.613 339.697,74.57C339.74,74.505 339.805,74.462 339.869,74.419C339.934,74.376 339.999,74.354 340.085,74.354C340.149,74.333 340.235,74.333 340.278,74.333C340.515,74.333 340.751,74.376 340.945,74.484Z"/>
<path android:fillColor="#ffffff" android:fillType="nonZero" android:pathData="M345.723,70.526C345.573,71.192 345.444,71.881 345.357,72.591C345.25,73.3 345.164,73.989 345.099,74.699C345.271,74.699 345.422,74.699 345.573,74.699C345.745,74.699 345.874,74.72 346.024,74.72C346.153,74.72 346.282,74.72 346.39,74.72L346.648,74.72C346.971,74.72 347.229,74.699 347.444,74.656C347.465,74.72 347.465,74.785 347.487,74.828C347.487,74.871 347.508,74.914 347.508,74.957C347.508,75.107 347.444,75.215 347.315,75.258C347.186,75.301 346.992,75.322 346.756,75.322C346.519,75.322 346.261,75.322 345.981,75.279C345.702,75.258 345.379,75.236 345.035,75.215C344.992,75.581 344.97,75.946 344.949,76.312C344.927,76.656 344.906,77 344.884,77.301C344.863,77.624 344.863,77.925 344.841,78.183C344.841,78.463 344.841,78.699 344.841,78.893C344.841,79.259 344.863,79.603 344.884,79.883C344.927,80.184 344.992,80.442 345.099,80.635C345.207,80.85 345.336,81.001 345.508,81.109C345.68,81.216 345.895,81.281 346.153,81.281C346.347,81.281 346.562,81.238 346.777,81.173C347.014,81.109 347.293,81.001 347.573,80.85C347.681,80.958 347.724,81.066 347.745,81.195C347.53,81.345 347.315,81.474 347.078,81.582C346.863,81.711 346.648,81.818 346.433,81.883C346.218,81.969 346.024,82.034 345.809,82.098C345.616,82.141 345.422,82.163 345.25,82.163C345.013,82.163 344.798,82.12 344.605,82.034C344.411,81.948 344.239,81.797 344.11,81.56C343.959,81.345 343.852,81.066 343.766,80.678C343.701,80.313 343.658,79.84 343.658,79.28C343.658,78.958 343.658,78.635 343.68,78.291C343.68,77.947 343.701,77.624 343.723,77.258C343.723,76.914 343.744,76.57 343.787,76.226C343.809,75.882 343.83,75.538 343.873,75.193C343.594,75.215 343.336,75.215 343.099,75.236C342.862,75.258 342.669,75.279 342.497,75.322C342.497,75.301 342.475,75.279 342.475,75.258C342.475,75.172 342.518,75.107 342.583,75.043C342.669,74.978 342.755,74.935 342.884,74.871C343.013,74.828 343.163,74.785 343.357,74.742C343.529,74.699 343.723,74.677 343.938,74.677C344.024,73.946 344.11,73.236 344.196,72.591C344.282,71.945 344.368,71.365 344.476,70.848C344.476,70.762 344.519,70.676 344.562,70.633C344.605,70.569 344.669,70.526 344.734,70.483C344.798,70.44 344.863,70.418 344.927,70.418C345.013,70.397 345.078,70.397 345.142,70.397C345.271,70.397 345.379,70.397 345.465,70.418C345.551,70.44 345.637,70.483 345.723,70.526Z"/>
<path android:fillColor="#ffffff" android:fillType="nonZero" android:pathData="M357.984,69.536C358.435,69.536 358.865,69.579 359.253,69.665C359.64,69.751 359.984,69.837 360.264,69.966C360.543,70.095 360.78,70.225 360.93,70.354C361.103,70.504 361.167,70.633 361.167,70.741C361.167,70.977 361.167,71.3 361.146,71.687C361.124,72.074 361.103,72.526 361.081,73.085L360.629,73.064L360.629,72.784C360.629,72.548 360.608,72.311 360.543,72.096C360.5,71.881 360.414,71.666 360.285,71.472C360.135,71.279 359.962,71.085 359.769,70.934C359.575,70.784 359.36,70.655 359.145,70.547C358.908,70.44 358.65,70.375 358.392,70.311C358.134,70.268 357.854,70.225 357.596,70.225C357.123,70.225 356.693,70.311 356.306,70.44C355.94,70.59 355.596,70.784 355.295,71.042C354.994,71.3 354.714,71.623 354.499,71.967C354.284,72.311 354.09,72.698 353.94,73.107C353.811,73.516 353.703,73.967 353.617,74.419C353.552,74.871 353.509,75.322 353.509,75.796C353.509,76.355 353.552,76.871 353.66,77.344C353.746,77.839 353.875,78.291 354.047,78.699C354.219,79.108 354.413,79.474 354.649,79.797C354.886,80.119 355.166,80.399 355.445,80.635C355.746,80.85 356.069,81.023 356.413,81.152C356.757,81.259 357.123,81.324 357.51,81.324C357.768,81.324 358.048,81.302 358.328,81.238C358.607,81.173 358.887,81.087 359.188,80.958C359.468,80.829 359.769,80.678 360.07,80.485C360.35,80.291 360.651,80.076 360.952,79.797C360.973,79.818 361.016,79.84 361.038,79.861C361.081,79.883 361.124,79.904 361.146,79.926C361.189,79.947 361.21,79.99 361.232,80.012C361.253,80.055 361.253,80.076 361.253,80.098C361.253,80.119 361.253,80.162 361.21,80.205C360.887,80.571 360.522,80.894 360.178,81.152C359.833,81.41 359.468,81.603 359.124,81.775C358.779,81.926 358.414,82.034 358.048,82.098C357.704,82.184 357.338,82.206 356.973,82.206C356.499,82.206 356.069,82.141 355.639,82.012C355.209,81.883 354.8,81.689 354.434,81.453C354.069,81.195 353.725,80.915 353.445,80.549C353.144,80.205 352.886,79.818 352.671,79.388C352.455,78.958 352.305,78.484 352.197,77.968C352.068,77.452 352.025,76.914 352.025,76.355C352.025,75.817 352.068,75.258 352.176,74.699C352.305,74.161 352.455,73.623 352.671,73.128C352.907,72.612 353.165,72.139 353.509,71.709C353.832,71.279 354.219,70.891 354.671,70.569C355.101,70.246 355.596,69.988 356.155,69.816C356.714,69.622 357.317,69.536 357.984,69.536Z"/>
<path android:fillColor="#ffffff" android:fillType="nonZero" android:pathData="M362.354,78.872C362.354,78.506 362.397,78.14 362.462,77.796C362.526,77.43 362.634,77.065 362.784,76.721C362.935,76.398 363.107,76.075 363.322,75.774C363.537,75.473 363.774,75.215 364.053,74.978C364.333,74.763 364.634,74.591 364.978,74.462C365.322,74.333 365.688,74.268 366.075,74.268C366.527,74.268 366.936,74.354 367.28,74.548C367.624,74.72 367.904,74.978 368.14,75.279C368.377,75.602 368.549,75.946 368.678,76.355C368.807,76.742 368.85,77.151 368.85,77.559C368.85,77.925 368.829,78.269 368.743,78.635C368.678,79.001 368.57,79.345 368.42,79.689C368.269,80.033 368.097,80.356 367.882,80.657C367.667,80.958 367.43,81.216 367.151,81.431C366.871,81.668 366.57,81.84 366.226,81.969C365.882,82.098 365.516,82.163 365.107,82.163C364.677,82.163 364.268,82.077 363.924,81.883C363.58,81.689 363.3,81.453 363.064,81.13C362.827,80.829 362.655,80.463 362.526,80.076C362.419,79.689 362.354,79.28 362.354,78.872ZM363.602,78.355C363.602,78.592 363.623,78.85 363.666,79.087C363.688,79.345 363.752,79.56 363.838,79.797C363.903,80.033 364.01,80.248 364.118,80.442C364.225,80.635 364.376,80.807 364.527,80.958C364.677,81.109 364.849,81.216 365.043,81.302C365.236,81.388 365.452,81.431 365.688,81.431C365.946,81.431 366.161,81.367 366.355,81.259C366.57,81.152 366.721,81.001 366.871,80.829C367.022,80.635 367.129,80.442 367.215,80.205C367.323,79.969 367.387,79.71 367.452,79.474C367.495,79.216 367.538,78.979 367.559,78.721C367.581,78.484 367.603,78.248 367.603,78.054C367.603,77.818 367.581,77.559 367.538,77.323C367.495,77.086 367.452,76.85 367.366,76.613C367.301,76.398 367.194,76.183 367.086,75.989C366.979,75.796 366.828,75.624 366.678,75.473C366.527,75.322 366.355,75.215 366.161,75.129C365.968,75.043 365.753,75 365.516,75C365.258,75 365.021,75.043 364.828,75.15C364.634,75.279 364.484,75.408 364.333,75.602C364.182,75.774 364.075,75.989 363.989,76.226C363.881,76.462 363.817,76.699 363.752,76.957C363.709,77.194 363.666,77.452 363.645,77.689C363.623,77.947 363.602,78.162 363.602,78.355Z"/>
<path android:fillColor="#ffffff" android:fillType="nonZero" android:pathData="M369.371,74.914C369.607,74.806 369.844,74.742 370.08,74.656C370.317,74.591 370.532,74.527 370.726,74.484C370.898,74.419 371.07,74.376 371.221,74.354C371.371,74.333 371.479,74.311 371.565,74.311C371.78,74.311 371.93,74.376 372.038,74.505C372.167,74.613 372.21,74.828 372.21,75.129C372.21,75.193 372.21,75.344 372.188,75.559C372.188,75.796 372.167,76.075 372.145,76.441C372.102,76.785 372.081,77.172 372.038,77.624C371.995,78.054 371.952,78.527 371.909,79.044L371.93,79.044C372.188,78.162 372.447,77.409 372.726,76.807C373.006,76.204 373.285,75.71 373.565,75.344C373.845,74.978 374.146,74.699 374.426,74.548C374.727,74.376 375.006,74.311 375.286,74.311C375.523,74.311 375.716,74.354 375.888,74.484C376.06,74.613 376.189,74.763 376.297,74.957C376.404,75.15 376.49,75.365 376.534,75.602C376.577,75.839 376.598,76.097 376.598,76.355C376.598,76.57 376.598,76.807 376.577,77.043C376.577,77.301 376.555,77.538 376.534,77.775C376.512,78.011 376.49,78.248 376.469,78.463C376.447,78.678 376.426,78.872 376.404,79.022L376.447,79.022C376.684,78.119 376.942,77.366 377.222,76.764C377.501,76.14 377.781,75.667 378.082,75.279C378.362,74.914 378.663,74.656 378.943,74.484C379.244,74.333 379.523,74.247 379.782,74.247C379.975,74.247 380.126,74.29 380.298,74.354C380.47,74.44 380.62,74.548 380.728,74.72C380.857,74.871 380.965,75.086 381.051,75.344C381.115,75.602 381.158,75.903 381.158,76.269C381.158,76.376 381.158,76.505 381.137,76.678C381.137,76.871 381.115,77.065 381.094,77.28C381.072,77.495 381.051,77.732 381.029,77.968C381.008,78.205 380.986,78.441 380.986,78.678C380.965,78.915 380.943,79.151 380.922,79.345C380.922,79.56 380.922,79.753 380.922,79.904C380.922,80.184 380.922,80.399 380.943,80.571C380.965,80.743 381.008,80.872 381.051,80.98C381.115,81.087 381.18,81.152 381.266,81.173C381.352,81.216 381.459,81.238 381.588,81.238C381.782,81.238 381.954,81.216 382.169,81.152C382.363,81.109 382.556,81.044 382.75,80.958L382.857,81.216C382.728,81.324 382.556,81.431 382.384,81.539C382.191,81.646 381.976,81.732 381.76,81.818C381.545,81.904 381.352,81.969 381.137,82.034C380.922,82.077 380.749,82.12 380.577,82.12C380.384,82.12 380.233,82.055 380.126,81.969C380.018,81.861 379.932,81.732 379.889,81.539C379.825,81.367 379.803,81.173 379.782,80.937C379.76,80.7 379.76,80.442 379.76,80.184C379.76,79.861 379.76,79.517 379.803,79.151C379.825,78.786 379.846,78.463 379.868,78.14C379.889,77.818 379.932,77.516 379.954,77.258C379.975,77 379.975,76.807 379.975,76.678C379.975,76.29 379.911,76.011 379.782,75.817C379.652,75.624 379.459,75.516 379.222,75.516C379.05,75.516 378.857,75.602 378.663,75.774C378.448,75.946 378.254,76.183 378.061,76.484C377.846,76.785 377.652,77.129 377.48,77.538C377.286,77.947 377.114,78.377 376.964,78.85C376.813,79.323 376.684,79.818 376.577,80.334C376.49,80.85 376.404,81.367 376.383,81.883L375.286,82.012L375.2,82.012C375.243,81.281 375.264,80.635 375.307,80.055C375.35,79.495 375.372,79.001 375.393,78.57C375.415,78.14 375.436,77.796 375.458,77.495C375.479,77.194 375.479,76.957 375.479,76.764C375.479,76.376 375.415,76.075 375.286,75.882C375.157,75.688 374.985,75.581 374.77,75.581C374.576,75.581 374.382,75.667 374.189,75.839C373.974,75.989 373.78,76.226 373.565,76.527C373.372,76.807 373.178,77.172 373.006,77.559C372.812,77.968 372.64,78.398 372.49,78.872C372.339,79.323 372.231,79.818 372.124,80.334C372.016,80.85 371.952,81.367 371.93,81.883L370.833,82.012L370.747,82.012C370.769,81.689 370.812,81.324 370.833,80.937C370.855,80.528 370.876,80.141 370.898,79.732C370.919,79.323 370.941,78.936 370.941,78.527C370.962,78.14 370.984,77.753 370.984,77.409C371.005,77.065 371.005,76.764 371.027,76.484C371.027,76.226 371.027,76.011 371.027,75.839C371.027,75.71 371.005,75.581 370.984,75.473C370.962,75.387 370.941,75.301 370.898,75.258C370.855,75.193 370.79,75.15 370.726,75.129C370.661,75.107 370.597,75.086 370.489,75.086C370.36,75.086 370.21,75.107 370.037,75.15C369.865,75.172 369.672,75.215 369.435,75.279L369.371,74.914Z"/>
<path android:fillColor="#ffffff" android:fillType="nonZero" android:pathData="M385.953,67.794C385.889,68.353 385.824,68.934 385.76,69.515C385.695,70.117 385.631,70.719 385.566,71.322C385.502,71.945 385.437,72.548 385.373,73.171C385.33,73.774 385.265,74.397 385.222,75C385.179,75.581 385.136,76.183 385.093,76.764C385.071,77.323 385.028,77.882 385.007,78.42L385.071,78.42C385.351,77.732 385.652,77.129 385.975,76.613C386.276,76.097 386.599,75.667 386.943,75.322C387.265,74.978 387.588,74.699 387.911,74.527C388.233,74.354 388.556,74.268 388.836,74.268C389.072,74.268 389.287,74.311 389.481,74.397C389.653,74.484 389.825,74.591 389.954,74.742C390.105,74.892 390.212,75.064 390.298,75.258C390.384,75.451 390.47,75.667 390.513,75.882C390.578,76.075 390.621,76.29 390.643,76.505C390.664,76.742 390.664,76.936 390.664,77.129C390.664,77.645 390.621,78.14 390.513,78.592C390.406,79.044 390.277,79.452 390.083,79.818C389.911,80.205 389.696,80.528 389.438,80.829C389.201,81.109 388.922,81.367 388.642,81.56C388.341,81.754 388.04,81.904 387.717,82.012C387.395,82.12 387.072,82.163 386.749,82.163C386.534,82.163 386.319,82.141 386.082,82.098C385.867,82.034 385.652,81.969 385.459,81.861C385.243,81.775 385.05,81.625 384.856,81.474C384.663,81.324 384.512,81.13 384.362,80.915C384.233,81.216 384.103,81.474 383.996,81.668C383.867,81.861 383.738,82.012 383.63,82.141L383.372,82.077C383.544,81.539 383.695,80.894 383.781,80.141C383.888,79.388 383.953,78.549 383.996,77.602C384.017,77.194 384.039,76.721 384.06,76.204C384.082,75.688 384.125,75.15 384.146,74.57C384.168,73.989 384.211,73.408 384.233,72.806C384.276,72.203 384.297,71.623 384.319,71.063C384.362,70.483 384.383,69.945 384.426,69.45C384.448,68.934 384.469,68.482 384.491,68.095C384.491,68.009 384.534,67.944 384.577,67.88C384.62,67.815 384.684,67.772 384.749,67.729C384.835,67.686 384.921,67.665 385.007,67.643C385.093,67.622 385.201,67.622 385.287,67.622C385.502,67.622 385.717,67.686 385.953,67.794ZM389.395,77.624C389.395,77.387 389.373,77.151 389.33,76.893C389.309,76.656 389.244,76.419 389.158,76.204C389.094,76.011 388.986,75.839 388.857,75.71C388.728,75.581 388.578,75.516 388.384,75.516C388.169,75.516 387.954,75.559 387.76,75.667C387.545,75.774 387.352,75.925 387.158,76.097C386.964,76.29 386.771,76.484 386.599,76.721C386.405,76.957 386.254,77.215 386.082,77.473C385.932,77.732 385.803,77.99 385.674,78.248C385.545,78.527 385.437,78.764 385.351,79.001C385.265,79.259 385.201,79.474 385.136,79.667C385.093,79.861 385.071,80.012 385.071,80.119C385.114,80.334 385.201,80.528 385.308,80.721C385.416,80.894 385.566,81.044 385.717,81.173C385.867,81.281 386.039,81.388 386.233,81.453C386.427,81.517 386.62,81.56 386.835,81.56C387.179,81.56 387.524,81.474 387.825,81.281C388.147,81.109 388.405,80.85 388.642,80.528C388.879,80.184 389.051,79.775 389.18,79.28C389.33,78.807 389.395,78.248 389.395,77.624Z"/>
<path android:fillColor="#ffffff" android:fillType="nonZero" android:pathData="M391.898,78.872C391.898,78.506 391.941,78.14 392.005,77.796C392.07,77.43 392.178,77.065 392.328,76.721C392.479,76.398 392.651,76.075 392.866,75.774C393.081,75.473 393.318,75.215 393.597,74.978C393.877,74.763 394.178,74.591 394.522,74.462C394.866,74.333 395.232,74.268 395.619,74.268C396.071,74.268 396.48,74.354 396.824,74.548C397.168,74.72 397.447,74.978 397.684,75.279C397.921,75.602 398.093,75.946 398.222,76.355C398.351,76.742 398.394,77.151 398.394,77.559C398.394,77.925 398.372,78.269 398.286,78.635C398.222,79.001 398.114,79.345 397.964,79.689C397.813,80.033 397.641,80.356 397.426,80.657C397.211,80.958 396.974,81.216 396.695,81.431C396.415,81.668 396.114,81.84 395.77,81.969C395.426,82.098 395.06,82.163 394.651,82.163C394.221,82.163 393.812,82.077 393.468,81.883C393.124,81.689 392.844,81.453 392.608,81.13C392.371,80.829 392.199,80.463 392.07,80.076C391.962,79.689 391.898,79.28 391.898,78.872ZM393.145,78.355C393.145,78.592 393.167,78.85 393.21,79.087C393.232,79.345 393.296,79.56 393.382,79.797C393.447,80.033 393.554,80.248 393.662,80.442C393.769,80.635 393.92,80.807 394.07,80.958C394.221,81.109 394.393,81.216 394.587,81.302C394.78,81.388 394.995,81.431 395.232,81.431C395.49,81.431 395.705,81.367 395.899,81.259C396.114,81.152 396.264,81.001 396.415,80.829C396.566,80.635 396.673,80.442 396.759,80.205C396.867,79.969 396.931,79.71 396.996,79.474C397.039,79.216 397.082,78.979 397.103,78.721C397.125,78.484 397.146,78.248 397.146,78.054C397.146,77.818 397.125,77.559 397.082,77.323C397.039,77.086 396.996,76.85 396.91,76.613C396.845,76.398 396.738,76.183 396.63,75.989C396.523,75.796 396.372,75.624 396.221,75.473C396.071,75.322 395.899,75.215 395.705,75.129C395.512,75.043 395.296,75 395.06,75C394.802,75 394.565,75.043 394.372,75.15C394.178,75.279 394.027,75.408 393.877,75.602C393.726,75.774 393.619,75.989 393.533,76.226C393.425,76.462 393.361,76.699 393.296,76.957C393.253,77.194 393.21,77.452 393.189,77.689C393.167,77.947 393.145,78.162 393.145,78.355Z"/>
<path android:fillColor="#232121" android:pathData="M416.973,95.845L209.326,96.482C213.769,127.785 224.276,159.089 239.861,190.392L440.758,189.436C438.993,157.477 432.454,126.107 416.973,95.845Z"/>
<path android:fillColor="#9C9C9C" android:pathData="M413.058,100.938L215.693,101.496C219.916,128.9 229.903,156.303 244.717,183.707L435.666,182.87C433.989,154.892 427.773,127.43 413.058,100.938Z"/>
<path android:fillColor="#6C684D" android:pathData="M425.643,129.529C423.305,120.953 419.946,108.243 414.674,106.986L240.117,108.259C235.949,108.259 232.565,111.649 232.565,115.823L232.387,159.04C233.967,163.413 237.931,169.147 241.146,177.34L423.255,177.022L425.643,129.529Z"/>
<path android:fillAlpha="0.41" android:fillColor="#FF000000" android:pathData="M347.126,132.454L341.648,126.976L330.691,126.976L325.213,132.454L325.213,152.05L330.691,157.528L341.648,157.528L347.126,152.05L347.126,132.454Z"/>
<path android:fillColor="#00000000"
android:pathData="M342.29,135.852L334.916,142.279L334.916,129.426L342.29,135.852Z"
android:strokeColor="#ffffff" android:strokeWidth="0.51"/>
<path android:fillColor="#00000000"
android:pathData="M342.29,148.474L334.916,154.9L334.916,142.047L342.29,148.474Z"
android:strokeColor="#ffffff" android:strokeWidth="0.51"/>
<path android:fillColor="#ffffff" android:pathData="M331.435,136.157l4.641,5.732l-1.146,0.928l-4.641,-5.732z"/>
<path android:fillColor="#ffffff" android:pathData="M330.048,146.319l5.215,-5.215l0.939,0.939l-5.215,5.215z"/>
<path android:fillColor="#232121" android:pathData="M113.089,117.685a30.88,41.039 78.705,1 0,80.488 -16.075a30.88,41.039 78.705,1 0,-80.488 16.075z"/>
<path android:fillColor="#FF000000" android:pathData="M128.597,114.092a16.385,26.463 78.705,1 0,51.901 -10.366a16.385,26.463 78.705,1 0,-51.901 10.366z"/>
<path android:fillColor="#232121" android:pathData="M132.048,113.5a14.004,22.618 78.705,1 0,44.359 -8.86a14.004,22.618 78.705,1 0,-44.359 8.86z"/>
<path android:fillColor="#222323" android:pathData="M168.678,130.425L175.653,150.194L155.566,156.186L148.59,136.419L168.678,130.425Z"/>
<path android:fillColor="#232121" android:pathData="M129.006,182.945a30.88,41.039 78.705,1 0,80.488 -16.075a30.88,41.039 78.705,1 0,-80.488 16.075z"/>
<path android:fillColor="#FF000000" android:pathData="M144.514,179.352a16.385,26.463 78.705,1 0,51.901 -10.366a16.385,26.463 78.705,1 0,-51.901 10.366z"/>
<path android:fillColor="#232121" android:pathData="M148.26,178.465a14.004,22.618 78.705,1 0,44.359 -8.86a14.004,22.618 78.705,1 0,-44.359 8.86z"/>
<path android:fillColor="#ffffff" android:pathData="M179.325,165.012l2.125,2.334l-12.868,15.36l-2.125,-2.334z"/>
<path android:fillColor="#ffffff" android:pathData="M161.089,172.594l2.659,-2.261l6.739,10.387l-2.659,2.261z"/>
<path android:fillColor="#ffffff" android:pathData="M123.737,65.763l9.903,0l0,14.96l-9.903,0z"/>
<path android:fillAlpha="0.44" android:fillColor="#ffffff" android:pathData="M127.108,61.55l10.114,0l0,12.853l-10.114,0z"/>
<path android:fillAlpha="0.44" android:fillColor="#ffffff" android:pathData="M149.479,99.794l10.114,0l0,12.853l-10.114,0z"/>
<path android:fillColor="#ffffff" android:pathData="M146.107,104.008l9.903,0l0,14.96l-9.903,0z"/>
<path android:fillColor="#ffffff" android:pathData="M139.922,204.626l2.125,2.334l-12.868,15.36l-2.125,-2.334z"/>
<path android:fillColor="#ffffff" android:pathData="M121.686,212.207l2.659,-2.261l6.739,10.387l-2.659,2.261z"/>
<path android:fillColor="#FF000000" android:pathData="M441.078,223.997C441.078,220.735 438.431,218.088 435.17,218.088L203.775,218.088C200.513,218.088 197.866,220.735 197.866,223.997L246.254,291.943L441.078,291.943L441.078,223.997Z"/>
<path android:fillColor="#A7A9A6" android:pathData="M435.666,228.08C435.666,224.984 433.181,222.47 430.119,222.47L212.884,222.47C209.822,222.47 207.337,224.984 207.337,228.08L252.764,292.58L435.666,292.58L435.666,228.08Z"/>
<path android:fillAlpha="0.41" android:fillColor="#FF000000" android:pathData="M435.666,228.078C435.666,224.983 433.154,222.471 430.057,222.471L322.894,222.471L322.894,292.579L435.666,292.579L435.666,228.078Z"/>
<path android:fillAlpha="0.4" android:fillColor="#443F29" android:pathData="M255.574,221.745L203.277,222.401L252.322,292.719L255.719,292.726L255.574,221.745Z"/>
<path android:fillAlpha="0.4" android:fillColor="#443F29" android:pathData="M255.09,292.268L255.09,222.162L272.626,239.688L272.626,274.741L255.09,292.268Z"/>
<path android:fillAlpha="0.4" android:fillColor="#443F29" android:pathData="M272.625,250.743l13.111,0l0,12.947l-13.111,0z"/>
<path android:fillAlpha="0.55" android:fillColor="#FF000000" android:pathData="M285.53,222.427l6.227,0l0,70.472l-6.227,0z"/>
<path android:fillAlpha="0.55" android:fillColor="#FF000000" android:pathData="M316.504,222.427l6.391,0l0,70.472l-6.391,0z"/>
<path android:fillAlpha="0.55" android:fillColor="#FF000000" android:pathData="M302.41,221.935l7.374,0l0,70.472l-7.374,0z"/>
<path android:fillColor="#FF000000" android:fillType="nonZero" android:pathData="M355.056,265.709L355.056,264.691L361.93,264.691C361.685,264.448 361.439,264.124 361.194,263.719C360.936,263.325 360.752,262.966 360.629,262.654L361.673,262.654C361.955,263.221 362.299,263.719 362.704,264.147C363.109,264.575 363.502,264.876 363.882,265.05L363.882,265.709L355.056,265.709Z"/>
<path android:fillColor="#FF000000" android:fillType="nonZero" android:pathData="M355.056,268.859L356.283,268.859L356.283,270.016L355.056,270.016C354.601,270.016 354.245,269.935 353.963,269.785C353.681,269.634 353.472,269.403 353.312,269.067L353.779,268.789C353.877,269.009 354.024,269.16 354.221,269.264C354.417,269.368 354.687,269.426 355.056,269.437L355.056,268.859Z"/>
<path android:fillColor="#FF000000" android:fillType="nonZero" android:pathData="M357.364,271.53L357.449,272.595C356.897,272.676 356.492,272.85 356.21,273.139C355.927,273.428 355.792,273.776 355.792,274.181C355.792,274.678 355.989,275.095 356.381,275.431C356.774,275.766 357.29,275.94 357.941,275.94C358.554,275.94 359.045,275.778 359.401,275.454C359.745,275.118 359.929,274.702 359.929,274.169C359.929,273.845 359.856,273.544 359.696,273.278C359.536,273.023 359.328,272.815 359.082,272.665L359.205,271.704L363.723,272.503L363.723,276.623L362.692,276.623L362.692,273.324L360.334,272.873C360.703,273.371 360.887,273.891 360.887,274.435C360.887,275.165 360.617,275.778 360.089,276.276C359.549,276.773 358.874,277.016 358.039,277.016C357.241,277.016 356.553,276.796 355.976,276.357C355.264,275.836 354.908,275.107 354.908,274.181C354.908,273.428 355.129,272.815 355.571,272.341C356.025,271.866 356.615,271.588 357.364,271.53Z"/>
<path android:fillColor="#FF000000" android:fillType="nonZero" android:pathData="M377.436,266.363L377.436,265.145L385.185,265.145C384.908,264.855 384.631,264.467 384.355,263.983C384.064,263.512 383.856,263.083 383.718,262.71L384.894,262.71C385.212,263.388 385.6,263.983 386.057,264.495C386.513,265.007 386.956,265.367 387.385,265.574L387.385,266.363L377.436,266.363Z"/>
<path android:fillColor="#FF000000" android:fillType="nonZero" android:pathData="M280.26,261.937L280.395,262.923C279.835,263.035 279.431,263.226 279.185,263.494C278.938,263.774 278.815,264.099 278.815,264.491C278.815,264.951 278.972,265.343 279.297,265.668C279.622,265.981 280.014,266.138 280.484,266.138C280.944,266.138 281.313,265.992 281.604,265.701C281.907,265.41 282.053,265.029 282.053,264.57C282.053,264.391 282.008,264.155 281.941,263.875L282.803,263.987C282.792,264.055 282.792,264.111 282.792,264.144C282.792,264.57 282.904,264.939 283.117,265.276C283.341,265.612 283.677,265.78 284.136,265.78C284.495,265.78 284.797,265.656 285.032,265.421C285.267,265.175 285.379,264.861 285.379,264.469C285.379,264.088 285.267,263.763 285.021,263.517C284.786,263.259 284.427,263.091 283.946,263.024L284.114,262.038C284.775,262.161 285.29,262.43 285.648,262.856C286.018,263.282 286.197,263.808 286.197,264.447C286.197,264.883 286.096,265.287 285.917,265.656C285.727,266.026 285.469,266.306 285.144,266.496C284.819,266.698 284.472,266.799 284.114,266.799C283.766,266.799 283.453,266.698 283.162,266.519C282.882,266.328 282.657,266.06 282.489,265.69C282.377,266.16 282.153,266.53 281.806,266.799C281.459,267.057 281.033,267.191 280.507,267.191C279.812,267.191 279.219,266.933 278.726,266.418C278.244,265.914 277.998,265.264 277.998,264.491C277.998,263.786 278.21,263.203 278.625,262.733C279.051,262.273 279.588,262.005 280.26,261.937Z"/>
<path android:fillColor="#FF000000" android:fillType="nonZero" android:pathData="M278.143,268.694L279.263,268.694L279.263,269.814L278.143,269.814C277.729,269.814 277.404,269.736 277.146,269.59C276.889,269.445 276.698,269.221 276.553,268.896L276.978,268.627C277.068,268.84 277.202,268.985 277.381,269.086C277.561,269.187 277.807,269.243 278.143,269.254L278.143,268.694Z"/>
<path android:fillColor="#FF000000" android:fillType="nonZero" android:pathData="M278.143,274.988L278.143,274.002L284.416,274.002C284.192,273.767 283.968,273.453 283.744,273.061C283.509,272.68 283.341,272.333 283.229,272.03L284.181,272.03C284.439,272.579 284.752,273.061 285.122,273.475C285.491,273.89 285.85,274.181 286.197,274.349L286.197,274.988L278.143,274.988Z"/>
<path android:fillColor="#FF000000" android:fillType="nonZero" android:pathData="M280.249,277.509L280.327,278.539C279.823,278.618 279.454,278.786 279.196,279.066C278.938,279.346 278.815,279.682 278.815,280.074C278.815,280.556 278.995,280.959 279.353,281.284C279.711,281.609 280.182,281.777 280.776,281.777C281.336,281.777 281.784,281.62 282.109,281.306C282.422,280.981 282.59,280.578 282.59,280.063C282.59,279.749 282.523,279.458 282.377,279.2C282.232,278.954 282.041,278.752 281.817,278.607L281.929,277.677L286.052,278.45L286.052,282.438L285.111,282.438L285.111,279.245L282.96,278.808C283.296,279.29 283.464,279.794 283.464,280.32C283.464,281.026 283.218,281.62 282.736,282.101C282.243,282.583 281.627,282.818 280.865,282.818C280.137,282.818 279.51,282.605 278.983,282.18C278.334,281.676 278.009,280.97 278.009,280.074C278.009,279.346 278.21,278.752 278.614,278.293C279.028,277.834 279.566,277.565 280.249,277.509Z"/>
<path android:fillColor="#FF000000" android:fillType="nonZero" android:pathData="M332.515,268.572L331.486,268.572L331.486,262.779C331.743,262.779 332,262.815 332.233,262.913C332.625,263.06 333.017,263.293 333.396,263.611C333.788,263.942 334.229,264.407 334.732,265.02C335.503,265.975 336.128,266.624 336.581,266.955C337.046,267.286 337.475,267.457 337.879,267.457C338.308,267.457 338.663,267.31 338.957,267.004C339.251,266.698 339.398,266.294 339.398,265.804C339.398,265.289 339.239,264.873 338.933,264.567C338.626,264.248 338.185,264.101 337.634,264.089L337.757,262.987C338.577,263.06 339.202,263.342 339.643,263.844C340.072,264.334 340.292,264.995 340.292,265.828C340.292,266.673 340.059,267.335 339.594,267.825C339.129,268.315 338.541,268.56 337.855,268.56C337.5,268.56 337.157,268.498 336.826,268.351C336.483,268.204 336.128,267.972 335.748,267.641C335.381,267.298 334.866,266.747 334.205,265.975C333.666,265.326 333.298,264.91 333.103,264.726C332.907,264.542 332.711,264.395 332.515,264.273L332.515,268.572Z"/>
<path android:fillColor="#FF000000" android:pathData="M269.728,231.777l165.621,0l0,2.547l-165.621,0z"/>
<path android:fillColor="#FF000000" android:pathData="M289.011,234.139l2.065,-0l0,22.125l-2.065,0z"/>
<path android:fillColor="#FF000000" android:pathData="M281.931,233.313l2.065,0l0,13.275l-2.065,0z"/>
<path android:fillColor="#FF000000" android:pathData="M353.793,233.752l2.064,0l0,13.275l-2.064,0z"/>
<path android:fillColor="#FF000000" android:pathData="M345.107,234.08l2.064,0l0,13.276l-2.064,0z"/>
<path android:fillColor="#FF000000" android:pathData="M335.764,234.244l2.065,0l0,22.125l-2.065,0z"/>
<path android:fillColor="#FF000000" android:pathData="M326.096,233.751l2.065,0l0,13.275l-2.065,0z"/>
<path android:fillColor="#FF000000" android:pathData="M316.774,233.043l2.065,0l0,13.275l-2.065,0z"/>
<path android:fillColor="#FF000000" android:pathData="M298.012,233.869l2.065,0l0,13.275l-2.065,0z"/>
<path android:fillColor="#FF000000" android:pathData="M307.334,233.397l2.065,0l0,13.275l-2.065,0z"/>
<path android:fillColor="#FF000000" android:pathData="M428.196,233.26l2.065,0l0,22.125l-2.065,0z"/>
<path android:fillColor="#FF000000" android:pathData="M418.856,234.243l2.065,0l0,13.275l-2.065,0z"/>
<path android:fillColor="#FF000000" android:pathData="M410.334,234.243l2.065,0l0,13.274l-2.065,0z"/>
<path android:fillColor="#FF000000" android:pathData="M400.992,234.571l2.065,0l0,13.275l-2.065,0z"/>
<path android:fillColor="#FF000000" android:pathData="M391.159,233.914l2.064,0l0,13.275l-2.064,0z"/>
<path android:fillColor="#FF000000" android:pathData="M382.309,234.078l2.065,0l0,22.125l-2.065,0z"/>
<path android:fillColor="#FF000000" android:pathData="M372.804,234.079l2.13,0l0,13.275l-2.13,0z"/>
<path android:fillColor="#FF000000" android:pathData="M363.606,234.125l2.065,0l0,13.275l-2.065,0z"/>
<path android:fillColor="#FF000000" android:pathData="M358.382,233.26l2.066,0l0,23.436l-2.066,0z"/>
</vector>

View file

@ -0,0 +1,456 @@
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".plugins.pump.combov2.ComboV2Fragment">
<LinearLayout
android:visibility="gone"
android:id="@+id/combov2_fragment_unpaired_ui"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="5dp"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1.5"
android:gravity="center"
android:text="@string/combov2_not_paired"
android:textSize="14sp" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:paddingTop="10dp"
app:drawableTopCompat="@drawable/ic_combov2" />
</LinearLayout>
<RelativeLayout
android:visibility="visible"
android:id="@+id/combov2_fragment_main_ui"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="5dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1.5"
android:gravity="end"
android:paddingRight="5dp"
android:text="@string/combov2_driver_state_label"
android:textSize="14sp" />
<TextView
android:id="@+id/combov2_driver_state"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="start"
android:paddingLeft="5dp"
android:textColor="@android:color/white"
android:textSize="14sp" />
</LinearLayout>
<View
android:layout_width="fill_parent"
android:layout_height="2dip"
android:layout_marginBottom="5dp"
android:layout_marginLeft="20dp"
android:layout_marginRight="20dp"
android:background="@color/list_delimiter" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1.5"
android:gravity="end"
android:paddingRight="5dp"
android:text="@string/lastconnection_label"
android:textSize="14sp" />
<TextView
android:id="@+id/combov2_last_connection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="start"
android:paddingLeft="5dp"
android:textColor="@android:color/white"
android:textSize="14sp" />
</LinearLayout>
<View
android:layout_width="fill_parent"
android:layout_height="2dip"
android:layout_marginBottom="5dp"
android:layout_marginLeft="20dp"
android:layout_marginRight="20dp"
android:layout_marginTop="5dp"
android:background="@color/list_delimiter" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1.5"
android:gravity="end"
android:paddingRight="5dp"
android:text="@string/combov2_current_activity_label"
android:textSize="14sp" />
<com.joanzapata.iconify.widget.IconTextView
android:id="@+id/combov2_current_activity_desc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="start"
android:paddingLeft="5dp"
android:textColor="@android:color/white"
android:textSize="14sp" />
</LinearLayout>
<ProgressBar
android:id="@+id/combov2_current_activity_progress"
android:layout_width="match_parent"
android:layout_height="4dip"
android:layout_marginLeft="20dp"
android:layout_marginRight="20dp"
android:max="100"
style="@android:style/Widget.ProgressBar.Horizontal"
/>
<View
android:layout_width="fill_parent"
android:layout_height="2dip"
android:layout_marginBottom="5dp"
android:layout_marginLeft="20dp"
android:layout_marginRight="20dp"
android:background="@color/list_delimiter" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1.5"
android:gravity="end"
android:paddingRight="5dp"
android:text="@string/battery_label"
android:textSize="14sp" />
<com.joanzapata.iconify.widget.IconTextView
android:id="@+id/combov2_battery"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="start"
android:paddingLeft="5dp"
android:textColor="@android:color/white"
android:text=""
android:textSize="14sp" />
</LinearLayout>
<View
android:layout_width="fill_parent"
android:layout_height="2dip"
android:layout_marginBottom="5dp"
android:layout_marginLeft="20dp"
android:layout_marginRight="20dp"
android:layout_marginTop="5dp"
android:background="@color/list_delimiter" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1.5"
android:gravity="end"
android:paddingRight="5dp"
android:text="@string/reservoir_label"
android:textSize="14sp" />
<TextView
android:id="@+id/combov2_reservoir"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="start"
android:paddingLeft="5dp"
android:textColor="@android:color/white"
android:textSize="14sp" />
</LinearLayout>
<View
android:layout_width="fill_parent"
android:layout_height="2dip"
android:layout_marginBottom="5dp"
android:layout_marginLeft="20dp"
android:layout_marginRight="20dp"
android:layout_marginTop="5dp"
android:background="@color/list_delimiter" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1.5"
android:gravity="end"
android:paddingRight="5dp"
android:text="@string/lastbolus_label"
android:textSize="14sp" />
<TextView
android:id="@+id/combov2_last_bolus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="start"
android:paddingLeft="5dp"
android:textColor="@android:color/white"
android:textSize="14sp" />
</LinearLayout>
<View
android:layout_width="fill_parent"
android:layout_height="2dip"
android:layout_marginBottom="5dp"
android:layout_marginLeft="20dp"
android:layout_marginRight="20dp"
android:layout_marginTop="5dp"
android:background="@color/list_delimiter" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1.5"
android:gravity="end"
android:paddingRight="5dp"
android:text="@string/tempbasal_label"
android:textSize="14sp" />
<TextView
android:id="@+id/combov2_current_tbr"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="start"
android:paddingLeft="5dp"
android:textColor="@android:color/white"
android:textSize="14sp" />
</LinearLayout>
<View
android:layout_width="fill_parent"
android:layout_height="2dip"
android:layout_marginBottom="5dp"
android:layout_marginLeft="20dp"
android:layout_marginRight="20dp"
android:layout_marginTop="5dp"
android:background="@color/list_delimiter" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1.5"
android:gravity="end"
android:paddingRight="5dp"
android:text="@string/base_basal_rate_label"
android:textSize="14sp" />
<TextView
android:id="@+id/combov2_base_basal_rate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="start"
android:paddingLeft="5dp"
android:textColor="@android:color/white"
android:textSize="14sp" />
</LinearLayout>
<View
android:layout_width="fill_parent"
android:layout_height="2dip"
android:layout_marginBottom="5dp"
android:layout_marginLeft="20dp"
android:layout_marginRight="20dp"
android:layout_marginTop="5dp"
android:background="@color/list_delimiter" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1.5"
android:gravity="end"
android:paddingRight="5dp"
android:text="@string/serialnumber"
android:textSize="14sp" />
<TextView
android:id="@+id/combov2_pump_id"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="start"
android:paddingLeft="5dp"
android:textColor="@android:color/white"
android:textSize="14sp" />
</LinearLayout>
<View
android:layout_width="fill_parent"
android:layout_height="2dip"
android:layout_marginBottom="5dp"
android:layout_marginLeft="20dp"
android:layout_marginRight="20dp"
android:layout_marginTop="5dp"
android:background="@color/list_delimiter" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1.5"
android:gravity="end"
android:paddingRight="5dp"
android:text="@string/bluetooth_address"
android:textSize="14sp" />
<TextView
android:id="@+id/combov2_bluetooth_address"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="start"
android:paddingLeft="5dp"
android:textColor="@android:color/white"
android:textSize="14sp" />
</LinearLayout>
<View
android:layout_width="fill_parent"
android:layout_height="2dip"
android:layout_marginBottom="5dp"
android:layout_marginLeft="20dp"
android:layout_marginRight="20dp"
android:layout_marginTop="5dp"
android:background="@color/list_delimiter" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginBottom="5dp"
android:layout_marginTop="5dp"
android:orientation="horizontal"
android:gravity="center">
<info.nightscout.androidaps.plugins.pump.combov2.ComboV2RTDisplayFrameView
android:id="@+id/combov2_rt_display_frame"
android:layout_width="192dp"
android:layout_height="64dp"
/>
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:paddingTop="10dp"
app:drawableTopCompat="@drawable/ic_combov2" />
</LinearLayout>
</ScrollView>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:orientation="vertical">
<info.nightscout.core.ui.elements.SingleClickButton
android:id="@+id/combov2_refresh_button"
style="@style/ButtonSmallFontStyle"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="0.5"
android:drawableTop="@drawable/ic_actions_refill"
android:paddingLeft="0dp"
android:paddingRight="0dp"
android:text="@string/refresh" />
</LinearLayout>
</RelativeLayout>
</FrameLayout>
</layout>

View file

@ -0,0 +1,256 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".plugins.pump.combov2.activities.ComboV2PairingActivity">
<ScrollView
android:visibility="visible"
android:id="@+id/combov2_pairing_section_initial"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:singleLine="false"
android:text="@string/combov2_pairing_start_steps"
android:textSize="16sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="horizontal">
<Button
android:id="@+id/combov2_start_pairing"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/combov2_start_pairing" />
</LinearLayout>
</LinearLayout>
</ScrollView>
<ScrollView
android:visibility="gone"
android:id="@+id/combov2_pairing_section_main"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:padding="16dp"
android:singleLine="false"
android:text="@string/combov2_pairing_in_progress"
android:textSize="16sp" />
<View
android:layout_width="fill_parent"
android:layout_height="2dip"
android:layout_marginLeft="0dp"
android:layout_marginRight="0dp"
android:background="@color/list_delimiter" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:layout_marginBottom="10dp"
android:padding="16dp"
android:singleLine="false"
android:text="@string/combov2_steps_if_no_connection_during_pairing"
android:textSize="16sp" />
<View
android:layout_width="fill_parent"
android:layout_height="2dip"
android:layout_marginLeft="0dp"
android:layout_marginRight="0dp"
android:background="@color/list_delimiter" />
<TextView
android:id="@+id/combov2_current_pairing_step_desc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_marginBottom="20dp"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
android:gravity="center"
android:padding="16dp"
android:background="@color/pumpStatusBackground"
android:text=""
android:textSize="16sp" />
<ProgressBar
android:id="@+id/combov2_pairing_progress_bar"
style="@android:style/Widget.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="10dp"
android:layout_marginBottom="20dp"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
android:maxHeight="5dp"
android:minHeight="3dp"
android:max="100" />
<LinearLayout
android:id="@+id/combov2_pin_entry_ui"
android:visibility="invisible"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="20dp"
android:gravity="center_horizontal"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:layout_marginBottom="20dp"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
android:text="@string/combov2_enter_pin"
android:textSize="16sp" />
<EditText
android:id="@+id/combov2_pin_entry_edit"
android:layout_width="200dp"
android:layout_height="wrap_content"
android:maxLength="12"
android:inputType="number"
android:digits="0123456789 "
android:minHeight="48dp"
android:hint="@string/combov2_pin_hint"
android:autofillHints="@string/combov2_pin_entry_hint"
android:text="" />
<Button
android:id="@+id/combov2_enter_pin"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/ok" />
</LinearLayout>
<TextView
android:id="@+id/combov2_pin_failure_indicator"
android:visibility="invisible"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
android:gravity="center"
android:text="@string/combov2_pairing_pin_failure"
android:textSize="16sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="horizontal">
<Button
android:id="@+id/combov2_cancel_pairing"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/combov2_cancel_pairing" />
</LinearLayout>
</LinearLayout>
</ScrollView>
<LinearLayout
android:visibility="gone"
android:id="@+id/combov2_pairing_section_finished"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:gravity="center"
android:text="@string/combov2_pairing_finished_successfully"
android:textSize="16sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="horizontal">
<Button
android:id="@+id/combov2_pairing_finished_ok"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/ok" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:visibility="gone"
android:id="@+id/combov2_pairing_section_aborted"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/combov2_pairing_aborted_reason_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:gravity="center"
android:text=""
android:textSize="16sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="horizontal">
<Button
android:id="@+id/combov2_pairing_aborted"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/ok" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
</layout>

View file

@ -0,0 +1,132 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="combov2_plugin_name">Accu-Chek Combo (new plugin)</string>
<string name="combov2_plugin_shortname" translatable="false">COMBOV2</string>
<string name="combov2_plugin_description">Pump integration for Accu-Chek Combo pumps, successor to Ruffy (EXPERIMENTAL)</string>
<string name="combov2_could_not_connect">Could not connect to the pump</string>
<string name="combov2_not_paired">Not paired to a pump</string>
<string name="combov2_pump_terminated_connection">Pump terminated connection</string>
<string name="combov2_warning">Combo warning</string>
<string name="combov2_error">Combo error</string>
<string name="combov2_warning_4">Call hotline for update</string>
<string name="combov2_warning_10">Bluetooth fault; redo pairing</string>
<string name="combov2_error_1">Reservoir empty</string>
<string name="combov2_error_2">Battery empty</string>
<string name="combov2_error_4">Occlusion</string>
<string name="combov2_error_5">End of backup pump operation</string>
<string name="combov2_error_6">Mechanical error</string>
<string name="combov2_error_7">Electronic error</string>
<string name="combov2_error_8">Power interrupt</string>
<string name="combov2_error_9">End of loan pump operation</string>
<string name="combov2_error_10">Reservoir error</string>
<string name="combov2_error_11">Infusion set not primed</string>
<string name="combov2_extended_bolus_not_supported">Extended bolus is not supported</string>
<string name="combov2_title">Accu-Check Combo</string>
<string name="combov2_pair_with_pump_title">Pair with pump</string>
<string name="combov2_unpair_pump_title">Unpair pump</string>
<string name="combov2_driver_state_label">Driver state</string>
<string name="combov2_current_activity_label">Current activity</string>
<string name="bluetooth_address">Bluetooth address</string>
<string name="key_combov2_settings" translatable="false">combov2_settings</string>
<string name="key_combov2_pair_with_pump" translatable="false">combov2_pair_with_pump</string>
<string name="key_combov2_unpair_pump" translatable="false">combov2_unpair_pump</string>
<string name="combov2_start_pairing">Start pairing</string>
<string name="combov2_pairing_in_progress">Combo pairing in progress</string>
<string name="combov2_pairing_start_steps">Steps to perform pairing with your Combo:\n\n
1. On your pump, navigate to the Bluetooth Settings\n
2. Check if a device is already shown as paired; if so, go to the "Delete device" pump screen to delete/unpair that device\n
3. Go to the "Add device" pump screen, and initiate pairing on the pump\n
4. Click on the "Start pairing" button below to initiate pairing in AndroidAPS\n</string>
<string name="combov2_steps_if_no_connection_during_pairing">After a while, the phone\'s name is shown on the pump\'s screen; press CHECK to confirm.\n\n
When pairing is successfully completed, confirm the finished pairing on your pump and return to the main pump screen by pressing the CHECK button twice.\n\n
If no connection is established after more than ~5 minutes:\n\n
1. Press Back or the "Cancel Pairing" button\n
2. Cancel the pairing on the Combo (press both UP and MENU
buttons at the same time to cancel pairing)\n
3. Try to pair again</string>
<string name="combov2_pin_entry_hint">0123456789</string>
<string name="combov2_enter_pin">Enter PIN</string>
<string name="combov2_cancel_pairing">Cancel pairing</string>
<string name="combov2_pin_hint">10-digit PIN</string>
<string name="combov2_pairing_finished_successfully">Successfully paired with Combo</string>
<string name="combov2_pairing_cancelled">Pairing with Combo cancelled by user</string>
<string name="combov2_pairing_combo_scan_timeout_reached">Combo scan timeout reached</string>
<string name="combov2_pairing_failed_due_to_error">Pairing failed due to error: %1$s</string>
<string name="combov2_pairing_aborted_unknown_reasons">Pairing aborted for unknown reasons</string>
<string name="combov2_scanning_for_pump">Scanning for pump</string>
<string name="combov2_establishing_bt_connection">Establishing Bluetooth connection (attempt no. %1$d)</string>
<string name="combov2_pairing_performing_handshake">Performing handshake with pump</string>
<string name="combov2_pairing_pump_requests_pin">Pump requests 10-digit PIN</string>
<string name="combov2_pairing_finishing">Finishing pairing</string>
<string name="combov2_no_connection_for_n_mins">No connection for %1$d minutes</string>
<string name="combov2_less_than_one_minute_ago">Less than 1 minute ago</string>
<string name="combov2_setting_current_pump_time">Setting current pump time</string>
<string name="combov2_setting_current_pump_date">Setting current pump date</string>
<string name="combov2_not_initialized">Not initialized</string>
<string name="combov2_checking_pump">Checking pump</string>
<string name="combov2_ready">Ready</string>
<string name="combov2_suspended">Suspended</string>
<string name="combov2_pump_is_suspended">Pump is suspended</string>
<string name="combov2_executing_command">Executing command</string>
<string name="combov2_getting_basal_profile_cmddesc">Getting basal profile</string>
<string name="combov2_setting_basal_profile_cmddesc">Setting basal profile</string>
<string name="combov2_setting_tbr_cmddesc">Setting %1$d%% TBR for %2$d minutes</string>
<string name="combov2_cancelling_tbr">Cancelling ongoing TBR</string>
<string name="combov2_delivering_bolus_cmddesc">Delivering %1$.1f U bolus</string>
<string name="combov2_fetching_tdd_history_cmddesc">Fetching TDD history</string>
<string name="combov2_updating_pump_datetime_cmddesc">Updating pump datetime</string>
<string name="combov2_updating_pump_status_cmddesc">Updating pump status</string>
<string name="combov2_pairing_pin_failure">PIN did not work. Check if there was a typo. If this
keeps happening, cancel and retry pairing.</string>
<string name="key_combov2_discovery_duration" translatable="false">combov2_discovery_duration</string>
<string name="combov2_discovery_duration">Discovery duration (in seconds)</string>
<string name="key_combov2_verbose_logging" translatable="false">combov2_verbose_logging</string>
<string name="combov2_verbose_logging">Enable verbose Combo logging</string>
<string name="combov2_getting_basal_profile">Getting basal profile; %1$d factor(s) read</string>
<string name="combov2_setting_basal_profile">Setting basal profile; %1$d factor(s) written</string>
<string name="combov2_delivering_bolus">Delivering bolus (%1$.1f of %2$.1f U delivered)</string>
<string name="combov2_cannot_deliver_pump_suspended">Cannot deliver treatment - pump is suspended</string>
<string name="combov2_insufficient_insulin_in_reservoir">Insufficient insulin in reservoir</string>
<string name="combov2_bolus_cancelled">Bolus cancelled</string>
<string name="combov2_bolus_delivery_failed">Bolus delivery failed. It appears no bolus was delivered. To be sure, please check the pump to avoid a double bolus and then bolus again. To guard against bugs, boluses are not automatically retried.</string>
<string name="combov2_bolus_not_delivered">Bolus not delivered</string>
<string name="combov2_cannot_access_pump_data">Cannot access pump data; the pump must be paired again</string>
<string name="combov2_unaccounted_bolus_detected_cancelling_bolus">Unaccounted bolus deliveries detected. Cancelling bolus for safety reasons.</string>
<string name="combov2_incorrect_active_basal_profile">Incorrect active basal profile; profile 1 must be the active one, not profile %1$d</string>
<string name="combov2_unrecognized_alert">Unrecognized Combo alert</string>
<string name="combov2_combo_alert">Combo alert</string>
<string name="combov2_last_bolus">%1$.1f %2$s (%3$s)</string>
<string name="combov2_current_tbr">%1$d%% (%2$d min remaining)</string>
<string name="combov2_current_tbr_less_than_1min">%1$d%% (less than 1 min remaining)</string>
<string name="combov2_load_tdds_cancelled">Loading TDDs cancelled</string>
<string name="combov2_retrieving_tdds_failed">Retrieving TDDs failed</string>
<string name="combov2_no_activity">{fa-bed}</string>
<string name="combov2_battery_empty_indicator">{fa-battery-empty}</string>
<string name="combov2_battery_low_indicator">{fa-battery-quarter}</string>
<string name="combov2_battery_full_indicator">{fa-battery-full}</string>
<string name="combov2_battery_low_warning">Pump battery is low</string>
<string name="combov2_reservoir_low_warning">Pump reservoir level is low</string>
<string name="combov2_setting_tbr_succeeded">Setting TBR succeeded</string>
<string name="combov2_setting_tbr_failed">Setting TBR failed</string>
<string name="combov2_hit_unexpected_tbr_limit">Unexpected limit encountered while adjusting TBR: target percentage was %1$d%%, hit a limit at %1$d%%</string>
<string name="combov2_cannot_set_absolute_tbr_if_basal_zero">Cannot set absolute TBR if base basal rate is zero</string>
<string name="combov2_pair_with_pump_summary">Pair AndroidAPS and Android with a currently unpaired Accu-Chek Combo pump</string>
<string name="combov2_unpair_pump_summary">Unpair AndroidAPS and Android from the currently paired Accu-Chek Combo pump</string>
<string name="combov2_unknown_tbr_detected">Unknown TBR was detected and stopped; percentage: %1$d%%; remaining duration: %2$s</string>
<string name="combov2_connection_error">Connection error: %1$s</string>
<string name="combov2_short_status_last_connection">LastConn: %1$d min ago</string>
<string name="combov2_short_status_alert">Alert: %s</string>
<string name="combov2_short_status_last_bolus">LastBolus: %1$sU @ %2$s</string>
<string name="combov2_short_status_temp_basal">Temp: %s</string>
<string name="combov2_short_status_reservoir">Reserv: %dU</string>
<string name="combov2_short_status_battery_state_empty">empty</string>
<string name="combov2_short_status_battery_state_low">low</string>
<string name="combov2_short_status_battery_state_full">full</string>
<string name="combov2_short_status_battery_state">Batt: %s</string>
<string name="key_combov2_automatic_reservoir_entry" translatable="false">combov2_automatic_reservoir_entry</string>
<string name="key_combov2_automatic_battery_entry" translatable="false">combov2_automatic_battery_entry</string>
<string name="combov2_automatic_reservoir_entry">Autodetect and automatically enter insulin reservoir change</string>
<string name="combov2_automatic_battery_entry">Autodetect and automatically enter battery change</string>
<string name="combov2_note_reservoir_change">Insulin reservoir change inserted automatically by combov2 driver</string>
<string name="combov2_note_battery_change">Battery change inserted automatically by combov2 driver</string>
</resources>

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceCategory
android:key="@string/key_combov2_settings"
android:title="@string/combov2_title"
app:initialExpandedChildrenCount="0">
<Preference
android:key="@string/key_combov2_pair_with_pump"
android:title="@string/combov2_pair_with_pump_title"
android:summary="@string/combov2_pair_with_pump_summary"
android:shouldDisableView="true">
<intent
android:targetClass="info.nightscout.androidaps.plugins.pump.combov2.activities.ComboV2PairingActivity"
android:targetPackage="info.nightscout.androidaps" />
</Preference>
<Preference
android:title="@string/combov2_unpair_pump_title"
android:summary="@string/combov2_unpair_pump_summary"
android:key="@string/key_combov2_unpair_pump"
android:shouldDisableView="true"/>
<SeekBarPreference
android:key="@string/key_combov2_discovery_duration"
android:title="@string/combov2_discovery_duration"
android:min="30"
android:max="300"
android:inputType="number"
android:defaultValue="300" />
<SwitchPreference
android:defaultValue="true"
android:key="@string/key_combov2_automatic_reservoir_entry"
android:title="@string/combov2_automatic_reservoir_entry" />
<SwitchPreference
android:defaultValue="true"
android:key="@string/key_combov2_automatic_battery_entry"
android:title="@string/combov2_automatic_battery_entry" />
<SwitchPreference
android:defaultValue="false"
android:key="@string/key_combov2_verbose_logging"
android:title="@string/combov2_verbose_logging" />
</PreferenceCategory>
</androidx.preference.PreferenceScreen>

View file

@ -22,6 +22,8 @@ include ':plugins:main'
include ':plugins:openhumans' include ':plugins:openhumans'
include ':plugins:sensitivity' include ':plugins:sensitivity'
include ':pump:combo' include ':pump:combo'
include ':pump:combov2'
include ':pump:combov2:comboctl'
include ':pump:dana' include ':pump:dana'
include ':pump:danar' include ':pump:danar'
include ':pump:danars' include ':pump:danars'